Skip to content

feat(scheduler): recover in-flight jobs on worker death#320

Merged
pratyush618 merged 3 commits into
masterfrom
feat/worker-death-recovery
Jun 28, 2026
Merged

feat(scheduler): recover in-flight jobs on worker death#320
pratyush618 merged 3 commits into
masterfrom
feat/worker-death-recovery

Conversation

@pratyush618

@pratyush618 pratyush618 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

When a worker process crashes mid-job, its job stays Running and is only recovered by the time-based stale reaper — waiting the full timeout_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

  • Claim owner = real worker_id. The scheduler previously claimed execution under a constant "scheduler"; it now claims under its process worker_id (new Scheduler::set_claim_owner, called by all three SDK bindings, which already register + heartbeat that id).
  • New maintenance step recover_orphaned_jobs (runs alongside the existing reaper): builds the live-owner set from list_workers() (fresh heartbeats) plus self (self-rescue guard), then reap_orphaned_jobs(live) returns Running jobs whose claim owner is not live, paired with the dead owner.
  • Atomic election. Each orphan is requeued only by the survivor that wins reclaim_execution(job, dead_owner, self) — a guarded claim transfer (UPDATE … WHERE worker_id = expected_owner, serialized by the job_id PK / a Redis Lua CAS). This is required because retry() 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 the on_timeout hook.
  • Time-based reap_stale_jobs stays as the timeout backstop.

Storage surface (all 3 backends)

Two new Storage methods: reap_orphaned_jobs(live_owner_ids, now) -> Vec<(Job, owner)> (read-only) and reclaim_execution(job, expected_owner, new_owner) -> bool. Diesel uses two indexed queries (no new joinable-tables declaration); Redis adds a RECLAIM_CLAIM_SCRIPT Lua CAS and iterates the bounded Running set.

Testing

  • Cross-backend contract tests (also added the previously-missing reap_stale_jobs test): reap_stale_jobs, reclaim_execution, reap_orphaned_jobs — run on SQLite + real Redis (verified locally) + Postgres-gated.
  • Scheduler tests: dead-owner job requeued (retry_count=1, claim cleared); self-claim left Running (self-guard); retries-exhausted orphan → DLQ.
  • 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

    • Added faster recovery for jobs when a worker dies, so surviving workers can pick up running jobs much sooner instead of waiting for the full timeout.
    • Improved recovery behavior across supported runtimes and storage backends.
  • Bug Fixes

    • Reduced the chance of duplicate job recovery by coordinating ownership checks during reclaim.
    • Execution claims are now attributed to the active worker, improving dead-worker handling.

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.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pratyush618, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6d3f1cc-d5f6-41cb-885f-1f29081dd0e5

📥 Commits

Reviewing files that changed from the base of the PR and between df8e089 and d7f146b.

📒 Files selected for processing (3)
  • crates/taskito-core/src/storage/redis_backend/jobs/maintenance.rs
  • crates/taskito-core/src/storage/redis_backend/locks.rs
  • crates/taskito-core/tests/rust/storage_tests.rs
📝 Walkthrough

Walkthrough

Adds fast in-flight recovery for jobs when a worker dies: surviving schedulers detect Running jobs whose claiming worker is no longer alive, atomically reclaim execution ownership via new reclaim_execution and reap_orphaned_jobs storage primitives, and route those jobs through normal retry/dead-letter handling within ~30s of a missed heartbeat. Worker IDs are now bound as scheduler claim owners across Python, Node, and Java runtimes.

Changes

Fast in-flight recovery on worker death

