feat(scheduler): recover in-flight jobs on worker death#320
Conversation
A surviving worker requeues a crashed worker's Running jobs within ~30s of the missed heartbeat instead of waiting the full timeout. The scheduler claims execution under its real worker_id (set_claim_owner); a new maintenance step finds claims whose owner is no longer live (reap_orphaned_jobs) and atomically transfers each (reclaim_execution) so concurrent survivors never double-rescue, then routes the job through the normal retry/dead-letter path. Cross-backend (SQLite/Postgres/Redis), wired in all three SDKs. No API or wire change.
|
Warning Review limit reached
More reviews will be available in 42 minutes and 27 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds fast in-flight recovery for jobs when a worker dies: surviving schedulers detect ChangesFast in-flight recovery on worker death
Sequence Diagram(s)sequenceDiagram
participant SurvivingWorker
participant Scheduler
participant Storage
participant DB
rect rgba(255, 165, 0, 0.5)
note over SurvivingWorker,DB: reap_stale maintenance tick
end
SurvivingWorker->>Scheduler: reap_stale(now)
Scheduler->>Storage: list_workers()
Storage-->>Scheduler: worker heartbeats
Scheduler->>Scheduler: filter by DEAD_WORKER_THRESHOLD_MS → live_ids
Scheduler->>Storage: reap_orphaned_jobs(live_ids, now)
Storage->>DB: SELECT execution_claims WHERE worker_id NOT IN live_ids AND job status=Running
DB-->>Storage: [(job, dead_owner), ...]
Storage-->>Scheduler: orphaned jobs
loop each orphaned job
Scheduler->>Storage: reclaim_execution(job_id, dead_owner, claim_owner)
Storage->>DB: UPDATE execution_claims SET worker_id=claim_owner WHERE worker_id=dead_owner
DB-->>Storage: rows_affected
alt reclaim succeeded
Scheduler->>Scheduler: handle_result(JobResult::Failure{should_retry:true})
else another scheduler won
Scheduler->>Scheduler: skip
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/taskito-core/src/scheduler/mod.rs`:
- Around line 211-216: The claim-owner format in
set_claim_owner/reap_orphaned_jobs/reclaim_execution is ambiguous because worker
IDs are currently stored with a ":"-delimited timestamp and later parsed by
splitting on the first ":". Update the scheduler logic so worker IDs cannot
contain ":" (validate/reject in set_claim_owner or register_worker/heartbeat),
or switch the claim owner storage/parsing to an unambiguous representation used
consistently by reap_orphaned_jobs and reclaim_execution.
In `@crates/taskito-core/src/storage/redis_backend/locks.rs`:
- Around line 8-21: The RECLAIM_CLAIM_SCRIPT in locks.rs is extracting the claim
owner with the first colon, which breaks arbitrary owner strings from
claim_execution and set_claim_owner such as host:pid. Update the Lua parsing
logic to recover the owner using the last delimiter, or otherwise store owner
and timestamp separately, so the owner comparison against ARGV[2] remains
accurate and reclaim/orphan checks do not misclassify live claims.
In `@crates/taskito-core/tests/rust/storage_tests.rs`:
- Line 463: The cleanup call in storage_tests currently discards the result of
complete, which can hide a backend failure and leave the job in Running for
later tests. Update the cleanup in the storage test around s.complete for job.id
to assert success instead of ignoring the return value, so the test fails
immediately if job completion during teardown does not work.
- Around line 466-516: The execution claim owner format is ambiguous because
Redis encodes claims as owner:timestamp and recovery decodes them by splitting
on “:”, so owners containing a colon will be truncated. Fix this by either
rejecting “:” in claim owner IDs at the Storage boundary or changing the claim
serialization/parsing used by claim_execution, reclaim_execution, and
reap_orphaned_jobs to an unambiguous encoding that preserves the full owner
string.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8901aa55-5a2a-44cc-b144-f70bb52a3b57
📒 Files selected for processing (17)
CHANGELOG.mdcrates/taskito-core/src/scheduler/maintenance.rscrates/taskito-core/src/scheduler/mod.rscrates/taskito-core/src/scheduler/poller.rscrates/taskito-core/src/storage/diesel_common/jobs.rscrates/taskito-core/src/storage/diesel_common/locks.rscrates/taskito-core/src/storage/mod.rscrates/taskito-core/src/storage/postgres/jobs.rscrates/taskito-core/src/storage/redis_backend/jobs/maintenance.rscrates/taskito-core/src/storage/redis_backend/locks.rscrates/taskito-core/src/storage/sqlite/jobs.rscrates/taskito-core/src/storage/traits.rscrates/taskito-core/tests/rust/storage_tests.rscrates/taskito-java/src/worker.rscrates/taskito-node/src/worker.rscrates/taskito-python/src/py_queue/worker.rsdocs/content/docs/resources/changelog.mdx
The Redis claim value is "{owner}:{ts}"; orphan detection and the reclaim Lua split on the first ':', truncating owners like "host:pid" and misclassifying live claims. Parse the owner from the LAST ':' (the timestamp is a numeric suffix) in both paths. Also fail the reap_stale cleanup (.unwrap) instead of dropping it. Adds colon-owner regression cases to the contract suite.
Summary
When a worker process crashes mid-job, its job stays
Runningand is only recovered by the time-based stale reaper — waiting the fulltimeout_ms(default 5min). This adds fast recovery: a surviving worker requeues a dead worker's in-flight jobs within ~30s of the missed heartbeat, via the existing retry / dead-letter path.How
worker_id. The scheduler previously claimed execution under a constant"scheduler"; it now claims under its processworker_id(newScheduler::set_claim_owner, called by all three SDK bindings, which already register + heartbeat that id).recover_orphaned_jobs(runs alongside the existing reaper): builds the live-owner set fromlist_workers()(fresh heartbeats) plus self (self-rescue guard), thenreap_orphaned_jobs(live)returnsRunningjobs whose claim owner is not live, paired with the dead owner.reclaim_execution(job, dead_owner, self)— a guarded claim transfer (UPDATE … WHERE worker_id = expected_owner, serialized by thejob_idPK / a Redis Lua CAS). This is required becauseretry()has no status guard and would otherwise double-increment under concurrent schedulers.timed_out: false— a crash is not a deadline breach, so it doesn't fire theon_timeouthook.reap_stale_jobsstays as the timeout backstop.Storage surface (all 3 backends)
Two new
Storagemethods:reap_orphaned_jobs(live_owner_ids, now) -> Vec<(Job, owner)>(read-only) andreclaim_execution(job, expected_owner, new_owner) -> bool. Diesel uses two indexed queries (no new joinable-tables declaration); Redis adds aRECLAIM_CLAIM_SCRIPTLua CAS and iterates the bounded Running set.Testing
reap_stale_jobstest):reap_stale_jobs,reclaim_execution,reap_orphaned_jobs— run on SQLite + real Redis (verified locally) + Postgres-gated.cargo test --workspace+--features postgres/--features redis,cargo clippy(default/pg/redis) clean; Python suite 1077 passed, 4 skipped.Out of scope (deliberate)
The pre-existing concurrent-scheduler race in the timeout reaper is untouched (the new path is gated). A live-but-hung worker (>30s GC pause) may have its jobs rescued and double-run — inherent at-least-once tradeoff, same as the timeout reaper today.
Summary by CodeRabbit
New Features
Bug Fixes