Skip to content

fix(storage): converge canonical drain for perpetually snoozing jobs (#456) - #458

Open
hardbyte wants to merge 3 commits into
mainfrom
brian/456-mixed-transition-reschedule-migration
Open

fix(storage): converge canonical drain for perpetually snoozing jobs (#456)#458
hardbyte wants to merge 3 commits into
mainfrom
brian/456-mixed-transition-reschedule-migration

Conversation

@hardbyte

@hardbyte hardbyte commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Fixes #456.

The bug

canonical_live_backlog counts all of awa.scheduled_jobs, and the drain phase assumes that backlog converges as jobs come due and finish. For workloads whose handlers snooze on every run (recurring per-entity heartbeats implemented as JobResult::Snooze) it never does: the canonical executor's snooze path writes the job straight back into canonical scheduled_jobs, so the backlog self-replenishes and awa storage finalize --wait blocks forever.

Reproduced on a restored production-shaped snapshot with tens of thousands of jobs on a ~24h snooze cycle. After enter-mixed-transition, new inserts routed to queue storage correctly, but every canonical job that came due was promoted to canonical jobs_hot, executed, and re-snoozed back into canonical scheduled_jobs. Queue-storage deferred_jobs stayed empty; canonical_live_backlog stayed flat.

This affects 0.7 as well as 0.6 — the ADR-037 gate blocks awa migrate on an unfinalized cluster, but it does not stop a 0.7 worker from executing canonical work (canonical still works in 0.7, per docs/upgrade-0.6-to-0.7.md, and is removed in 0.8).

The fix

awa_model::reschedule owns the snooze / retry-backoff / retry-after write and branches on awa.active_queue_storage_schema():

  • canonical (NULL) — the same jobs_hot -> scheduled_jobs move as before, so pre-flip behaviour is byte-for-byte unchanged.
  • mixed_transition / active — takes the canonical row and re-inserts the attempt into the active queue-storage schema's deferred_jobs, carrying over attempt, errors, progress, run_lease, timestamps, and the unique claim.

Every canonical job therefore leaves the canonical plane at its next completion, and the existing drain/finalize flow converges for perpetual snoozers with no operator intervention.

No migration, deliberately

This ships as runtime SQL against the existing v040 schema rather than a v044 function. A migration would be unreachable by the clusters that need it: the ADR-037 gate refuses awa migrate on exactly the unfinalized clusters where this bug manifests. It also keeps the fix backportable to the 0.6 line, whose migration numbering 0.7 already owns past v040. (A queue-storage-only variant of this fix was prototyped as v044 first and discarded for these reasons.)

run_lease semantics

Unchanged, and strictly stronger. The take of the canonical row carries the same state = 'running' AND run_lease = $n guard as the historical UPDATE. The successor takes a fresh id from the queue-storage sequence (the two planes have different id sequences, so reusing the canonical id could collide with a later queue-storage insert), which means a late completion from a rescued attempt matches no canonical row and reports stale. Both stale paths are covered by tests.

Adjacent fix: leadership no longer gates drain

Two related stalls, both observed in the same rehearsal:

  • a queue-storage leader did not promote due canonical scheduled_jobs, and
  • a canonical-resolved (pre-flip) leader did not promote queue-storage deferred_jobs — so re-scheduled jobs that had already migrated sat unpromoted until leadership moved.

Each maintenance leader now promotes the other plane's deferred backlog for the duration of the transition; the pre-flip case goes through a new standby_queue_storage handle, which is the configured-but-inactive queue-storage runtime a canonical-resolved worker already carries. Both paths are guarded no-ops on finalized and fresh installs (active_queue_storage_schema() / empty canonical tables), so steady-state cost is one cheap query per promote tick.

Tests

rolling_transition_rehearsal_test.rs gains a snooze-forever workload on its own queue. This is the gap that let #456 through: the existing snooze_once handler snoozes 100ms once and then completes, so the drain converges and the perpetual case is unrepresented. canonical_backlog_count now also counts scheduled_jobs, as its doc comment already claimed it did. The rehearsal asserts the snoozer bank has left the canonical plane and keeps executing on queue storage after finalize — it fails on the pre-fix code (at_finalize=763 after=763) and passes with it.

A focused test in sql_only_storage_upgrade_test.rs covers canonical routing (id preserved, snooze does not count the attempt), a stale wrong-run_lease call, migrated routing (fresh id, retryable state, payload metadata/tags/errors/progress preserved, unique claim re-pointed at the successor), the stale post-migration re-call, and a canonical backlog of zero with nothing having completed.

migration_test.rs also swaps two hardcoded 43s for CURRENT_VERSION (with the version-floor rewinds pinned to 43 deliberately, so the pending range keeps crossing v043). Behaviour-neutral today; it stops the next migration bump from failing those tests spuriously.

Verification

  • cargo test --workspace — 75 suites, 713 tests, clean run from a fresh schema
  • cargo test -p awa --test rolling_transition_rehearsal_test -- --ignored — passes end-to-end
  • pytest — 315 passed, 1 skipped
  • cargo fmt --all and cargo clippy --all-targets --all-features -- -D warnings clean

Two environment notes for anyone reproducing locally, neither a code issue: the test Postgres needs max_locks_per_transaction=2048 (the substrate installs many partitions), and a stale awa_health_test database carrying a leftover future-version row fails health_endpoint_test until dropped.

A 0.6.x backport of the fix (without the migration_test.rs hardening) follows separately against release/0.6.3.

Summary by CodeRabbit

  • Bug Fixes

    • Improved canonical rescheduling during mixed storage transitions so perpetually snoozing jobs no longer stall canonical backlog drain.
    • Canonical completion reschedules are routed/migrated into the prepared plane’s deferred jobs while preserving attempts, errors, progress, and unique-claim behavior.
    • Stale completions are safely ignored; retry timing (including retry-after vs backoff) is honored.
  • Tests

    • Expanded transition, migration, and rolling-rehearsal coverage, including lock-behavior and unique-claim corner cases.
  • Documentation

    • Updated upgrade and operational guidance to reflect the new deferred-job backlog behavior and health checks.

…456)

During a storage transition, the re-scheduling completions of
canonical-claimed jobs (snooze, retry backoff, retry-after) wrote the job
back into canonical `scheduled_jobs`. A workload whose handlers snooze on
every run therefore replenished `canonical_live_backlog` forever, and
`awa storage finalize --wait` could never pass.

`awa_model::reschedule` now branches on `awa.active_queue_storage_schema()`:
canonical routing performs the same hot -> scheduled move as before, while
`mixed_transition` / `active` takes the canonical row and re-inserts the
attempt into the active queue-storage schema's `deferred_jobs`. Every
canonical job leaves the canonical plane at its next completion, so the
existing drain/finalize flow converges without operator intervention.

Implemented as runtime SQL against the existing v040 schema rather than a
migration: the ADR-037 gate refuses `awa migrate` on exactly the
unfinalized clusters this fix serves, so a migration would be unreachable
by them, and this keeps the fix backportable to the 0.6 line.

Stale-completion protection is unchanged and strictly stronger: the take of
the canonical row carries the same `state = 'running' AND run_lease = $n`
guard as the historical UPDATE, and the successor takes a fresh job id from
the queue-storage sequence, so a late completion from a rescued attempt
matches no canonical row and reports stale.

Maintenance leaders also promote the other plane's deferred backlog for the
duration of the transition: a queue-storage leader promotes due canonical
`scheduled_jobs`, and a canonical-resolved (pre-flip) leader promotes
queue-storage `deferred_jobs` through its configured-but-inactive runtime.
Drain and post-flip execution no longer depend on which runtime happens to
hold leadership. Both paths are guarded no-ops on finalized and fresh
installs.

Tests: the rolling-transition rehearsal gains a snooze-forever workload on
its own queue (the existing `snooze_once` handler completes on re-run, so it
could not surface this), `canonical_backlog_count` now counts
`scheduled_jobs` as its doc comment already claimed, and a focused test
covers canonical routing, migrated routing, unique-claim hand-off, payload
preservation, and both stale paths.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hardbyte, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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 reviews.

How do review 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 refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 89f4ce5e-7c8d-4b57-9abe-cf0bf53bd9ad

📥 Commits

Reviewing files that changed from the base of the PR and between c8d69d4 and d167a45.

📒 Files selected for processing (3)
  • awa-model/src/reschedule.rs
  • awa-model/tests/sql_only_storage_upgrade_test.rs
  • awa/tests/rolling_transition_rehearsal_test.rs
📝 Walkthrough

Walkthrough

Canonical job rescheduling now uses shared model logic that routes successors to queue-storage deferred_jobs during mixed transitions. Executors, maintenance promotion, transition tests, correctness models, and operational documentation are updated for stale completions, perpetual snoozing, and cross-plane backlog draining.

Changes

Canonical drain convergence

Layer / File(s) Summary
Reschedule model and storage routing
awa-model/src/lib.rs, awa-model/src/reschedule.rs
Adds reschedule types and outcomes, guarded canonical replacement, transition locking, and migration into queue-storage deferred_jobs with preserved job data and claims.
Executor completion integration
awa-worker/src/executor.rs, awa/tests/lifecycle_hook_test.rs
Routes retry, backoff, snooze, and follow-up completion paths through the reschedule helpers and updates retry event snapshots.
Maintenance routing-flip promotion
awa-worker/src/client.rs, awa-worker/src/maintenance.rs
Configures standby queue-storage promotion and mirrors due-state promotion across storage planes during transitions.
Transition validation and rehearsal
awa-model/tests/sql_only_storage_upgrade_test.rs, awa/tests/rolling_transition_rehearsal_test.rs, awa/tests/migration_test.rs, correctness/run-tlc-suite.sh
Adds SQL routing, locking, claim, delay, perpetual-snooze, migration, and TLC coverage.
Transition model and operational contract
correctness/storage/*, CHANGELOG.md, docs/*.md, skills/awa-operations/SKILL.md
Models snoozing backlog migration and documents cross-plane promotion, drain convergence, rollout checks, and older-build workarounds.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant Executor
  participant Reschedule
  participant Canonical
  participant QueueStorage
  Worker->>Executor: complete canonical job
  Executor->>Reschedule: reschedule with run lease
  Reschedule->>Canonical: remove guarded running row
  Reschedule->>QueueStorage: insert successor into deferred_jobs
  QueueStorage-->>Worker: promote successor after transition
Loading

Possibly related PRs

  • hardbyte/awa#314: Both changes extend SQL-only storage-transition tests and use the same transition functions.

Suggested labels: full-ci

Poem

A rabbit watched jobs hop from hot rows to queue,
Snoozing forever as transitions grew true.
Claims kept their names, attempts stayed in line,
Stale leases vanished at just the right time.
“Drain on,” cried the bunny, “the backlog is through!”

🚥 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 fix: canonical drain convergence for perpetually snoozing jobs.
Linked Issues check ✅ Passed The code routes transition-time reschedules to queue-storage, preserves metadata and stale-completion guards, and adds the perpetual-snooze rehearsal test.
Out of Scope Changes check ✅ Passed The documentation, model, and test updates all support the #456 transition-reschedule fix; no clear unrelated changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

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

@coderabbitai coderabbitai Bot added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Jul 30, 2026

@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: c1334ad6f2

ℹ️ 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".

Comment thread awa-model/src/reschedule.rs Outdated
Comment on lines +134 to +138
let active_schema: Option<String> =
sqlx::query_scalar("SELECT awa.active_queue_storage_schema()")
.fetch_one(&mut *conn)
.await
.map_err(map_sqlx_error)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Lock transition state before choosing reschedule storage

When storage_abort() runs concurrently with a canonical completion during mixed_transition, this unlocked lookup can observe queue storage after the abort has locked the transition row and counted the queue-storage tables as empty. The completion can then delete the canonical row and commit a new deferred row after the abort restores canonical routing, permanently stranding that job in the inactive queue-storage schema. Read and lock the transition-state row for the duration of the reschedule transaction so abort cannot validate and flip routing between this decision and the insert.

Useful? React with 👍 / 👎.

Comment on lines +1512 to +1515
let mut row = job.clone();
row.id = job_id;
row.state = state;
row.run_at = run_at;

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 Include retry mutations in the emitted job snapshot

For a retryable handler error when lifecycle events are enabled, this helper clones the pre-completion running row and updates only routing fields, while the database reschedule also appends the new error and sets finalized_at. Consequently JobEvent::Retried.job no longer represents the persisted post-retry row—hooks inspecting job.errors miss the failure that triggered this event and may see stale finalization data. Pass the error/finalization changes into the synthesized row (or materialize the actual deferred representation) before emitting it.

Useful? React with 👍 / 👎.

@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

🧹 Nitpick comments (4)
awa-worker/src/maintenance.rs (1)

2177-2201: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cross-plane promotion runs unconditionally on every promote tick. Both new blocks add steady-state DB work to every leader at the promote cadence (default 250ms, twice per tick) with no transition-state gate, so clusters that finalized long ago keep paying for mixed-transition machinery.

  • awa-worker/src/maintenance.rs#L2177-L2201: gate the canonical mirror-drain on the transition state (or a cached "canonical plane drained" flag) instead of running promote_due_batch unconditionally.
  • awa-worker/src/maintenance.rs#L2208-L2215: resolve/cache awa.active_queue_storage_schema() once per pass (or with a coarse refresh) rather than per state per tick.
🤖 Prompt for 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.

In `@awa-worker/src/maintenance.rs` around lines 2177 - 2201, The promote tick
performs unnecessary steady-state database work. In the canonical promotion
block around promote_due_batch, gate mirror draining on the active
storage-transition state or a cached canonical-plane-drained flag; also update
the per-state logic at awa-worker/src/maintenance.rs lines 2208-2215 to resolve
or cache awa.active_queue_storage_schema() once per pass or via a coarse refresh
instead of once per state per tick.
awa-model/tests/sql_only_storage_upgrade_test.rs (3)

447-456: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

UNION ALL … ORDER BY 1 DESC LIMIT 1 reads as a max-of-two-counts trick.

It works (max is 0 only when both are 0), but a single summed scalar is clearer and asserts the same thing.

♻️ Clearer query
-    let canonical_left: i64 = sqlx::query_scalar(
-        "SELECT count(*)::bigint FROM awa.jobs_hot WHERE id = $1 \
-         UNION ALL SELECT count(*)::bigint FROM awa.scheduled_jobs WHERE id = $1 \
-         ORDER BY 1 DESC LIMIT 1",
-    )
+    let canonical_left: i64 = sqlx::query_scalar(
+        "SELECT (SELECT count(*)::bigint FROM awa.jobs_hot WHERE id = $1) \
+              + (SELECT count(*)::bigint FROM awa.scheduled_jobs WHERE id = $1)",
+    )
🤖 Prompt for 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.

In `@awa-model/tests/sql_only_storage_upgrade_test.rs` around lines 447 - 456,
Update the canonical_left query in the SQL-only storage upgrade test to use one
scalar that sums the counts from awa.jobs_hot and awa.scheduled_jobs for the
bound job2, instead of UNION ALL with ORDER BY and LIMIT. Preserve the existing
fetch and assert_eq! behavior.

401-408: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The count(*) OVER () assertion is tautological.

WHERE id = $1 on a primary key can never yield more than one row, so count(*) OVER () is always 1 when fetch_one succeeds. If the intent is to assert the canonical plane holds exactly one reschedule_test row, count without the id filter; otherwise just select state.

♻️ Simplify
-    let (state, count): (String, i64) = sqlx::query_as(
-        "SELECT state::text, count(*) OVER () FROM awa.scheduled_jobs WHERE id = $1",
-    )
-    .bind(job1)
-    .fetch_one(&pool)
-    .await
-    .expect("scheduled row after canonical snooze");
-    assert_eq!((state.as_str(), count), ("scheduled", 1));
+    let state: String =
+        sqlx::query_scalar("SELECT state::text FROM awa.scheduled_jobs WHERE id = $1")
+            .bind(job1)
+            .fetch_one(&pool)
+            .await
+            .expect("scheduled row after canonical snooze");
+    assert_eq!(state, "scheduled");
🤖 Prompt for 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.

In `@awa-model/tests/sql_only_storage_upgrade_test.rs` around lines 401 - 408,
Update the query and assertion around the canonical snooze check to avoid
counting rows filtered by the primary-key id. Count the canonical
reschedule_test rows without the id predicate if verifying exactly one row, or
select only state if row-count validation is not intended; keep the existing
scheduled-state assertion.

528-544: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cleanup/finalize only runs on the success path.

Every assertion above uses assert!/panic!, so any failure leaves the shared test database in mixed_transition with leftover deferred rows and claims — which, per your own comment, makes a later migrations::run hit the ADR-037 unfinalized-cluster gate and turns one failure into cascading failures in unrelated tests. Consider running the body via AssertUnwindSafe(...).catch_unwind() (or a drop guard that finalizes) and re-raising after cleanup.

🤖 Prompt for 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.

In `@awa-model/tests/sql_only_storage_upgrade_test.rs` around lines 528 - 544, The
cleanup and storage finalization after the reschedule test must also run when
assertions panic. Wrap the test body containing the assertions in an
unwind-catching mechanism such as AssertUnwindSafe(...).catch_unwind(), perform
the existing deferred-job, claims, canonical-work, and storage_finalize cleanup
afterward, then re-raise the original panic.
🤖 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 `@awa-model/src/reschedule.rs`:
- Around line 279-288: Update promote_due around the payload construction and
migrated DELETE ... RETURNING flow to preserve canonical lease callback state,
including callback_id, callback_timeout_at, callback_filter,
callback_on_complete, callback_on_fail, and callback_transform, on a compatible
successor claim; if that cannot be migrated, explicitly skip jobs with callback
wiring instead of promoting them as plain queued jobs.
- Around line 336-340: Update the rescheduling query around the run_at-producing
SELECT and its finalized_at bind so the Retryable timestamp is generated by
PostgreSQL using the same DB clock, rather than Utc::now() in the worker. Fold
the finalized_at value into that SELECT and bind the returned value, preserving
None for non-Retryable states.

In `@awa-worker/src/executor.rs`:
- Around line 1501-1521: Update rescheduled_event_row to accept the newly
recorded error entry and the appropriate finalized_at value, then assign both to
the cloned JobRow so the Retried event matches the previously re-read row.
Ensure the synthesized row includes the appended errors entry and clears or
updates finalized_at for the rescheduled state.

In `@awa/tests/rolling_transition_rehearsal_test.rs`:
- Around line 639-650: Give the queue_storage_target client in the
SnoozeForeverWorker registration its own Arc<AtomicI64> counter instead of
reusing snooze_performs, mirroring the separate rust_handled and
qs_target_handled counters. Update the related phase-7 assertion to track this
queue-storage-specific counter so post-finalize progress is attributed to queue
storage.

---

Nitpick comments:
In `@awa-model/tests/sql_only_storage_upgrade_test.rs`:
- Around line 447-456: Update the canonical_left query in the SQL-only storage
upgrade test to use one scalar that sums the counts from awa.jobs_hot and
awa.scheduled_jobs for the bound job2, instead of UNION ALL with ORDER BY and
LIMIT. Preserve the existing fetch and assert_eq! behavior.
- Around line 401-408: Update the query and assertion around the canonical
snooze check to avoid counting rows filtered by the primary-key id. Count the
canonical reschedule_test rows without the id predicate if verifying exactly one
row, or select only state if row-count validation is not intended; keep the
existing scheduled-state assertion.
- Around line 528-544: The cleanup and storage finalization after the reschedule
test must also run when assertions panic. Wrap the test body containing the
assertions in an unwind-catching mechanism such as
AssertUnwindSafe(...).catch_unwind(), perform the existing deferred-job, claims,
canonical-work, and storage_finalize cleanup afterward, then re-raise the
original panic.

In `@awa-worker/src/maintenance.rs`:
- Around line 2177-2201: The promote tick performs unnecessary steady-state
database work. In the canonical promotion block around promote_due_batch, gate
mirror draining on the active storage-transition state or a cached
canonical-plane-drained flag; also update the per-state logic at
awa-worker/src/maintenance.rs lines 2208-2215 to resolve or cache
awa.active_queue_storage_schema() once per pass or via a coarse refresh instead
of once per state per tick.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f283bba9-e73e-4bc2-8526-9b488b77fe3a

📥 Commits

Reviewing files that changed from the base of the PR and between 2ddf310 and c1334ad.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • awa-model/src/lib.rs
  • awa-model/src/reschedule.rs
  • awa-model/tests/sql_only_storage_upgrade_test.rs
  • awa-worker/src/client.rs
  • awa-worker/src/executor.rs
  • awa-worker/src/maintenance.rs
  • awa/tests/migration_test.rs
  • awa/tests/rolling_transition_rehearsal_test.rs

Comment thread awa-model/src/reschedule.rs
Comment thread awa-model/src/reschedule.rs
Comment thread awa-worker/src/executor.rs
Comment thread awa/tests/rolling_transition_rehearsal_test.rs
…ment it (#456)

Addresses both review findings on the cross-plane re-schedule, extends the
corner-case coverage, and brings the TLA+ model and operator docs in line.

Lock the transition singleton FOR SHARE for the duration of the reschedule
transaction before deciding where the attempt goes. `storage_abort`,
`storage_enter_mixed_transition`, and `storage_finalize` all take FOR UPDATE
on that row, so the share lock serializes against them. Without it an abort
could validate the queue-storage tables as empty and restore canonical
routing between the routing decision and the insert, stranding the job in a
schema that is no longer active — lost work, which the delivery contract
puts above every fast path. A missing singleton still resolves to canonical
rather than erroring.

Carry the persisted mutations into the synthesized event row. The
re-schedule appends the triggering error and stamps `finalized_at`, so a
row that only tracked the routing fields made `JobEvent::Retried.job`
disagree with the stored row: hooks reading `job.errors` missed the failure
that caused the retry. `lifecycle_hook_test` now asserts both.

Corner cases added, several of which found real gaps in the first cut:

- `state = prepared` must still route canonical. Migrating before the
  routing flip would put rows in a schema that is not yet authoritative and
  would break the `storage abort` interlock.
- A `unique_states` mask that does not claim the destination state leaves
  the successor unclaimed; a newer duplicate already holding the key keeps
  it and the successor proceeds unclaimed without losing the job.
- `RetryAfter` honours the caller's delay instead of computing backoff, and
  does not consume an attempt.
- A missing transition singleton routes canonical.
- The canonical and queue-storage id sequences are independent and can hand
  out the same number, so the successor-id assertions now advance
  `job_id_seq` first instead of passing on a coincidence.

TLA+ (`AwaStorageTransition`): add `MigrateCanonicalRescheduleToQueueStorage`
for the cross-plane move, which was previously unmodelled — canonical rows
could only leave via `DrainCanonical`. Modelling the workload required
tightening `DrainCanonical` to exclude a re-scheduling job, since such a job
is executed but never completed; without that the bug is invisible to the
model. New checks: `NoQueueRowsUnderCanonicalRouting`,
`SnoozerImpliesCanonicalWork`, `MixedTransitionCanReduceCanonicalBacklog`,
and the action property `RescheduleMigrationConservesWork`. A witness config
models the pre-fix behaviour and violates the convergence invariant, showing
mixed transition wedged with canonical work outstanding and `CanFinalize`
unreachable. Convergence is encoded as safety, not liveness: the model
legitimately permits a cluster never to prepare, to abort, or to stop every
runtime, so no unconditional temporal property holds even with the fix.
768 distinct states (was 480); suite green at 22 checks. MAPPING.md records
the FOR SHARE / action-atomicity correspondence and the refinement
obligations the model cannot express (attempt identity, claim handoff).

Docs: name the convergence mechanism in the substrate and upgrade guides,
add the drain signal to the rollout watch list (it had no backlog metric),
record the stall and its manual workaround under Known issues, and note that
the leader promotes both planes' backlogs mid-transition. Also correct a
pre-existing error while in the area: snooze re-schedules to `scheduled`,
not `retryable`, in both planes.

@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: 1

🤖 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 `@docs/upgrade-0.5-to-0.6.md`:
- Line 174: The documented manual backlog migration must be atomic. Update the
workaround to require one transaction, lock each scheduled job and its
corresponding awa.job_unique_claims row before deletion, and then reinsert
through awa.insert_job_compat(...), preserving the original job payload and
uniqueness metadata; alternatively explicitly require all producers to be
stopped.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 600ca76a-07cd-4d83-b56e-2dbcfebc938c

📥 Commits

Reviewing files that changed from the base of the PR and between c1334ad and c8d69d4.

📒 Files selected for processing (17)
  • awa-model/src/reschedule.rs
  • awa-model/tests/sql_only_storage_upgrade_test.rs
  • awa-worker/src/executor.rs
  • awa/tests/lifecycle_hook_test.rs
  • correctness/run-tlc-suite.sh
  • correctness/storage/AwaStorageTransition.cfg
  • correctness/storage/AwaStorageTransition.tla
  • correctness/storage/AwaStorageTransitionCurrentGate.cfg
  • correctness/storage/AwaStorageTransitionMigrate07Ungated.cfg
  • correctness/storage/AwaStorageTransitionRescheduleStaysCanonical.cfg
  • correctness/storage/MAPPING.md
  • docs/architecture.md
  • docs/configuration.md
  • docs/queue-storage-substrate.md
  • docs/upgrade-0.5-to-0.6.md
  • docs/upgrade-0.6-to-0.7.md
  • skills/awa-operations/SKILL.md


- **Python workers without claim-ring knobs.** Older Python wheels don't accept `queue_storage_claim_slot_count` / `queue_storage_claim_rotate_interval_ms` on `client.start()`. They'll still run with default values and the rollout will work, but operators wanting non-default ring sizing need a wheel that exposes those kwargs.
- **Held-tx during finalize.** A long-running canonical transaction (e.g., reporting query) blocks vacuum, which can stall partition prune in the queue-storage tables. `awa_maintenance_prune_attempts_total{awa_ring_outcome="blocked"}` will rise. Identify and terminate the held-tx; prune resumes on the next maintenance tick.
- **Perpetually snoozing jobs stall the drain on older builds.** A handler that ends every run in `JobResult::Snooze` (recurring per-entity polls, heartbeat-style jobs) used to re-enter *canonical* `scheduled_jobs` after each post-flip execution, so `canonical_live_backlog` replenished itself and `awa storage finalize --wait` blocked indefinitely. Fixed in [#456](https://github.com/hardbyte/awa/issues/456): once routing has flipped, those re-schedules land in the prepared schema's `deferred_jobs`, so each job leaves the canonical plane at its next completion. On a build without the fix the workaround is to migrate the backlog by hand after the flip — take each `awa.scheduled_jobs` row, delete it and its `awa.job_unique_claims` entry, and re-insert it through `awa.insert_job_compat(...)`, which routes to queue storage post-flip. (A plain `INSERT` into the `awa.jobs` compatibility view does **not** route; it is canonical-only.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the manual backlog move atomic.

Deleting awa.job_unique_claims before reinserting permits a concurrent unique enqueue to take the key. Require a single transaction with appropriate locking (or require producers stopped), and preserve the original row payload/uniqueness metadata through the move.

🤖 Prompt for 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.

In `@docs/upgrade-0.5-to-0.6.md` at line 174, The documented manual backlog
migration must be atomic. Update the workaround to require one transaction, lock
each scheduled job and its corresponding awa.job_unique_claims row before
deletion, and then reinsert through awa.insert_job_compat(...), preserving the
original job payload and uniqueness metadata; alternatively explicitly require
all producers to be stopped.

…456)

Second round of review findings on the cross-plane re-schedule.

**Callback wiring would have been silently dropped.** `deferred_jobs` has no
callback columns — queue storage keeps that state on the lease — so migrating
a job that carries `callback_id` or any of the CEL expressions would have left
the callback unresolvable with no way to resume it. The migrating DELETE now
excludes such rows and they fall through to the canonical write, which applies
the same `run_lease` guard: a callback-carrying job re-schedules in place, and
a genuinely lost attempt still reports stale. This is a deliberate, narrow
carve-out — a callback job is not the perpetual-snooze shape this path exists
for, and it reaches a terminal state on its own.

**`finalized_at` now comes from the database clock.** It was stamped with
`Utc::now()` while `run_at` came from Postgres `now()`. Retention and cleanup
compare `finalized_at` against the DB clock, so worker skew could shift those
decisions; both values now come from one statement.

**The rehearsal's phase-7 assertion was attributable to the wrong runtime.**
Both clients registered the snoozer against one shared counter, so the
post-finalize check only proved *some* runtime kept executing the bank — not
that queue storage did, which is the property #456 is about. The
queue_storage_target client now has its own counter, mirroring
`rust_handled` / `qs_target_handled`.

Adds a regression test for the callback carve-out: the job stays canonical
under its own id, no queue-storage row is created, the callback wiring
survives on the canonical successor, and a wrong-`run_lease` call still
reports stale rather than being quietly re-scheduled by the fallback.
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.

Canonical drain never converges for perpetually snoozing jobs; scheduled_jobs backlog self-replenishes during the staged storage transition

1 participant