Layer / File(s) Summary
Storage trait contracts
crates/taskito-core/src/storage/traits.rs
Adds reap_orphaned_jobs (returns (Job, dead_owner) pairs for Running jobs with non-live claim owners) and reclaim_execution (atomically transfers a claim from expected_owner to new_owner) to the Storage trait.
Backend implementations: Diesel, Redis, forwarding
crates/taskito-core/src/storage/diesel_common/jobs.rs, crates/taskito-core/src/storage/diesel_common/locks.rs, crates/taskito-core/src/storage/redis_backend/locks.rs, crates/taskito-core/src/storage/redis_backend/jobs/maintenance.rs, crates/taskito-core/src/storage/mod.rs, crates/taskito-core/src/storage/postgres/jobs.rs, crates/taskito-core/src/storage/sqlite/jobs.rs
Implements both new methods for Diesel (SQL NOT IN filter for orphans, conditional UPDATE for reclaim) and Redis (Lua script for atomic reclaim, claim-prefix parsing for orphan detection); wires both through impl_storage! macro and StorageBackend delegation.
Scheduler claim_owner field and API
crates/taskito-core/src/scheduler/mod.rs, crates/taskito-core/src/scheduler/poller.rs
Adds claim_owner: String to Scheduler, initializes it from SCHEDULER_CLAIM_OWNER, exposes set_claim_owner setter, and changes claim_for_dispatch to use self.claim_owner instead of the hardcoded constant.
recover_orphaned_jobs in maintenance loop
crates/taskito-core/src/scheduler/maintenance.rs
Integrates recover_orphaned_jobs into reap_stale: builds a live-worker set using DEAD_WORKER_THRESHOLD_MS, iterates orphaned jobs, atomically reclaims ownership via reclaim_execution, and routes successful claims through handle_result as retryable failures.
Worker ID wired as claim owner across runtimes
crates/taskito-python/src/py_queue/worker.rs, crates/taskito-node/src/worker.rs, crates/taskito-java/src/worker.rs
Python, Node, and Java workers call set_claim_owner with the generated worker_id; Python also moves worker_id resolution before Scheduler construction and removes the later override.
Tests
crates/taskito-core/tests/rust/storage_tests.rs, crates/taskito-core/src/scheduler/mod.rs
Adds storage-level tests for reclaim_execution ownership rules and reap_orphaned_jobs edge cases; adds scheduler-level tests for requeue-on-dead-worker, skip-own-claims, and dead-letter-when-exhausted behaviors.
Changelog
CHANGELOG.md, docs/content/docs/resources/changelog.mdx
Documents the new fast in-flight recovery feature under Unreleased → Added in both changelog files.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested labels

tests

🐇 A worker fell silent, its heartbeat gone cold,
But the scheduler peeked at the claims it once told.
"That job is now mine!" cried the rabbit with cheer,
Reclaiming the work with a Lua script clear.
No double-rescue, no timeout to wait—
Fast recovery hops through the scheduler's gate! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: scheduler recovery of in-flight jobs after worker death.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/worker-death-recovery

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3434381 and df8e089.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • crates/taskito-core/src/scheduler/maintenance.rs
  • crates/taskito-core/src/scheduler/mod.rs
  • crates/taskito-core/src/scheduler/poller.rs
  • crates/taskito-core/src/storage/diesel_common/jobs.rs
  • crates/taskito-core/src/storage/diesel_common/locks.rs
  • crates/taskito-core/src/storage/mod.rs
  • crates/taskito-core/src/storage/postgres/jobs.rs
  • crates/taskito-core/src/storage/redis_backend/jobs/maintenance.rs
  • crates/taskito-core/src/storage/redis_backend/locks.rs
  • crates/taskito-core/src/storage/sqlite/jobs.rs
  • crates/taskito-core/src/storage/traits.rs
  • crates/taskito-core/tests/rust/storage_tests.rs
  • crates/taskito-java/src/worker.rs
  • crates/taskito-node/src/worker.rs
  • crates/taskito-python/src/py_queue/worker.rs
  • docs/content/docs/resources/changelog.mdx

Comment thread crates/taskito-core/src/scheduler/mod.rs
Comment thread crates/taskito-core/src/storage/redis_backend/locks.rs
Comment thread crates/taskito-core/tests/rust/storage_tests.rs Outdated
Comment thread crates/taskito-core/tests/rust/storage_tests.rs
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.
@pratyush618
pratyush618 merged commit 35dc225 into master Jun 28, 2026
37 checks passed
@pratyush618
pratyush618 deleted the feat/worker-death-recovery branch June 28, 2026 07:36
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.

1 participant