# ADR-009: Scheduler collapses into queues — `schedule()` + runner, `@every`-only v1 ## Status Accepted (2026-10-05, Phase 1 — OQ-09's resolution; resolves the scheduler row transferred by [ADR-008](008-contract-v1-pinning.md) §7) ## Context [ADR-002](002-feature-scope.md) took the scheduler in as "documented-thin": the family-wide "who sweeps / renews / reaps, and when" problem is real, but only just begins to have consumers lean on it. OQ-09's decision rule (inherited from [queues.md](../queues.md)'s framing of it): decide against the inventory rows, not against the whole honker menu — the scheduler is a first-class mechanism if consumers need inspectable/pausable schedule *objects* (`add/pause/resume/update/list/remove`), and otherwise it collapses into queues + a `schedule()` call with the tick machinery absorbed. The evidence for the decision: - **alkblobs (sweep cadence)** — [documented: consumer-inventory.md](../../research/consumer-inventory.md) — needs "run the sweep every N ticks with leader election across a fleet." An interval, not a schedule object. - **alkfs (orphan reaping)** — [recalled: alkfs OQ-FS-07, `/workspace/@alkdev/alkfs/docs/research/phase-0.md`] — a periodic orphan-cleanup pass. An interval. - No consumer document (pinned, documented, or operator-authority) names any of: wall-clock cron expressions, per-schedule timezones, pausing/resuming schedules without unregistering, listing schedule objects, or updating a schedule's expression in place. So the collapse question itself answers cleanly. Two secondary questions ride along and this ADR pins them with the collapse: - **Which schedule specs does v1 accept?** Honker's parser accepts 5-field cron, seconds-field cron, and `@every ` — but its cron boundary arithmetic is hard-wired to *host local time* (`chrono::Local`, honker cron.rs), which is brittleness a contract should not inherit: a server TZ change silently re-times every stored schedule, and the two engines would diverge if one inherited local time and the other pinned UTC. - **What runs the tick?** Every consumer punting "the embedder owns cadence" is exactly the hand-rolling the substrate exists to kill — but an *ambient* runner violates the family's no-ambient-timers posture (alkblobs ADR-005). The line between them is opt-in. ## Decision ### 1. The scheduler collapses into queues The contract surface is two registration methods on `Store` plus one runner; there is no `Scheduler` handle, no schedule objects, no inspection surface: ```text store.schedule(name, spec, queue, payload, opts) -> Result store.unschedule(name) -> bool store.run_schedules(stop) -> Result<()> // runs until `stop` ``` - `schedule` upserts by `name` (re-register replaces the whole row) — honker's register semantics, which makes update = re-register and pause = unregister + re-register. Both are honest v1 substitutes; nothing in the inventory needs finer control. - `Schedule` is a value carrying the registered row (name, spec, queue, opts) — read-back for confirmation, not a mechanism handle. - **Name validation**: schedule names follow the queue-name rules — non-empty (`InvalidName`) and reserved-prefix rejection (`ReservedName`) at the entry point; `schedule()`/`unschedule()` are name-bearing entry points in the [ADR-008](008-contract-v1-pinning.md) §4 sense (their consumers-side obligation extends the entry-point list: a reserved-prefix schedule name could collide with the machinery's derived names). No charset rule beyond that in v1. *(The **queue argument** of `schedule()` gets the same directly-supplied-queue-name validation, added 2026-10-07 by [ADR-021](021-tx-reads-and-value-shape-fixes.md) §3 — without it, a schedule row could store a reserved name as its fire target and every boundary fire would enqueue into it, a third write path into the outbox's backing queue contradicting [ADR-014](014-outbox-tx-enqueue.md)'s stated guarantee.)* - `run_schedules` is the tick: acquire the leadership lock, loop {renew lock — on loss, *return before ticking*; fire every due boundary; sleep until the next due boundary or `stop`}. Losing leadership ends the runner — the consumer's recipe is to respawn it (documented); the lock loss always precedes any stolen fire, so respawn never double-fires a boundary. - **Stop and return semantics**: `stop` is a cancellation token — a core-crate cloneable `StopToken` (pinned by [ADR-019](019-mechanism-handle-surfaces.md) §4, 2026-10-06; the original "exact type shapes at implementation" deferral is superseded). A leadership loss returns `Err(LeadershipLost)` — a distinct, matchable outcome from the clean `Ok(())` a stop produces; the respawn recipe matches on it. (One taxonomy note: `LeadershipLost` is a *return-shape* variant of the runner, pinned by the same act-differently rule as ADR-008 §5 — the caller acts on it by respawning.) - Schedules do not fire until a consumer runs `run_schedules` — the opt-in is explicit, the no-ambient-timers posture holds, [ADR-002](002-feature-scope.md)'s scheduler evidence is served, and consumers who never schedule never pay for the runner. ### 2. v1 spec grammar: `@every ` only `spec` accepts exactly `@every ` with `n` a positive integer and `` in `s | m | h | d` (honker's `@every` grammar, honker cron.rs). Everything else — 5-field cron, seconds-field cron — parses as `InvalidSpec { spec }` and is rejected. - **Why not cron strings**: no consumer row names one (checked against the inventory, not against honker's menu), and accepting them forces a timezone answer now. `@every` durations are TZ-free — the TZ question dissolves instead of being answered. - **Extension path** (when a consumer names wall-clock cron via a consumer-inventory row): cron-string support with an explicit TZ decision attached (UTC-pinned contract or a per-schedule TZ parameter — decided by that extension, not assumed here). - This keeps the SQLite engine off honker's cron boundary machinery entirely (`@every` advance is `next = last + interval`) — the local-TZ brittleness is not inherited, and a dependency requirement is not created. *(Consumer-inventory rows change this only via the row-first discipline.)* ### 3. Enqueue semantics per boundary At each due boundary the tick enqueues the schedule's `payload` (`serde_json::Value`, uniform with all payloads) into the named queue with `ScheduleOpts { priority, max_attempts, expires }` — the [EnqueueOpts](008-contract-v1-pinning.md) fields minus delay/run-at (a boundary fire is never delayed by its own config; the schedule *is* the delay). The enqueued work is ordinary queue work: it inherits the queue mechanism's delivery guarantees ([ADR-006](006-wake-and-delivery-contract.md) queues row). The scheduler adds no delivery machinery of its own. ### 4. Boundary guarantee — the scheduler's row in the delivery table Extends [ADR-006](006-wake-and-delivery-contract.md)'s table, resolving the transfer from [ADR-008](008-contract-v1-pinning.md) §7: | Mechanism | Durability | Replay | Atomicity | Guarantee | |---|---|---|---|---| | scheduler (`@every`) | schedule rows + boundary fires are durable rows | n/a (fire = enqueue; the work's durability is the queues row) | fire and boundary-advance commit atomically under a row lock on the schedule row (crash mid-tick rolls back both — the boundary refires; a rogue ticker cannot double-fire) | **at-least-once per elapsed boundary while a leader runs**, with bounded catch-up: downtime fires replay boundary-by-boundary up to a fixed contract-constant per-tick cap (64, honker parity), beyond which missed boundaries skip forward to the next future boundary | - The **bounded-catch-up + skip-forward** semantics are pinned as the honest contract: a consumer who pauses runners for a week does not come back to 10,000 stacked sweep jobs. (For an `@every 60s` sweep under an outage, replays beyond the cap are useless anyway.) **The cap is a fixed contract constant (64 per task per tick, honker parity)** — not a knob; like the backoff cap, no consumer names one, and the skip-forward is documented behavior, not configuration. - **One firer per boundary** is pinned engine-generically by two layers: (a) the leadership lock — one leader at a time, a leader that lost the lock returns before ticking; (b) **the boundary advance is a transactional claim on the schedule row**: the fire transaction takes a row lock on the schedule row, re-checks `next_fire_at <= now` under the lock, enqueues, and advances — so even a rogue second ticker (an operator bypassing the leadership lock) observes a boundary as already-fired and skips it, on either engine (Postgres: `SELECT … FOR UPDATE` on the row inside the fire tx; SQLite: the same guarantee via writer serialization — `BEGIN IMMEDIATE`). Layer (b) is the correctness floor, layer (a) the efficiency (one waiter instead of row-lock contention); crash mid-tick rolls back enqueue + advance together (the boundary refires). - **Leader election** is a named lock under the reserved prefix — `__alkstore_scheduler` (engine-derived name in the lock namespace per [ADR-008](008-contract-v1-pinning.md) §4's rule; consumers can never collide with it because every entry point rejects the reserved prefix). Both engines: each engine's own lock machinery, engine- internal detail. One firer per boundary across all processes sharing the store. ### 5. Schedule storage is engine-internal Schedule rows live in engine-owned storage (SQLite: the forked substrate's `__alkstore_scheduler_tasks` table per [ADR-011](011-sqlite-substrate-fork.md) — honker's scheduler-task table re-owned and renamed, carrying `@every` specs in its spec column, the cron machinery not ported; Postgres: the engine-owned schema per [ADR-010](010-queue-semantics-depth.md) §8). Not consumer namespace; schedule *names* take the same validation as queue names (§1 — non-empty, reserved-prefix rejected). No registry validation against queue names — enqueuing into a queue nobody has claimed yet is fine (queues are names, not registered objects). ### 6. Error taxonomy delta Two variants added by this surface, per ADR-008 §5's act-differently rule: `InvalidSpec { spec }` — a schedule spec that doesn't parse under §2's grammar (the caller fixes the spec string; rejected before storage, no round trip) — and `LeadershipLost` — the `run_schedules` return when leadership is lost mid-run (the caller acts by respawning; distinct from clean stop, §1). ## Consequences **Positive** - The contract gains the scheduler for two registrations + one runner; the "who sweeps" problem has a substrate answer that is one `@every` row and one spawned task. - The tick machinery (leader election, boundary advance, catch-up cap) is engine-internal and mostly inherited (SQLite: honker's scheduler machinery with `@every` specs; Postgres: re-derived, small at this surface size — the row-locked fire tx is the one mechanism the contract pins engine-generically, §4). - No TZ semantics, no cron parser in the contract, no schedule-object CRUD — the surface stays proportionate to the two inventory rows that exist. **Negative** - Honker's richer scheduler (`add/update/pause/resume/list`, cron with TZ) is deliberately not consumed at the contract level; consumers needing inspectable schedules need an inventory row + contract extension first. - The opt-in runner is one more thing a deployer must remember: forgot `run_schedules` ⇒ schedules silently never fire. The mechanism's doc text must carry this (a silent-nothing deployment posture, the honest twin of "no ambient timers"). - The catch-up cap is a consumer-visible loss mode for long outages — documented (skip-forward), not silent: the schedule row's advance makes the gap inspectable. - The scheduler surface (and this track's `QueueOpts` depth) are **post-v1 contract extensions** — the first exercises of the extension path [ADR-008](008-contract-v1-pinning.md) leaves open; their versioning discipline (how engine crates track the additions) is OQ-10's, still open. *(Resolved 2026-10-06 by [ADR-017](017-contract-versioning.md): nothing was released when these extensions were made, so they factually fall in ADR-017 class 1 — pre-implementation amend-in-place — and ship inside the initial contract v1 text; the post-release extension *shape* this ADR exercised is ADR-017's class 2.)* ## References - OQ-09 (`docs/architecture/open-questions.md`) — this ADR's resolution; owns ADR-008 §7's transferred scheduler guarantee row. - `docs/research/consumer-inventory.md` §scheduler — the two rows the decision is made against. - [ADR-002](002-feature-scope.md) — scheduler in-scope posture this ADR completes (documented-thin → pinned thin). - [ADR-006](006-wake-and-delivery-contract.md) — the guarantee table §4 extends; the enqueued work inherits the queues row. - [ADR-008](008-contract-v1-pinning.md) §1 (scheduler out of v1 pending this decision), §4 (reserved-prefix rule the leadership lock name follows), §5 (act-differently rule the `InvalidSpec` variant follows), §7 (the guarantee-row transfer). - [ADR-010](010-queue-semantics-depth.md) — the queue semantics depth this ADR's fire semantics compose with; the maintenance recipe (who sweeps) is recorded there with the collapse machinery. - Honker reference checkout `/workspace/honker` @ f4e53c6 — scheduler surface (`packages/honker-rs/src/lib.rs`, `honker-core/src/honker_ops.rs`), `@every` grammar and local-TZ brittleness (`honker-core/src/cron.rs`), tick/leader mechanics. - [ADR-011](011-sqlite-substrate-fork.md) — the SQLite-side schedule storage this §5 describes is the forked substrate's; cron machinery not ported. - [core-contract.md](../core-contract.md) — the spec carrying this surface.