Skip to content

fix: clean up stale and orphaned Redis locks - #7951

Open
carlosthe19916 wants to merge 3 commits into
pulp:mainfrom
carlosthe19916:fix/7919-7920-orphan-redis-locks
Open

fix: clean up stale and orphaned Redis locks#7951
carlosthe19916 wants to merge 3 commits into
pulp:mainfrom
carlosthe19916:fix/7919-7920-orphan-redis-locks

Conversation

@carlosthe19916

@carlosthe19916 carlosthe19916 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

When a K8s pod restarts, the new worker gets the same name but Redis still holds locks from the dead predecessor. These orphan locks block resources indefinitely because the periodic cleanup either sees the old AppStatus as still online or it was already deleted without releasing its locks.

Solution

  • Introduce a per-owner lock registry (pulp:owner_locks:{owner} Redis SET), maintained atomically inside the acquire/release Lua scripts, so an owner's locks can be cleaned in O(locks held) without scanning the whole keyspace.
  • Add release_stale_locks_for_self() at worker startup to release Redis locks left under this worker's name by a dead same-name predecessor. (Stale AppStatus rows are cleaned separately by the periodic missing-worker pass — startup only handles the locks.)
  • Add reconcile_orphan_redis_locks() to the periodic cleanup to release locks whose owner has no AppStatus row at all.
  • Make cleanup_redis_locks_for_worker() sweep any remaining locks via the registry, with per-task exception isolation so one Redis failure doesn't abort cleanup for the worker's other tasks. A throttled keyspace SCAN is kept only as a rolling-upgrade fallback for locks acquired before the registry existed.

Fixes #7919, #7920.

Test plan

  • oci-env test -p pulpcore unit -k "orphan_redis_locks"
  • oci-env test -p pulpcore functional -k "test_tasking"

@dkliban dkliban left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The design is solid — startup cleanup, successor detection, retry-safe cleanup, WAITING task preservation, and the test coverage are all well done. The main concern is the SCAN-based lock discovery which creates scaling issues.

SCAN cost at scale

cleanup_locks_for_owner() does a full SCAN of all task:* keys and all pulp:resource_lock:* keys, checking each key's value against the owner. collect_lock_owners() does the same to enumerate all owners. These are O(all_locks_in_redis) operations.

Three code paths trigger these scans:

  1. release_stale_locks_for_self() — runs at every worker startup unconditionally (even with no prior incarnation). At scale-up from 10 to 150 workers, that's 150 concurrent full SCANs.

  2. reconcile_orphan_redis_locks() — runs every ~1000 seconds. Does collect_lock_owners() (full SCAN) plus cleanup_locks_for_owner() for each orphan (another full SCAN each).

  3. cleanup_redis_locks_for_worker() — runs per missing worker during periodic cleanup. One full SCAN per missing worker.

With 200+ locks in production (observed on 2026-07-24), each SCAN touches every key. During the incident with 150 workers crash-looping, this would compound the Redis pressure.

Recommendation: per-owner lock registry

Instead of scanning all keys to find locks for a specific owner, maintain a per-owner set in Redis:

Key: pulp:owner_locks:{worker_name}
Type: Redis SET
Members: all lock keys held by this owner (task locks + resource locks)

Add SADD pulp:owner_locks:{owner} <key> to the acquire_locks Lua script and SREM pulp:owner_locks:{owner} <key> to the release_resource_locks Lua script. This is atomic with the lock operations — no sync issues.

Then cleanup_locks_for_owner() becomes:

keys = redis_conn.smembers(f"pulp:owner_locks:{owner}")
for key in keys:
    # delete-if-owner-matches (Lua for atomicity)
redis_conn.delete(f"pulp:owner_locks:{owner}")

This is O(locks_held_by_owner) instead of O(all_locks_in_redis). For a worker that held 5 locks, it reads 5 keys instead of scanning 200+.

For backward compatibility during rolling upgrades: old workers don't write to the registry, so their locks won't appear in it. Keep the SCAN as a fallback only when the registry set doesn't exist for an owner. After a full rollout, all owners will have registries and SCAN is never used.

Per-task exception handling in cleanup_redis_locks_for_worker

The DB-linked cleanup loop (the for task in tasks block) has a single try/except around the entire loop. After #7945 merged, release_resource_locks raises RedisError. A single Redis failure on one task aborts cleanup for all remaining tasks of that worker.

Recommendation: catch per-task exceptions inside the for loop:

for task in tasks:
    try:
        safe_release_task_locks(task, lock_owner=self.name)
        self._fail_incomplete_task(task, worker_name, "Worker has gone missing.")
    except Exception as e:
        _logger.error("Error cleaning up task %s for worker %s: %s", task.pk, worker_name, e)

Non-atomic delete in cleanup_locks_for_owner

The function uses GET key then DEL key as separate commands. Between them, another worker could acquire the same lock key. Use a Lua script for atomic delete-if-owner-matches:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
end
return 0

