Files
alkstore/tasks/pg-engine-scheduler-outbox.md
T

154 lines
7.9 KiB
Markdown

---
id: pg-engine-scheduler-outbox
name: Postgres engine — scheduler (`schedule`/`unschedule`/`run_schedules`) and outbox helper
status: pending
depends_on: [pg-engine-queues, pg-engine-locks]
scope: broad
risk: high
impact: component
level: implementation
tags: [wave-4, postgres-engine]
---
## Description
Implement the scheduler collapse's pg arm (ADR-009) and the outbox
helper (ADR-014/019 §5) — the wave's last mechanism pair, riding the
queues task's claim machinery and the locks task's lock machinery:
**Scheduler** (over the schema task's schedule table):
- **`schedule(name, spec, queue, payload, opts)`** — upsert by name;
entry-point validation on all three name-bearing arguments
(schedule name like a queue name; the **queue argument** identically
per ADR-021 §3 — a schedule cannot target a reserved name, so the
outbox backing queue stays reachable only through the outbox
surface); payload encoded at the seam (`Codec` propagation); the
spec grammar is `@every <n><unit>` only (`s|m|h|d`) — **the pg
engine owns its parser** (no shared substrate parser on this side;
the grammar is small and pinned; cron strings → `InvalidSpec`).
Rejection is pre-storage (`InvalidSpec` before any round trip).
- **`unschedule(name)`** — the unregister; `Ok(false)` when absent.
- **`run_schedules(stop: StopToken)`** — the leader loop, the
structural twin of the SQLite engine's runner with the pg deltas:
- **Leadership via the engine's own lock machinery** on the
reserved name `__alkstore_scheduler` (ADR-009 §1) — `try_lock`
with a per-runner-instance-unique owner token (the wave-3
finding's lesson: a shared constant would make two processes each
see "self" holding; the token is pid + subsec-nanos or
equivalent). TTL engine-internal (10 s), renewed at the top of
every loop iteration **and** on a cadence across the sleep (the
wave-3 posture: without in-sleep renewals, a boundary further out
than the TTL lapses its own lease by drift). A failed mid-sleep
renewal is not acted on at the sleep site — the loop's
top-of-iteration renew owns the loss decision.
- **Acquire-time refusal is `Err(LeadershipLost)`** — the same
matchable loss a mid-run loser returns (the wave-3 posture: no
runner-loop exists without the lock; the respawn recipe is
identical either way).
- **The tick**: fire enqueues + boundary advance commit together —
on pg this is one pool-checked-out transaction (`BEGIN` …
`COMMIT` on the held object, not `BEGIN IMMEDIATE` — pg's
default isolation gives the row-locked fire tx ADR-009 §4
names; the schedule row's `SELECT … FOR UPDATE` inside the tick
tx is the row lock that serializes rogue second tickers). The
tick body: boundary math over the registered specs (the
**one-firer-per-boundary** guarantee — at-least-once per elapsed
boundary, the **64-boundary catch-up cap with skip-forward**
past the cap, honker parity), each fire an ordinary enqueue
stamped with the `ScheduleOpts` resolved over the target queue's
*derived engine defaults* (no handle is open — ADR-020 §3), then
the soonest-boundary read for the sleep deadline.
- **Wake-driven tick advance** (the pg delta over the SQLite
runner's pure-sleep shape): the runner parks on the sleep
deadline, but an enqueue-wake on the scheduler's own wake channel
(or a plain bounded-wait interrupt — implementer's choice,
documented) may shorten the sleep so a newly-registered schedule
is noticed promptly; correctness never rides the wake (the
deadline always fires; the wake only shortens idle latency —
the SQLite runner's 60 s idle-tick posture is the acceptable
floor if the wake path proves fussy; flag the choice in Notes).
- **Clean stop** (`StopToken::cancel`) → `Ok(())`, the leadership
lock released cleanly; **loss** → `Err(LeadershipLost)` before
any tick.
- **Boundary math is this engine's own implementation** (ADR-012 §2's
one-owner rule — the substrate's `scheduler_tick`/`scheduler_soonest`
are SQLite-side): the `@every` interval arithmetic, the boundary
advance, the 64-cap catch-up, and the skip-forward. The contract
suite (wave 5) pins the two engines' boundary behavior to identical
outcomes.
**Outbox** (a helper over queues — no separate mechanism):
- **`Store::outbox(name)`** — validated constructor (empty →
`InvalidName`, reserved-prefixed → `ReservedName`); carries no opts
of its own (the backing queue's derived 60/5/5 set is
engine-documented, ADR-010 §3a).
- **`enqueue(payload, opts)`** — the auto-commit path into the
derived backing queue: derive `__alkstore_outbox:{name}` (the
reserved prefix makes it unreachable by `enqueue_tx`/`queue()` by
design), stamp the 60/5/5 set with the `EnqueueOpts::max_attempts`
override, full ADR-020 resolution, ride the queues task's enqueue
machinery.
- **`run_once(worker_id, delivery) -> bool`** — the pull op: claim
one job from the backing queue via the ordinary claim machinery
(the queues task's), run the delivery callable (core's `Delivery`
trait object — ADR-019 §5's pinned shape), `ack` on `Ok`,
`retry(err, None)` (the queue's curve; the delivery error's
`Display` string as the retry's exhaust string — the wave-3
posture) on `Err`, `false` on empty. **No engine-issued heartbeat
inside delivery** (honker parity, inherited deliberately — the
boxed `JobHandle`'s own `heartbeat` remains available to the
closure); the boxed `JobHandle` handed to the caller.
- **`outbox_enqueue_tx`** landed with the seam task; this task's
tests may extend its coverage (the commit-atomicity row is
suite-owned in wave 5).
## Acceptance Criteria
- [ ] `schedule`: validation triad (name/spec/queue), `@every`-only
grammar (cron → `InvalidSpec` pre-storage), upsert read-back;
the pg-owned parser pinned by tests (accepts/rejects the
grammar's exact surface)
- [ ] `run_schedules`: leadership via `__alkstore_scheduler` with a
per-instance owner token; acquire-time refusal =
`Err(LeadershipLost)`; tick fires + boundary advance in one
transaction (row-locked — a rogue second ticker cannot
double-fire); loss returns `Err(LeadershipLost)` before any
tick; clean stop releases the lock; in-sleep lease renewals
hold across boundaries further out than the TTL
- [ ] One-firer-per-boundary: two runners on the same db, exactly one
fire per boundary (the band test); 64-cap catch-up +
skip-forward with no in-window refire
- [ ] Fired jobs stamp the derived defaults resolved over
`ScheduleOpts` (`get_job`-visible)
- [ ] `outbox`: constructor validation; backing queue unreachable by
`enqueue_tx`/`queue()`; 60/5/5 stamps + `max_attempts`
override; `run_once` ok/err/false dispositions with the curve
gap asserted; in-delivery heartbeat renewal available;
exhaustion to dead with the delivery error string
- [ ] `cargo test -p alkstore-postgres` (harness server), clippy
`-D warnings`, fmt clean; gates green server-less
## References
- docs/architecture/decisions/009-scheduler-collapse.md (§1–§6)
- docs/architecture/decisions/014-outbox-tx-enqueue.md
- docs/architecture/decisions/019-mechanism-handle-surfaces.md §4, §5
- docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md §3
- docs/architecture/decisions/021-tx-reads-and-value-shape-fixes.md §3
- docs/architecture/engine-postgres.md (Mapping the contract: scheduler/outbox)
- docs/architecture/queues.md (scheduler section)
- alkstore-sqlite/src/scheduler.rs, outbox.rs (the structural twin —
same semantics, own boundary math; its Notes carry the flake-fix
lessons: drive catch-up tests from far-out horizons so no live
boundary races the assertion window)
- alkstore/src/stop_token.rs, outbox.rs (the traits)
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion