fix(worker): reclaim expired 'processing' leases from departed workers - #3779
fix(worker): reclaim expired 'processing' leases from departed workers#3779sanastasiou wants to merge 3 commits into
Conversation
`_reclaim_own_processing_tasks` matches `worker_id = <own id>`, so startup
recovery can only rescue rows this worker itself abandoned. `worker_id` defaults
to the container hostname (`worker/main.py`: `args.worker_id or
socket.gethostname()`), so every `compose down`/`up` mints a new id and anything
in flight at that moment is stranded under an id that will never return. No
future startup can ever match it. `claimed_at` is already written on claim but
was never read, so nothing else notices either.
Consolidation makes this permanent rather than merely lossy: it is serialised
per bank at claim time (`busy_bank_ids` in `claim_tasks`), so one corpse in
'processing' marks its bank busy forever and no consolidation for that bank can
be claimed again. With the default `{"consolidation": 2}` reservation those
slots then sit idle for good.
Observed in production on a single-container deployment: two consolidations
stranded on 2026-07-05 were still 'processing' 50 days later, and the worker had
been running at half capacity the whole time:
slots=2/4 | reserved: [consolidation=0/2(avail=2)] | shared=2/2(avail=0)
`hindsight-admin worker-status` showed six departed container ids holding nine
rows. `decommission-worker` cleared them and slots went to 4/4 immediately — so
the recovery primitive already exists, it just is not reachable automatically.
This adds a startup sweep alongside the existing own-rows recovery: reclaim
'processing' rows whose `claimed_at` is older than
`HINDSIGHT_API_WORKER_ORPHAN_LEASE_SECONDS` (default 86400) regardless of
owner. This worker's own rows are excluded (the caller above already handles
them with retry/fail accounting), batch API rows are excluded for the same
reason as the own-rows path, and setting the lease to 0 disables the sweep. The
log line names the departed owners and points at `HINDSIGHT_API_WORKER_ID` so
operators fix the cause rather than rely on the sweep.
Relates to vectorize-io#3709 (worker id defaults to hostname), vectorize-io#3594 (zombie operations)
and vectorize-io#3720 (detect stuck tasks by stage progress). vectorize-io#3448 fixed the same class for
the case where the worker id is stable; this covers the case where it is not.
Tests: three cases added to `TestWorkerRecovery` — an expired lease from a
departed worker is reclaimed, a fresh lease from another live worker is left
alone, and lease=0 opts out. `tests/test_worker.py`: 110 passed, ruff clean.
(2 unrelated setup errors locally from a missing optional `sentence_transformers`
dependency in a minimal env.)
Signed-off-by: sanastasiou <stef.anastasiou@protonmail.ch>
Strix Security ReviewWarning This pull request has 2 commits after the last Strix review ( 1 open security finding on this PR:
Review summaryReviewed the four changed files in this PR (config, worker entrypoint, worker poller, and tests). The new startup orphan sweep is parameterized and correctly scopes out the caller's own rows and batch-API rows, and the configuration plumbing is benign. The substantive concern is in Fixed the findings? re-run the review, or tag Updated for Reviewed by Strix |
| WHERE status = 'processing' | ||
| AND worker_id IS NOT NULL | ||
| AND worker_id <> $1 | ||
| AND claimed_at IS NOT NULL | ||
| AND claimed_at < now() - make_interval(secs => $2) | ||
| AND result_metadata->>'batch_id' IS NULL |
There was a problem hiding this comment.
🔵 Race condition in orphan-lease reclaim resets live long-running consolidations to pending, enabling concurrent duplicate execution
Severity: MEDIUM · CWE-362
The startup reclaim sweep added by this PR (_reclaim_expired_processing_tasks) decides whether a processing operation has been abandoned using only claimed_at, a timestamp written once at claim time and never refreshed. Long-running jobs do not update claimed_at; they emit progress heartbeats that bump updated_at instead (MemoryEngine._write_operation_progress, called by the consolidator at phase/batch boundaries).
Consolidation has no wall-clock timeout (_wall_timeout_for returns None for consolidation) and HINDSIGHT_API_CONSOLIDATION_MAX_MEMORIES_PER_ROUND may be set to 0 (unlimited), so a single consolidation operation can legitimately run past the default 24-hour lease. When another worker starts while that job is still running — a rolling deploy, scale-out, or recovery of a peer — the sweep matches the row (status = 'processing', a different worker_id, stale claimed_at) and resets it to pending, clearing worker_id and claimed_at.
Nothing re-checks ownership mid-flight, and the claim predicates (bank_serialization_sql / document_serialization_sql) enforce exclusivity keyed on status = 'processing'. Once the row is back to pending, a second worker can claim the same bank/document and run the operation concurrently with the first worker, causing duplicate LLM inference and competing writes to the same bank or document (lost updates / duplicated memories). Terminal writes are also unfenced: _mark_completed matches only operation_id + status = 'processing' (no worker_id guard), so the original worker can mark the row completed even after a second worker has re-claimed and is still running it.
| WHERE status = 'processing' | |
| AND worker_id IS NOT NULL | |
| AND worker_id <> $1 | |
| AND claimed_at IS NOT NULL | |
| AND claimed_at < now() - make_interval(secs => $2) | |
| AND result_metadata->>'batch_id' IS NULL | |
| WHERE status = 'processing' | |
| AND worker_id IS NOT NULL | |
| AND worker_id <> $1 | |
| AND claimed_at IS NOT NULL | |
| AND claimed_at < now() - make_interval(secs => $2) | |
| AND updated_at < now() - make_interval(secs => $2) | |
| AND result_metadata->>'batch_id' IS NULL |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Race condition in orphan-lease reclaim resets live long-running consolidations to pending, enabling concurrent duplicate execution
Severity: MEDIUM
CWE: CWE-362
The startup reclaim sweep added by this PR (`_reclaim_expired_processing_tasks`) decides whether a `processing` operation has been abandoned using only `claimed_at`, a timestamp written once at claim time and never refreshed. Long-running jobs do not update `claimed_at`; they emit progress heartbeats that bump `updated_at` instead (`MemoryEngine._write_operation_progress`, called by the consolidator at phase/batch boundaries).
Consolidation has no wall-clock timeout (`_wall_timeout_for` returns `None` for `consolidation`) and `HINDSIGHT_API_CONSOLIDATION_MAX_MEMORIES_PER_ROUND` may be set to `0` (unlimited), so a single consolidation operation can legitimately run past the default 24-hour lease. When another worker starts while that job is still running — a rolling deploy, scale-out, or recovery of a peer — the sweep matches the row (`status = 'processing'`, a different `worker_id`, stale `claimed_at`) and resets it to `pending`, clearing `worker_id` and `claimed_at`.
Nothing re-checks ownership mid-flight, and the claim predicates (`bank_serialization_sql` / `document_serialization_sql`) enforce exclusivity keyed on `status = 'processing'`. Once the row is back to `pending`, a second worker can claim the same bank/document and run the operation concurrently with the first worker, causing duplicate LLM inference and competing writes to the same bank or document (lost updates / duplicated memories). Terminal writes are also unfenced: `_mark_completed` matches only `operation_id` + `status = 'processing'` (no `worker_id` guard), so the original worker can mark the row `completed` even after a second worker has re-claimed and is still running it.
Location: hindsight-api-slim/hindsight_api/worker/poller.py:1207-1212
Context: Require the progress heartbeat (updated_at) to also be stale before reclaiming
```
// Before:
WHERE status = 'processing'
AND worker_id IS NOT NULL
AND worker_id <> $1
AND claimed_at IS NOT NULL
AND claimed_at < now() - make_interval(secs => $2)
AND result_metadata->>'batch_id' IS NULL
// After:
WHERE status = 'processing'
AND worker_id IS NOT NULL
AND worker_id <> $1
AND claimed_at IS NOT NULL
AND claimed_at < now() - make_interval(secs => $2)
AND updated_at < now() - make_interval(secs => $2)
AND result_metadata->>'batch_id' IS NULL
```
How to fix:
Base liveness on the existing progress heartbeat rather than the static claim time: require the row's `updated_at` to also be older than the lease before reclaiming (the progress heartbeat bumps `updated_at` at phase/batch boundaries), or refresh `claimed_at` in `_write_operation_progress` on each heartbeat. Add `worker_id` (or an explicit lease token) fencing to the terminal-write statements in `_mark_completed` / `_mark_failed` so a worker cannot complete or fail an operation it no longer owns. Additionally, consider bounding consolidation with a wall-clock timeout (as `retain` already is) so a single operation cannot outlive the orphan lease.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
…claiming Addresses the Strix review finding on vectorize-io#3779 (CWE-362). The sweep judged abandonment on `claimed_at` alone. That column is written once at claim time and never refreshed — but a long-running job DOES emit progress heartbeats, and those bump `updated_at` (`MemoryEngine._write_operation_progress`, memory_engine.py:3579, called by the consolidator at phase/batch boundaries). Consolidation is not wall-clock bounded (`_wall_timeout_for` returns None for everything except retain) and `CONSOLIDATION_MAX_MEMORIES_PER_ROUND` may be 0, so a healthy consolidation can legitimately outlive a 24 h lease. Keying only on `claimed_at` would reset such a row to 'pending' while its worker is still executing; since the claim predicates enforce exclusivity through `status = 'processing'`, a second worker could then claim the same bank and run it concurrently — duplicate LLM inference and competing writes. Now both timestamps must be stale: a job that is merely slow is never touched, only one that is old AND silent. Note the existing test had to change with it, and that is the point: it inserted a stale `claimed_at` but let `updated_at` default to now(), so under the new predicate it correctly reclaimed nothing. Real abandoned rows have both stale — the two 50-day production corpses had created == updated == the claim instant, never touched again. The fixture now matches reality. Added `test_slow_but_live_job_with_fresh_heartbeat_is_not_reclaimed`: old claim, fresh heartbeat, different worker — must NOT be reclaimed. tests/test_worker.py: 111 passed, ruff clean. The review also flagged unfenced terminal writes (`_mark_completed` matches operation_id + status without a worker_id guard) and unbounded consolidation. Both are pre-existing and outside this PR's scope; happy to follow up separately if maintainers want them addressed here. Signed-off-by: sanastasiou <stef.anastasiou@protonmail.ch>
|
Good catch — this is a real race and I've taken the suggested fix. Pushed in I verified both premises in the source before accepting rather than taking them on trust:
So the sweep now requires both timestamps to be stale — a job that is merely slow is never touched, only one that is old and silent. One detail worth surfacing: my existing Added On the two adjacent points in the report — unfenced terminal writes ( |
koriyoshi2041
left a comment
There was a problem hiding this comment.
One small correctness issue in the diagnostic receipt: PostgreSQL UPDATE ... RETURNING exposes the updated tuple. Because this statement sets worker_id = NULL before RETURNING operation_id, worker_id, operation_type, every returned worker_id is NULL, so owners = sorted({str(r["worker_id"]) ...}) will log owners: None rather than the departed container IDs.
The reclaim behavior itself still looks sound, but this loses the exact operator signal the warning promises. A regression could assert that caplog contains dead-container-abc123. One repair is a candidate CTE that selects/locks the stale rows and preserves their old owner IDs, followed by UPDATE ... FROM candidates ... RETURNING candidates.worker_id; alternatively, return only fields whose post-update values are intended.
The reclaim warning exists to tell the operator which worker ids to pin via HINDSIGHT_API_WORKER_ID. It could never do that: the statement sets worker_id = NULL and then asks for it back, and PostgreSQL RETURNING yields the POST-update tuple, so every row came back NULL and the log read "owners: None" — accurate, useless, and indistinguishable from a bug in the sweep itself. Capture the owner in a CTE that selects the stale rows first, then update FROM it and return the CTE's pre-update worker_id. Predicates and reclaim behaviour are unchanged. The CTE takes FOR UPDATE SKIP LOCKED: this runs at worker startup, so two containers coming up together would otherwise contend on the same rows, one blocking until the other commits. With SKIP LOCKED each reclaims a disjoint set and neither waits — and a row already locked by a live worker is left alone, which is the desired reading anyway. Test asserts the warning names 'dead-container-abc123' and does not say "owners: None"; verified it FAILS against the previous RETURNING. Thanks to @koriyoshi2041 for catching this on PR vectorize-io#3779.
|
Good catch — confirmed and fixed in You're right about the mechanism: which is the worst version of this bug: accurate, useless, and indistinguishable from the sweep having matched a row with a genuinely NULL owner. Took the candidate-CTE option you suggested: WITH candidates AS (
SELECT operation_id, worker_id
FROM {table}
WHERE status = 'processing'
...
FOR UPDATE SKIP LOCKED
)
UPDATE {table} AS t
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
FROM candidates c
WHERE t.operation_id = c.operation_id
RETURNING c.operation_id, c.worker_id AS previous_worker_id, t.operation_typePredicates and reclaim behaviour are unchanged; only the reported owner moves. One addition beyond the repair: the CTE takes Regression test is the one you described — asserts |
koriyoshi2041
left a comment
There was a problem hiding this comment.
Re-checked the diagnostic repair at ced4ee0: the candidate CTE preserves the pre-update owner, keeps the original stale-row predicates, and FOR UPDATE SKIP LOCKED makes concurrent startup sweeps select disjoint rows. The regression now observes the operator-facing contract directly (dead-container-abc123 present, owners: None absent), so the issue I raised is resolved at this head. I did not re-evaluate the broader lease/fencing design in this follow-up.
Problem
_reclaim_own_processing_tasksmatchesworker_id = <own id>, so startup recovery can only rescue rows this worker abandoned. Butworker_iddefaults to the container hostname:In Docker every
compose down/upmints a new container id, so anything in flight at that moment is stranded inprocessingunder an id that will never return — and no future startup can match it.claimed_atis already recorded on claim but is never read, so nothing else notices either.Consolidation turns that from lossy into permanent. It is serialised per bank at claim time (
busy_bank_idsinclaim_tasks), so a single corpse inprocessingmarks its bank busy forever and no consolidation for that bank can ever be claimed again. With the default{"consolidation": 2}reservation, those slots then sit idle indefinitely.Evidence from production
Single-container deployment, v0.9.1. Two consolidations stranded on 2026-07-05 were still
processing50 days later:The worker had been at half capacity that entire time — two slots reserved for work that could never be claimed, while retain queued behind the two shared slots:
hindsight-admin worker-statusshowed six departed container ids holding nine rows in total — one per historical redeploy.hindsight-admin decommission-worker <id>cleared them and slots went to4/4immediately:So the recovery primitive already exists and works — it just isn't reachable automatically, and an operator has no reason to look until something is visibly broken. Nothing alerts: the API stays healthy, the queue keeps moving on the shared slots, and the only symptom is throughput quietly halving.
Worth noting the API can't help either — both affordances refuse
processingrows:Change
A startup sweep alongside the existing own-rows recovery: reclaim
processingrows whoseclaimed_atis older thanHINDSIGHT_API_WORKER_ORPHAN_LEASE_SECONDS(default86400), regardless of owner.Guards:
0disables the sweep entirely.The log line names the departed owners and points at
HINDSIGHT_API_WORKER_ID, so operators fix the root cause rather than lean on the sweep:Relationship to existing issues
not_planned).I'd also gently suggest
warn_if_container_default_worker_iddeserves to be louder. It fired on every one of our boots for 50 days and we sailed past it — a warning that predicts a 50 % capacity loss is arguably an error, or at least worth repeating periodically rather than once at startup.Tests
Three cases added to
TestWorkerRecovery, following the existingtest_recover_own_tasks_does_not_affect_other_workerspattern:test_expired_lease_from_departed_worker_is_reclaimedtest_fresh_lease_from_another_worker_is_left_alonetest_orphan_sweep_is_disabled_when_lease_is_zerotests/test_worker.py: 110 passed,ruff checkclean. (2 unrelated setup errors locally — missing optionalsentence_transformersin a minimal env.)Update — heartbeat-aware liveness (
7d96b38)The Strix review caught a real race in the first version, and I've taken the fix.
The sweep originally judged abandonment on
claimed_atalone. That column is written once at claim time and never refreshed — but a long-running job does emit progress heartbeats, and those land onupdated_at(MemoryEngine._write_operation_progress,memory_engine.py:3579). Combined with consolidation having no wall-clock ceiling (_wall_timeout_forreturnsNonefor everything except retain), a healthy consolidation can legitimately outlive a 24 h lease — and resetting it topendingmid-flight would let a second worker claim the same bank and run it concurrently.The sweep now requires both timestamps to be stale. A job that is merely slow is never touched; only one that is old and silent.
Worth noting: this change made my own
test_expired_lease_from_departed_worker_is_reclaimedfail, and that was the test being unfaithful rather than the fix being wrong. It set a staleclaimed_atbut letupdated_atdefault tonow(). Real abandoned rows have both stale — the two production corpses hadcreated == updated == 2026-07-05T14:31:37, never touched again, because no heartbeat ever ran. The fixture now matches.New test:
test_slow_but_live_job_with_fresh_heartbeat_is_not_reclaimed— 50-day-old claim, heartbeat seconds ago, different worker → must not be reclaimed.tests/test_worker.py: 111 passed, ruff clean.Two adjacent points from the same review — unfenced terminal writes (
_mark_completedmatchesoperation_id+statuswith noworker_idguard) and unbounded consolidation — I agree with, but both are pre-existing and independent of this change, so they're left out to keep the diff reviewable. Happy to fold either in if maintainers prefer.