What's good

  • Startup cleanup before accepting work — correct placement
  • Successor detection avoids releasing locks belonging to new incarnation
  • Retry-safe design with bool return from cleanup
  • WAITING tasks left reclaimable
  • Comprehensive test coverage (12 unit tests)

@carlosthe19916
carlosthe19916 force-pushed the fix/7919-7920-orphan-redis-locks branch from 43d0ecc to 374bbcf Compare August 11, 2026 16:34
@carlosthe19916
carlosthe19916 force-pushed the fix/7919-7920-orphan-redis-locks branch 2 times, most recently from ad7bb06 to 8c1ec9f Compare August 12, 2026 10:40
@carlosthe19916
carlosthe19916 marked this pull request as draft August 12, 2026 10:40
@carlosthe19916
carlosthe19916 force-pushed the fix/7919-7920-orphan-redis-locks branch from 8c1ec9f to 9a72fe2 Compare August 12, 2026 13:38
  RedisWorker could leave distributed locks stranded in Redis, causing tasks
  to stay WAITING and resources to stay blocked until a manual
  release-task-locks:

  - pulp#7919: a same-name pod restart reused a worker name while Redis still
    held locks from the dead incarnation, so the new worker was blocked by
    its own name's stale locks.
  - pulp#7920: periodic cleanup only handled owners found via
    AppStatus.objects.missing(); locks whose AppStatus row was already
    deleted (graceful shutdown, prior cleanup, kill -9) persisted with no
    DB record pointing at them.

  Introduce a per-owner lock registry (Redis SET pulp:owner_locks:{owner})
  maintained atomically inside the acquire/release Lua scripts, making
  cleanup O(locks-held-by-owner) instead of scanning the whole keyspace. A
  throttled legacy SCAN fallback (pulp:last_legacy_owner_scan) covers locks
  predating the registry during rolling upgrades.

  Add release_stale_locks_for_self() at worker startup (with successor
  detection and a brand-new-worker fast path) and
  reconcile_orphan_redis_locks() to the periodic cleanup (releasing locks
  only for owners with zero AppStatus row, never for stale heartbeats).
  Refactor cleanup_redis_locks_for_worker() to release locks without
  failing WAITING tasks, isolate per-task failures, and retain the
  AppStatus row for retry on failure. Immediate-task locks are given a
  grace period while their task is still incomplete.

  Add unit tests covering the S1-S12 scenario matrix.

