Files
alkstore/docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md

9.5 KiB

ADR-020: Enqueue option semantics — delay/run_at, expires, scheduler stamp source, payload encoding

Status

Accepted (2026-10-06, Phase 1 — second architecture review round, pre-decomposition; pre-implementation depth pinning per ADR-017 class 1, same frame as ADR-014/ADR-015)

Context

The second architecture review flagged four semantic holes where an implementer would otherwise improvise observable behavior:

  1. delay vs run_at precedence — ADR-008 §1 carries both fields in EnqueueOpts with only "run_at is the absolute-time counterpart of delay" for guidance. Claim ordering (priority DESC, ready-time run_at ASC — ADR-010 §1) silently presupposes an answer: what does an enqueue with both set mean?
  2. expires polarity — the v1 field set has one expiry field; relative lifetime or absolute deadline? The expires_at column and the sweep predicate depend on the answer.
  3. Scheduler-fired jobs' stamp source — ADR-009 §3 stamps boundary-fire enqueues "per ADR-010 §3a", but §3a resolves stamps "over the queue handle's QueueOpts" — and the scheduler's tick enqueues internally, with no queue handle open. The outbox closed this gap explicitly (derived QueueOpts defaults, ADR-010 §3); the scheduler never did. Two engines could pick different defaults before the contract suite catches it.
  4. The payload encoding bridge — ADR-008 §2 pins payloads crossing the trait as serde_json::Value; ADR-015 §3 pins StreamEvent.payload: Vec<u8> ("core value bytes"); payload_as<T> (decode side, error Codec) is pinned. The encode side — what relationship the stored bytes bear to the Value, without which the round trip and the cross-engine byte-equality rows are undefined — is stated nowhere.

All four are decidable now from decided material + the starting artifact's source; none is a new design space.

Decision

1. delay wins over run_at; both resolve to the row's ready time

Pinned exactly as honker's enqueue already implements it (honker-core/src/honker_ops.rs enqueue: "delay set → unixepoch() + delay (wins over run_at)"):

  • EnqueueOpts { delay, run_at } resolution precedence: delay set → ready time = now + delay (wins); else run_at set → ready time = run_at literally (absolute unix seconds); else → now (claimable immediately).
  • Both fields stay (honker parity, ADR-008 §1's "carried whole"); the resolution rule is the contract, and the row stores the resolved ready time — get_job shows the resolved run_at, never the enqueuer's raw fields.
  • No validation error for both-set: precedence is defined, not rejected (an error would be a third behavior for ADR-008 §5's rule to price, where honker's defined precedence already exists and suffices).
  • now is the engine's single second-precision clock (the queues pin); the same instant is used for the delay resolution and the row write (one enqueue, one clock read).

2. expires is relative seconds from enqueue; None = never

Pinned as honker implements it (expires_s: Option<i64> → row expires_at = unixepoch() + s):

  • EnqueueOpts.expires: Option<i64> is a relative TTL in seconds from the enqueue instant; the row's expires_at is resolved at enqueue and stored absolutely. None = no expiry.
  • This makes the stamping story uniform — every enqueue-time field resolves against the enqueue instant (delay → ready time; expires → expiry), and get_job shows the resolved absolute expires_at.
  • The consumer who wants absolute-form expiry computes run_at-style arithmetic on its side (expires = Some(t - now)); no second field is added (the v1 field set stays closed — an expires_at absolute twin would be an opts addition, class 3 priced, with no row naming it and the arithmetic available).

3. Scheduler-fired jobs' stamps: the target queue's derived defaults, engine-resolved

The boundary-fire enqueue resolves stamps from the named queue's derived QueueOpts defaults — the engine's built-in defaults (honker parities: 300 s / 3 / 5 s / none), not a handle's opts and not ScheduleOpts:

  • The tick enqueues internally; there is no queue handle in scope by design (queues are names, not registered objects — ADR-009 §5). The stamp source is therefore the engine's queue-defaults resolution — exactly the "no handle was opened" shape the outbox already resolved (derived defaults, ADR-014 §1; the same posture, the plain-queue default set instead of the outbox's 60 s/5/5 s set).
  • ScheduleOpts { priority, max_attempts, expires } ride as themselves — the two EnqueueOpts-minus-delay/run-at fields (ADR-009 §3) apply over the defaults (max_attempts overrides the default 3; expires resolves per §2); the other three stamps (visibility/backoff/retention) take the engine defaults. One resolution, engine-side, same on both engines by the contract suite's equivalence pin.
  • ScheduleOpts field growth post-release is class 3 (opts struct, ADR-017 §3's exemption) — stated so the pricing is visible now.
  • (Annotated 2026-10-07, third review round follow-through: §3a's "resolved over the queue handle's QueueOpts" reading applies only to the auto-commit Queue::enqueue — the one call shape with a queue handle in scope. Plain enqueue_tx(queue_name, opts, payload) is a no-handle-open shape too (a TxHandle never carries queue opts), so it stamps the engine's plain-queue defaults (300 s / 3 / 5 s / none) exactly as boundary fires do; the outbox's outbox_enqueue_tx keeps its 60 s/5/5 derived set (ADR-014 §1). Without this note the SQLite and pg implementers could legitimately diverge on the tx path's stamp source.)

4. The payload encoding: serde_json serialization of the trait Value, byte-exactly

  • The trait's payload parameter is serde_json::Value (ADR-008 §2); the engine serializes it with serde_json and stores exactly those bytes — the stored bytes of a job row or stream event row are, contract-pinned, the serde_json serialization of the Value the publish/enqueue carried.
  • Job/StreamEvent's payload: Vec<u8> fields and payload_as<T> decode those bytes; the error is Codec (pinned). Nothing re-encodes on the way out.
  • This makes the cross-engine byte-equality rows well-defined: a publish with Value V on engine A and engine B stores byte-identical rows (same serde_json canonical output for the same Value), which is what the stream-equivalence backlog row's byte comparison means.
  • Not pinned to a serde_json feature flavor (arbitrary-precision big ints, map key order etc.) beyond serde_json's default shipping behavior — the suite pins round-trip (publish → read → payload_as → Value equality), not storage-format trivia; engines must use serde_json (the family-standard JSON codec both POCs used), and a codec change would itself be a contract change under ADR-017 class 3 (it changes what payload_as can decode).

Consequences

Positive

  • The last four improvisation surfaces close with zero new machinery: two are honker's existing behavior promoted to contract text, one reuses the outbox's already-decided derived-defaults posture, one makes the existing round-trip assumptions explicit.
  • get_job's resolved-fields honesty (§1/§2) makes job behavior inspectable without ambiguity about which form was enqueued.
  • The equivalence suite's byte-equality rows get a stated meaning.

Negative

  • delay-wins-over-run_at is honker's precedence, carried as-is: a consumer supplying both gets the delay form's meaning — stated contract text, but a surprise if unread.
  • Relative-only expires puts the absolute arithmetic on the consumer needing it — acceptable at v1 field-set closure (no row names the second form).
  • serde_json's shipping behavior (map order, number handling) is implicitly in the stored row — pinned to default serde_json, testable by round-trip, and only visible via payload_as anyway.

References

  • ADR-008 §1 (field sets), §2 (the Value payload posture §4 encodes), §5 (§1's no-new-variant reasoning).
  • ADR-009 §3/§5 — the fire-enqueue shape §3 stamps; the names-not-objects posture that makes §3's source the engine defaults.
  • ADR-010 §1 (claim ordering's run_at key), §3a (stamping — §3 completes its source rule), §8.
  • ADR-014 §1 — the derived-defaults posture §3 reuses.
  • ADR-015 §3 — the Vec<u8> payload field §4 bridges.
  • ADR-017 §2 class 1/§2 class 3/§3 — the amendment frame; the opts-field pricing §3 states.
  • honker-core enqueue (/workspace/honker/honker-core/src/honker_ops.rs @ f4e53c6, precedence/expiration doc comment + implementation) — the §1/§2 behavior's source of record.
  • core-contract.md — the spec this ADR's §1–§4 pin into place.