Wave 4 decomposed: Postgres engine — 11 tasks (schema, open/opts, seam, forwarder, mechanisms, scheduler/outbox, integration, review gate); plan synced
This commit is contained in:
1 parent
ecb8211694
commit
4caea21098
11 files changed
+1287
-3
No files matched your search
@@ -0,0 +1,154 @@
|
||||
---
|
||||
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
|
||||
Reference in new issue
Block a user