Signed-off-by: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com>
  When a Kubernetes pod restarts, the new worker reuses the dead
  predecessor's name while Redis still holds its locks. These orphan
  locks blocked resources indefinitely: the periodic cleanup either saw
  the old AppStatus as still online, or the AppStatus was already deleted
  without its locks being released.

  Cleanup now scales with the work to be done, not with the size of the
  Redis keyspace. Previously each cleanup SCANned every key, which during
  an incident of many crash-looping workers compounded Redis pressure.

  - Per-owner lock registry ( SET), maintained
    atomically inside the acquire/release Lua scripts, so an owner's locks
    are cleaned in O(locks held) via SMEMBERS+Lua, not a keyspace SCAN.
  - Global active-owners SET () so orphan-owner
    enumeration is O(#owners) via SMEMBERS; no steady-state path SCANs the
    keyspace (a SCAN with MATCH still walks every key server-side).
    under this worker's name; brand-new workers skip it, and the legacy
    keyspace SCAN (for pre-registry locks during a rolling upgrade) is
    throttled fleet-wide.
  - reconcile_orphan_redis_locks() in the periodic cleanup releases locks
    whose owner has no AppStatus row at all.
  - cleanup_redis_locks_for_worker() sweeps remaining locks via the
    registry with per-task exception isolation, preserves WAITING tasks,
    and skips release when a live successor reuses the name.
  - Atomic delete-if-owner Lua for the legacy fallback; reclaimed-lock
    counts and startup-cleanup failures are logged for diagnosis.

  Adds unit tests for every cleanup path, including deterministic
  keys-touched proofs that each path's Redis cost is independent of the
  keyspace size, plus a control test that exercises the legacy SCAN.

Signed-off-by: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com>
@carlosthe19916
carlosthe19916 force-pushed the fix/7919-7920-orphan-redis-locks branch from 9a72fe2 to cee82a9 Compare August 12, 2026 19:54
@carlosthe19916

Copy link
Copy Markdown
Contributor Author

I needed to refactor some parts of the code

1. SCAN cost → per-owner registry. Added pulp:owner_locks:{owner} SET, maintained in the acquire/release Lua scripts. cleanup_locks_for_owner() now uses SMEMBERS +
one Lua call (O(locks held), no SCAN). Legacy SCAN kept only as a rolling-upgrade fallback, throttled fleet-wide.

Also: the registry alone didn't fix enumeration — collect_lock_owners() still did scan_iter(match="pulp:owner_locks:*"), and SCAN+MATCH still walks the whole keyspace
(100 keys → 1 round-trip, 50k → 100). Added a global pulp:active_owners SET and switched collect_lock_owners() to SMEMBERS (O(#owners), scan-free). None of the three
paths (startup, reconcile, missing-worker) SCAN the keyspace now.

2. Per-task exception handling. Each task in the cleanup_redis_locks_for_worker loop is now wrapped in try/except; one Redis failure no longer aborts the rest, and the
AppStatus is retained for retry.

3. Non-atomic delete. Replaced GET-then-DEL with delete-if-owner Lua (registry cleanup is one script; legacy fallback uses atomic per-key scripts).

Tests. Added coverage for all three paths plus keys-touched tests asserting each path's Redis cost is independent of keyspace size (100 vs 50k keys, zero SCANs), with a
legacy-SCAN control.

@carlosthe19916
carlosthe19916 marked this pull request as ready for review August 12, 2026 20:07
Previous run failed on a transient network error downloading the
GPG fixture key in the pulp-cli tests (unrelated to this change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@dkliban dkliban left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All concerns from the previous review have been addressed:

  1. Per-owner lock registry — acquire/release Lua scripts now maintain pulp:owner_locks:{owner} SETs and a global pulp:active_owners SET. Cleanup is O(locks_per_owner) via SMEMBERS instead of O(all_locks) via SCAN. Owner enumeration uses SMEMBERS on active_owners — no keyspace scan.

  2. Per-task exception handlingcleanup_redis_locks_for_worker catches per-task exceptions inside the for loop and continues cleaning other tasks. Returns success = False if any step failed.

  3. Atomic delete-if-ownerREDIS_DELETE_STRING_IF_OWNER_SCRIPT and REDIS_SREM_OWNER_SCRIPT Lua scripts for the legacy SCAN fallback.

Additional improvements:

  • Legacy SCAN throttled fleet-wide via LEGACY_OWNER_SCAN_KEY (15 min interval)
  • Brand-new workers skip cleanup entirely (no registry, no prior AppStatus → no SCAN at mass scale-up)
  • WAITING tasks left reclaimable (app_lock detached, not failed)
  • Immediate-task owner grace (checks if task is still running before cleanup)
  • 20 comprehensive unit tests

LGTM.

@dkliban dkliban left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up from adversarial architect review

1. Remove active_owners cleanup from release Lua script

The release script removes the owner from active_owners when SCARD == 0:

if redis.call("scard", owner_registry_key) == 0 then
    redis.call("srem", "pulp:active_owners", lock_owner)
end

This creates a race: between the release script (removes from active_owners) and the next acquire_locks script (adds back), the owner is briefly invisible. If reconcile_orphan_redis_locks runs in that gap, it won't see the owner.

Recommendation: Remove these lines from the release Lua script. Only remove from active_owners during cleanup of dead owners (the CLEANUP_OWNER_LOCKS_SCRIPT already does this). The trade-off is that active_owners may contain live workers that currently hold zero locks, but that's harmless — reconciliation checks for an AppStatus record and skips live workers.

2. Owner identity collision across incarnations

When worker-1 crashes and another worker starts cleanup, the cleanup's final registry sweep runs CLEANUP_OWNER_LOCKS_SCRIPT. If new worker-1 starts and acquires fresh locks between the successor check (Python) and the Lua script execution, the cleanup reads the new registry (same key pulp:owner_locks:worker-1), sees GET key == "worker-1" (matches), and deletes the new worker's live locks.

The successor_online check in cleanup_redis_locks_for_worker guards against this, but it's a Python check followed by a separate Lua execution — there's a TOCTOU window.

Recommendation: Consider adding a generation token (e.g., the AppStatus PK or a UUID) to the registry key: pulp:owner_locks:{worker_name}:{generation}. The cleanup script operates on the dead incarnation's registry key, which is distinct from the new incarnation's key. This eliminates the race entirely.

3. Cleanup script silently skips unknown key types

CLEANUP_OWNER_LOCKS_SCRIPT handles string and set types but silently skips anything else, then unconditionally deletes the registry. A lock with an unexpected type becomes permanently orphaned.

Recommendation: Log or count skipped keys, and consider not deleting the registry if any keys were skipped — or remove only successfully-cleaned keys from the registry individually rather than deleting it wholesale.

@dkliban
dkliban self-requested a review August 13, 2026 13:57

@dkliban dkliban left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the owner identity collision race (point 2 in my previous comment) has a very narrow TOCTOU window — the successor must start, create AppStatus, AND acquire locks in the milliseconds between the Python successor check and the Lua cleanup execution. The existing successor_online guard is sufficient for now. Not worth the complexity of a generation token.

Points 1 (remove active_owners from release script) and 3 (handle unknown key types) still stand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RedisWorker should clean up stale Redis locks from previous incarnation at startup

2 participants