Files
alkstore/tasks/pg-fix-scheduler-fire-wake.md
T
glm-5.3-flash 5f5f6741d5 release-renumber-sweep: annotate stale wave-7 release-pass references
Sweep of docs/reviews/ and tasks/ per the plan's §Wave 8 historical
note: 'wave 7'-means-release-pass references annotated or reworded
per the review-wave-5.md renumber-annotation precedent; genuine
fuzzing-wave/review-7 references untouched. Out-of-scope
architecture-doc trail records documented in the task's Notes.
12 annotated, 1 reworded (live pointer); taskgraph validate green.
2026-10-10 23:52:42 +00:00

11 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
pg-fix-scheduler-fire-wake pg scheduler — fire wakes: the tick's in-tx enqueue fires issue their pg_notify wake (the recorded latency-parity gap, closed) completed
narrow low component implementation
wave-5-aftermath
postgres-engine
scheduler
wake

Description

Close the recorded cross-engine scheduler fire-wake latency-parity gap — the one item the wave-5 review gate left standing "for a later task" (tasks/review-wave-5.md §Notes 4, audit verdict: "the recorded gap stands for a later task; accepted as-is"). Provenance chain: tasks/review-wave-4-fixes.md §"Recorded for later" (the original note: "Scheduler fire wakes (the tick's in-tx enqueue) issue no pg_notify — SQLite's watcher wakes on those commits… if wave 5's cross-engine equivalence rows demand the parity, it is a one-call wake_tx inside the tick's tx"), dispositioned not-demanded by tasks/suite-scheduler-rows.md §Notes (the suite rows observe fires via claim polling; wake latency out of scope), and audited as-is by the wave-5 gate. Correctness was never at stake — the queues row's pinned re-poll safety net covers it on both engines — but the asymmetry stands: a schedule fire on pg is invisible to a registered listener until the listener's re-poll, while on SQLite the watcher wakes that queue's listeners at the tick's commit.

Site: alkstore-postgres/src/scheduler.rs:518-548 — the tick's one-firer-per-boundary loop calls enqueue_row(client, …) inside the [crate::queue::in_tx] frame (ADR-009 §4's layer (b)) and issues no wake. The fix is the recorded one-call shape, riding the pg-fix-tx-wake pattern of record (tasks/pg-fix-tx-wake.md): tx::wake_tx (currently private at tx.rs:287 — widen to pub(crate)) issued inside the tick's in_tx frame, so NOTIFY's native transactional delivery makes the wake commit-atomic — a rolled- back tick (crash mid-tick ⇒ the boundary refires) discards the wake with its fire rows, exactly the tx/no-ghosts discipline the tx producer paths already pin.

Fix constraints (pinned):

  • Channel = the fired queue's name (the mechanism-name-is-the-channel realization; schedule() rejects reserved names per ADR-021 §3, so a fire's target can never be a derived outbox channel — the plain queue name is always the right channel).
  • Empty payload — Wake { channel } only (ADR-008 §3); best-effort log-and-swallow posture (wake_tx's existing stance; a wake must never fail a tick — ADR-006).
  • Coalescing default: one wake per tick per schedule that fired ≥ 1 (after the fire loop, still inside the in_tx body), not per-fire — wakes are hint-only (ADR-006; no per-wake semantics exist to preserve), and this matches the SQLite twin's shape (the substrate watcher wakes per committed tick transaction, not per enqueued row). Deviate to per-fire only with the reason recorded in Notes.
  • No double-wake risk: the wake is applied at the tick call site, NOT inside the shared enqueue_row (the pg-fix-tx-wake one-owner posture — enqueue_row stays wake-free; the auto-commit Queue::enqueue and the tx paths keep their own single wakes, untouched).

Test shape (engine-side, pg): a harness test in the scheduler tests — a schedule registered against a queue, a listener registered on that queue's channel (forwarder().register; listener_hears), the runner ticks with the schedule due: the fire wake arrives at/after the tick's commit (pre-commit silence window to pin commit-atomicity, the tx_producer_wakes_are_commit_atomic_and_rollback_discards_them pattern); a tick with nothing due issues no wake. The rollback arm is native (NOTIFY's transactional delivery — already proven by notify_tx and the tx-wake test); do not build a tick-fault seam for it here (pg-fix-scheduler-tick-seam's seam, if landed first, is the retry loop's, not this site's — same file, disjoint site, either landing order is fine).

Record updates (the gap is recorded in three places — retire all three): tasks/review-wave-4-fixes.md's "Recorded for later" bullet (marked retired by this fix), tasks/suite-scheduler-rows.md §Notes' fire-wake-parity disposition (gap closed, pointer), and tasks/review-wave-5.md §Notes 4's audit-table row (pointer to the closing task). Add the review-rounds line to docs/plans/implementation.md.

Ordering note: no hard dependency on wave 6, but landing before mem-engine-scheduler-outbox keeps the mem engine's fire posture written against a closed gap (mem's in-memory wake routing is trivially immediate; the suite's three-way story then starts symmetric).

Acceptance Criteria

  • The tick's fire path issues its wake commit-atomically inside the in_tx frame (channel = the fired queue's name, empty payload, best-effort wake_tx posture); the coalescing decision recorded in Notes (default: one wake per firing schedule per tick)
  • Pinned by test: a listener on the target queue's channel receives the wake at/after the tick's commit (pre-commit silence window); a tick with nothing due issues no wake
  • No wake regression: auto-commit Queue::enqueue, the tx producer paths, and enqueue_row itself unchanged (no double-wake)
  • The three gap records retired with pointers (review-wave-4-fixes, suite-scheduler-rows Notes, review-wave-5 §Notes 4) + the implementation.md review-rounds line
  • cargo test -p alkstore-postgres green against the harness server; skips clean server-less; clippy -D warnings; fmt clean; workspace gates green

