Skip to content

fix(worker): drain finalization on graceful shutdown (0.6) - #643

Merged
hardbyte merged 4 commits into
release/0.6.6from
brian/fix-graceful-restart-duplicate-0.6
Nov 6, 2026
Merged

hardbyte merged 4 commits into
release/0.6.6from
brian/fix-graceful-restart-duplicate-0.6

Conversation

@hardbyte

@hardbyte hardbyte commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Problem

A graceful restart could run a finished job a second time. On a rolling deploy, a job whose handler returned while its runtime drained was occasionally rescued as a stale heartbeat about heartbeat_staleness later and executed again on another runtime. The persona soak saw this after roughly one graceful restart in ten, on PostgreSQL 17 and 18.

Once a handler returns, the executor removes the attempt from the heartbeat set and finalizes it in a detached tokio::spawn task. Client::shutdown drains the job tasks but not those finalizers. When the last handler returns just as the drain ends, shutdown cancels the completion batcher before that finalizer's request reaches it. The direct-finalize fallback then races the process exit: it fails on a closed pool or is dropped with the runtime. Nothing heartbeats the attempt any more, so it goes stale and rescue runs it again.

A traced soak run shows the interleaving on one runtime (times in seconds):

t event
28.4631 shutdown cancels dispatch; an in-flight claim still returns jobs 17064 and 17065, which are spawned
28.4661 dispatchers exit; drain waits on the job set
28.4727 17064's handler returns Completed; its finalizer is spawned detached
28.4738 17065's handler returns Completed; its finalizer is spawned detached
28.4738 job set empty: shutdown cancels the services
28.4738 completion batcher sees the cancel, drains an empty channel and drops its receiver
28.4738 17065's finalizer: completion batcher stopped, falling back to direct finalize
28.4738 heartbeat's final beat, with nothing in flight
28.4738 shutdown returns; the process closes its pool and exits
40.3233 another runtime's maintenance rescues 17064 and 17065 (stale heartbeat)
42.6576 attempt 2 of both is claimed and executed

All four rescues in that run have this shape. In each, a finalizer started and never finished on a runtime that had just shut down gracefully.

Change

Finalizers are spawned on a TaskTracker. Client::shutdown closes it and waits for it after draining the job tasks and before it cancels the completion batcher, heartbeat and maintenance. The wait shares the existing drain deadline. A finalize that outlives the timeout is left to rescue, as before. main already drains finalizers this way (#521). This change backports that shutdown behaviour without #521's reconnect retry.

Tests

test_queue_storage_graceful_shutdown_finalizes_returned_attempts runs a client on its own tokio runtime, so a process exit can be simulated. The test holds the attempt's receipt-completion advisory lock, which keeps the finalize in flight while shutdown drains. It completes the job through a follow-up spec, so the finalize runs in its own transaction. It then releases the lock, stops the runtime after shutdown returns, and starts a second runtime that rescues anything left open.

  • Before the fix, it fails on PostgreSQL 17 and 18: the attempt is still running after the graceful exit, and the rescuing runtime runs the job a second time.
  • After the fix, it passes on both: the job is completed under the original lease and runs once.

ci_timing.rs comes over from main for the staleness and wait bounds.

Shard-ops persona soak, PostgreSQL 17, seed 5, 600 s, 40 graceful restarts per run:

  • Before the fix: four stale-heartbeat rescues and re-executions. Eleven finalizers were still unfinished when their runtime exited.
  • With the fix: no rescues in one run on this branch and one with the lane-provisioning fix merged in (77,000 executions). The traced run on this branch also finished every finalizer.

cargo fmt, clippy -D warnings (workspace and awa-python), the awa-worker unit tests, and the executor_guard, enqueue_spec and lifecycle_hook suites pass on PostgreSQL 18.

Independent review and validation

Includes the small #651 point-read fix. After a priority or queue move crossed a ready-ring wrap, retained tombstoned source rows could make get_job return old fields even after the batch succeeded. The available-candidate query now excludes full-identity tombstones (a 13-line SQL change), with priority and queue ring-wrap regressions. Both regressions fail before the fix and pass after it on PostgreSQL 17 and 18; existing aged-read and batch cases also pass. Required formatting, Clippy across all targets/features, and workspace build checks pass for the prepared source.

Full CI passed all 29 jobs for 050e87bfdb68e1c3b83042806d44370e72e6d1f7.

Once a handler returned, the executor removed the attempt from the
heartbeat set and finalized it in a detached task that Client::shutdown
did not wait for. When the last handler returned as the drain ended,
shutdown cancelled the completion batcher before that finalize reached
it, and the direct-finalize fallback raced the process exit. The attempt
stayed open with no heartbeat, so stale-heartbeat rescue ran the
finished job again after heartbeat_staleness.

Track finalizers in a TaskTracker and wait for them, within the drain
timeout, before stopping the completion batcher, heartbeat and
maintenance services. This is the shutdown part of #521 on main.
@coderabbitai

coderabbitai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e9869600-f844-49ba-ac04-4eef0390998d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T02:42:02.855237Z 7e6259e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e6259e04d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Graceful shutdown waits for this before it stops the completion
// batcher: the attempt has already left `in_flight`, so nothing
// heartbeats it, and a finalize lost at exit leaves it to rescue.
finalizers.spawn(async move {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep lifecycle hooks out of the tracked finalizer

When an outcome lifecycle hook is slow or hangs, this tracked future remains live through dispatch_lifecycle_event at line 572, so Client::shutdown waits until the entire drain deadline expires. This regresses the documented contract in docs/lifecycle-hooks.md:75-80 that hooks are best-effort and shutdown does not wait for them, turning user hook latency into shutdown latency. Track only the durable completion work and leave lifecycle dispatch detached.

Useful? React with 👍 / 👎.

@hardbyte hardbyte added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Nov 6, 2026
@hardbyte
hardbyte merged commit 7606185 into release/0.6.6 Nov 6, 2026
30 checks passed
hardbyte added a commit that referenced this pull request Nov 6, 2026
Preserve the exact tested #638 tree while incorporating the late #643 merge
of the identical live-ready point-read fix. Resolve CHANGELOG with #638 content.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full CI matrix (Python build+test, E2E) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant