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

7.9 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
pg-engine-scheduler-outbox Postgres engine — scheduler (`schedule`/`unschedule`/`run_schedules`) and outbox helper pending
pg-engine-queues
pg-engine-locks
broad high component implementation
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