References

  • tasks/review-wave-4-fixes.md §"Recorded for later" (the original recorded note, incl. the one-call shape)
  • tasks/suite-scheduler-rows.md §Notes (the not-demanded disposition)
  • tasks/review-wave-5.md §Notes 4 (the gate's audit row)
  • tasks/pg-fix-tx-wake.md (the wake_tx pattern + commit-atomicity precedent; tx.rs:287)
  • docs/architecture/decisions/006-wake-and-delivery-contract.md (wakes best-effort, hint-only)
  • docs/architecture/decisions/009-scheduler-collapse.md §4 (the tick's tx frame)
  • docs/architecture/engine-postgres.md (pg_notify as the wake trigger)

Notes

Fix-shape decisions of record (what the description left open):

  • Coalescing: the default — one wake per tick per schedule that fired ≥ 1, issued after the fire loop (before the boundary-advance UPDATE), inside the caller's in_tx frame. Not per-fire: wakes are hint-only (ADR-006, no per-wake semantics exist to preserve), and the SQLite substrate's watcher wakes per committed tick transaction, not per enqueued row — the parity shape is the committed tick, not the individual fire. The for row in &due loop naturally yields one wake per firing schedule (multi-schedule ticks wake each schedule's own channel once). A quarantined row (continues before the fire loop) fired nothing and wakes nothing. No per-fire deviation.
  • Wake placement & ownership: the wake is issued at the tick call site in scheduler.rs::tick via the shared tx::wake_tx (widened private → pub(crate) exactly as the task pinned); enqueue_row stays wake-free (no double-wake), the auto-commit Queue::enqueue and the tx producer paths untouched. Channel = the fired queue's name; empty payload (Wake { channel }, ADR-008 §3); best-effort — a swallowed wake failure aborts the tx server-side so the truth surfaces typed at COMMIT (and the tick rolls back with its fires).
  • No wake issued for a due row whose fire count is zero-guarded — no guard added: a non-quarantined due row fires ≥ 1 by construction (the while next_fire_at <= now loop runs at least once), so the unconditional post-loop wake is exactly "per schedule that fired ≥ 1".
  • Test shape: one harness test in scheduler_tests.rs (scheduler_fire_wakes_are_commit_atomic_coalesced_and_nothing_due_is_silent), four arms: (a) pre-commit silence — the tick driven directly through the runner's own in_tx frame (the rogue_concurrent_ticks shape; the silence window needs the tick body observable before its COMMIT, which the runner's loop holds internally), listener silent statement-side, hears at/after commit; (b) coalescing — backdated one interval ⇒ ≥ 2 boundaries fire in the one tick, exactly one wake (not per fire); (c) nothing-due silence — a same-tick not-due schedule and a later all-not-due tick wake nobody (in-frame and post-commit); (d) end-to-end runner leg — a live run_schedules leader's fire wake reaches a registered listener. The direct-tick drive is the runner's exact frame (tick_once = in_tx + tick); the runner loop itself is separately exercised by the existing fire tests and by arm (d). A local listener_hears helper copy rides the per-file helper convention (tx_tests.rs keeps its own). The rollback arm was not test-built here (native — NOTIFY's transactional delivery, proven by notify_tx and the tx-wake test; the task pinned no tick-fault seam for it).
  • Doc updates rode along: tx.rs's "The tx wakes" module section and wake_tx's doc now name the scheduler fire path as a shared caller; scheduler.rs's module docs and tick's doc pin the coalesced commit-atomic fire-wake semantics (the contract-relevant text).
  • Flake-ledger occurrence (unrelated to this task's mechanism): during the first full pg suite run this session, row_lock_ttl_expiry_and_reacquisition (contract suite) failed once — the wave-5 ledger's second recorded occurrence. Green on the next two full runs plus six focused runs. Recorded in tasks/review-wave-5.md §6 with the ledger's own conclusion: the "fails again" trigger is met, a dedicated investigative session is owed by the release-readiness watch carriage (wave 7 at writing — wave 8 since the fuzzing wave's renumber) before any postulate-and-fix. Not load-bearing here (locks, not scheduler/wakes; scheduler code untouched by that row).

Summary

Landed: the scheduler tick's commit-atomic fire wake — scheduler.rs::tick now issues one coalesced pg_notify per firing schedule per tick (channel = the fired queue's name, empty payload) inside the tick's in_tx frame via the shared tx::wake_tx (widened to pub(crate)); the new four-arm pinning test; the driver docs (tx.rs wake section, scheduler.rs module + tick docs); and the three gap records retired with pointers (review-wave-4-fixes's recorded-for-later note, suite-scheduler-rows's gate disposition note + Notes bullet, review-wave-5 §Notes 4's audit row) plus the implementation.md review-rounds line — the three-way (SQLite / pg / mem-to-be) fire-wake story is symmetric ahead of wave 6.

Verified: full pg lib suite 122/122 green against the harness server (the new test deterministically green across three solo runs); pg contract suite 25/25 and schema 9/9 green (one unrelated single-run flake noted above, ledger-carried); workspace cargo test green server-less (pg skips clean, incl. the new test); cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean.