347 lines
16 KiB
Markdown
347 lines
16 KiB
Markdown
# ADR-019: Mechanism-handle surfaces — handle traits, value shapes, claimant identity, stop token
|
|
|
|
## Status
|
|
|
|
Accepted (2026-10-06, Phase 1 — second architecture review round,
|
|
pre-decomposition; amends [ADR-008](008-contract-v1-pinning.md) §1/§2/
|
|
§8 in place, pre-implementation ([ADR-017](017-contract-versioning.md)
|
|
class 1 — no release exists; the amend-in-place frame is still open))
|
|
|
|
## Context
|
|
|
|
Contract v1 pinned the `TxHandle`, `WakeReceiver`, and `EventReceiver`
|
|
trait shapes exactly, but the mechanism handles were left
|
|
deliberately open:
|
|
|
|
- [core-contract.md](../core-contract.md) (pre-review): "Mechanism
|
|
handles (`Stream`, `Queue`, `Outbox`, `Lock`) are core-owned trait
|
|
objects … their trait methods pin at implementation, mirroring the
|
|
`TxHandle` pattern."
|
|
- [ADR-009](009-scheduler-collapse.md) §1 deferred the `run_schedules`
|
|
stop parameter's type the same way ("the exact type shapes at
|
|
implementation, engine-crate docs").
|
|
- `Job` and `Schedule` — both `#[non_exhaustive]` consumer-read types
|
|
under [ADR-017](017-contract-versioning.md) §3 — had prose-only
|
|
field lists, split across ADR-010 §1/§3a and ADR-009 §1.
|
|
- The two `save_offset` forms (the pinned
|
|
`save_offset(stream, consumer, offset)` store/tx form and
|
|
[ADR-008](008-contract-v1-pinning.md) §8's receiver-side
|
|
`save_offset(&mut self)`) had no stated composition.
|
|
|
|
The second architecture review flagged that "pin at implementation"
|
|
no longer holds: [ADR-017](017-contract-versioning.md) makes trait
|
|
methods the lockstep-priced additive class and their shapes permanent
|
|
contract surface, and that ADR's class-1 window (amend-in-place)
|
|
**terminates at the core crate's first release** — and these shapes
|
|
ship in that first release. So the pinning must happen in an ADR now,
|
|
or the implementation agent would be authoring versioned contract
|
|
surface with no decision record behind it.
|
|
|
|
The decisions below are derived, not invented: honker-rs
|
|
(`/workspace/honker` `packages/honker-rs/src/lib.rs` @ f4e53c6) is the
|
|
starting artifact every placement so far has mirrored, and its handle
|
|
placement is the inherited shape — `[ADR-002](002-feature-scope.md)`'s
|
|
"fidelity where kept" posture applied to the *contract* side the way
|
|
[ADR-012](012-forked-substrate-design.md) §3 applied it to the
|
|
substrate's.
|
|
|
|
## Decision
|
|
|
|
### 1. Handle placement mirrors honker-rs — mechanism ops live on the mechanism handles
|
|
|
|
Naming, construction, and placement (the return types are boxed
|
|
trait objects — core-owned traits, engine types never in consumer
|
|
signatures, the [ADR-008](008-contract-v1-pinning.md) §2 posture):
|
|
|
|
```text
|
|
store.queue(name, opts) -> Box<dyn Queue>
|
|
store.stream(name) -> Box<dyn StreamHandle>
|
|
store.outbox(name) -> Box<dyn Outbox>
|
|
store.try_lock(name, owner, ttl) -> Option<Box<dyn Lock>>
|
|
```
|
|
|
|
*(Trait-name note: the stream handle's trait is `StreamHandle`, not
|
|
`Stream` — `StreamEvent` already occupies the type-name's vocabulary
|
|
and a bare `Stream` trait beside `EventReceiver` invites confusion
|
|
with tokio's `Stream`. The honker-rs struct name maps to
|
|
`StreamHandle`; everything else keeps honker's names.)*
|
|
|
|
```text
|
|
trait Queue {
|
|
name(&self) -> &str
|
|
enqueue(payload, EnqueueOpts) -> job_id
|
|
claim_one(worker_id) -> Option<Box<dyn JobHandle>>
|
|
claim_batch(worker_id, n) -> Vec<Box<dyn JobHandle>>
|
|
ack_batch(ids) -> count // non-claimed ids not counted
|
|
cancel(job_id) -> bool // unconditional delete
|
|
get_job(job_id) -> Option<Job> // sees dead rows; a value,
|
|
// no ops — see §2
|
|
sweep_expired() -> count // this queue's rows
|
|
}
|
|
|
|
trait StreamHandle {
|
|
name(&self) -> &str
|
|
publish(payload) -> offset
|
|
publish_with_key(key, payload) -> offset
|
|
read_since(offset, limit) -> Vec<StreamEvent>
|
|
read_from_consumer(consumer, limit) -> Vec<StreamEvent>
|
|
save_offset(consumer, offset)
|
|
get_offset(consumer) -> i64 // absent consumer = 0
|
|
trim_to(horizon) -> count // deletes offset <= horizon
|
|
subscribe(consumer) -> Box<EventReceiver> // shape: ADR-008 §8
|
|
}
|
|
|
|
trait Outbox {
|
|
name(&self) -> &str
|
|
enqueue(payload, EnqueueOpts) -> job_id // into the derived
|
|
// backing queue
|
|
run_once(worker_id, delivery) -> bool // see §4
|
|
}
|
|
|
|
trait Lock {
|
|
name(&self) -> &str
|
|
renew(ttl) -> bool // new full TTL window from
|
|
// now; false = lost it
|
|
release(self: Box<Self>) -> bool // consuming; true if held
|
|
}
|
|
```
|
|
|
|
- **Why mirror honker's placement**: it is the starting artifact's
|
|
proven shape; every prior decision (claim ordering, QueueOpts
|
|
stamping, the v1 skeleton's method *names*) already assumed
|
|
queue-scoped claim/maintenance ops — `sweep_expired(queue)` on
|
|
`Store` would take the name argument that the handle already
|
|
carries; a `Queue` handle without `claim_*` would be a construction
|
|
ceremony with no behavior. Honker's `Job`-ops-on-claimed-work shape
|
|
and `Lock::release(self)` RAII shape are likewise inherited.
|
|
- `get_job`, `save_offset`, `get_offset`, `read_since`,
|
|
`read_from_consumer` remain available
|
|
from *inside* a transaction through the `TxHandle`'s `*_tx` methods
|
|
(`get_job_tx`, `get_offset_tx`, `read_since_tx`,
|
|
`read_from_consumer_tx` —
|
|
[ADR-021](021-tx-reads-and-value-shape-fixes.md) §1) — the handle
|
|
forms are the auto-commit convenience counterparts
|
|
([ADR-007](007-transactional-seam.md)); claim/maintenance ops are
|
|
deliberately not tx-shaped (no `claim_tx` — a claim's visibility
|
|
deadline must not be tied to a business transaction's lifetime).
|
|
- The **reserved-prefix / name validation rules apply to the
|
|
constructors** (`queue`/`stream`/`outbox`/`try_lock`) exactly as
|
|
pinned by [ADR-008](008-contract-v1-pinning.md) §4 — nothing new;
|
|
the handles carry validated names.
|
|
- Trait *method-set* additions on these handle traits post-release
|
|
are [ADR-017](017-contract-versioning.md) class 2 — the same
|
|
lockstep cost as `TxHandle` additions; stated here so the cost is
|
|
priced before any consumer depends on it.
|
|
|
|
### 2. Claimant identity — `worker_id`, caller-supplied and consumer-local
|
|
|
|
- `claim_one`/`claim_batch`/`run_once` take a **`worker_id: &str`** —
|
|
the claim's ownership token, stamping the row's `worker_id` column.
|
|
It is a **consumer-local identifier in the stream-consumer-name
|
|
class** ([ADR-008](008-contract-v1-pinning.md) §4: no reserved-
|
|
prefix rule, no shared namespace): callers self-supply (a host id, a
|
|
task label); the contract requires only non-emptiness
|
|
(`InvalidName` otherwise — the v1 skeleton's only name-kind rule
|
|
addition, same variant, no new taxonomy).
|
|
- Cross-process semantics are the queue mechanism's, not the
|
|
identifier's: claim exclusivity comes from the claim statement's
|
|
atomicity ([ADR-010](010-queue-semantics-depth.md)), not from id
|
|
uniqueness; two workers self-supplying the same string is their
|
|
confusion to avoid (ops tooling reads it, nothing enforces it).
|
|
|
|
### 3. Value shapes — `Job` and `Schedule` pinned as structs
|
|
|
|
Both `#[non_exhaustive]` per [ADR-017](017-contract-versioning.md) §3
|
|
(field additions ride class 2):
|
|
|
|
```text
|
|
struct Job {
|
|
id: i64,
|
|
queue: String,
|
|
state: JobState, // enum: Pending | Processing
|
|
// | Dead
|
|
payload: Vec<u8>,
|
|
priority: i64,
|
|
run_at: i64, // the claim-order key
|
|
attempts: i64, // every claim counts
|
|
max_attempts: i64, // the row's stamp
|
|
worker_id: Option<String>, // claimant, None pre-claim
|
|
claimed_at: Option<i64>, // unix seconds at the most
|
|
// recent claim; None pre-claim
|
|
// (added by ADR-021 §2)
|
|
claim_expires_at: Option<i64>, // deadline, None pre-claim
|
|
created_at: i64, // unix seconds
|
|
expires_at: Option<i64>, // job-level expiry, None =
|
|
// never
|
|
visibility_timeout_s: i64, // the §3a stamps, dead rows
|
|
backoff_base_s: i64, // included (get_job returns
|
|
dead_letter_retention_s: Option<i64>, // the stamps with the row)
|
|
last_error: Option<String>, // dead-only (None on live)
|
|
died_at: Option<i64>, // dead-only
|
|
}
|
|
```
|
|
|
|
- Single `Job` type, two carriers: **claims return
|
|
`Box<dyn JobHandle>`** — ops + row:
|
|
|
|
```text
|
|
trait JobHandle {
|
|
job(&self) -> &Job // the row, as a value
|
|
ack(self: Box<Self>) -> bool
|
|
retry(self: Box<Self>, err: Option<String>, delay: Option<i64>)
|
|
-> bool // None delay = queue curve
|
|
fail(self: Box<Self>, err: Option<String>) -> bool
|
|
heartbeat(self, extend: i64) -> bool // absolute reset
|
|
}
|
|
```
|
|
|
|
*(Annotated 2026-10-07, third review round follow-through: the
|
|
original sketch elided `retry`'s return type — pinned `-> bool`,
|
|
the same validity predicate as its siblings. A refused retry —
|
|
deadline lapsed, row reclaimed/cancelled/acked elsewhere — leaves
|
|
the handle's op **not applied**: the handle was consumed by
|
|
`retry(self: Box<Self>, …)` regardless (caller-side), but the row
|
|
keeps its pre-call state (still `processing`, or gone if acked/cancelled)
|
|
with its original stamps; no dead-letter, no schedule-side delay is
|
|
recorded. `false` = the caller's window closed, at-least-once
|
|
redelivery governs.)*
|
|
|
|
— and **`get_job` returns `Option<Job>`** (pure data). The *same
|
|
uniform validity predicate* ([ADR-010](010-queue-semantics-depth.md)
|
|
§2 — `processing` + unexpired caller deadline) governs a
|
|
`JobHandle`'s ops; a get_job row yields no handle, so no ghost ops
|
|
exist on it. Ops take `self: Box<Self>` mirroring `TxHandle`'s
|
|
commit shape (consumed handle, one-shot discipline); `heartbeat` is
|
|
the deliberate counter-case — `self, extend: i64`, repeatable,
|
|
because renewal is inherently repeated within one claim (the same
|
|
one-shot/repeatable split the receiver shapes carry; do not
|
|
"fix" the asymmetry at implementation).
|
|
`err: Option<String>`: `None` = the engine's
|
|
documented-default string (taxonomy-consistent: these strings are
|
|
row content, `get_job` data — not matched error variants).
|
|
*(Annotated 2026-10-07, third review round follow-through: the
|
|
default string is pinned — **`fail(None)` records `"failed"`**;
|
|
`retry(None, …)` that exhausts the budget records
|
|
`"max attempts exceeded"`, per
|
|
[ADR-010](010-queue-semantics-depth.md) §4's path-based rule —
|
|
exhaustion is engine-observed state, so the path's pinned string
|
|
wins over the caller-None case; a successful `retry(None, …)`
|
|
records nothing (the row returns to pending, `last_error` exists
|
|
only on dead rows).)*
|
|
*(Struct corrected 2026-10-07 by
|
|
[ADR-021](021-tx-reads-and-value-shape-fixes.md) §2: `claimed_at`
|
|
was missing from the original pinning —
|
|
[ADR-010](010-queue-semantics-depth.md) §1's `get_job` list carried
|
|
it and the fork schema adds the column — restored above.)*
|
|
- `Schedule` (the `schedule()` read-back value):
|
|
`Schedule { name: String, spec: String, queue: String, opts:
|
|
ScheduleOpts }`.
|
|
|
|
### 4. `run_schedules(stop)` — the stop token is a core-crate type
|
|
|
|
```text
|
|
store.run_schedules(stop: StopToken) -> Result<()>
|
|
|
|
#[derive(Clone)] struct StopToken
|
|
impl StopToken {
|
|
fn cancel(&self)
|
|
fn is_cancelled(&self) -> bool
|
|
}
|
|
```
|
|
|
|
- **Pinned in core, not deferred**: the parameter type is contract
|
|
surface ([ADR-017](017-contract-versioning.md) — a pinned method's
|
|
signature), and a core-owned `StopToken` is exactly the
|
|
config-split posture ([ADR-008](008-contract-v1-pinning.md) §6)
|
|
in miniature — a contract-owned type whose *await mechanics* are
|
|
engine-internal (tokio watch per engine; not contract).
|
|
- Semantics ([ADR-009](009-scheduler-collapse.md) §1, unchanged):
|
|
`cancel()` on any clone flips every clone;
|
|
`run_schedules` ends its sleep early and returns `Ok(())` — the
|
|
clean-stop return; leadership loss still returns
|
|
`Err(LeadershipLost)` regardless of the token.
|
|
|
|
### 5. `run_once`'s delivery callable
|
|
|
|
`Outbox::run_once(worker_id, delivery) -> bool` (true = claimed and
|
|
processed) — the delivery argument is the consumer-supplied async
|
|
callable taking `Box<dyn JobHandle>`, returning the boxed future of
|
|
`Result<()>` (object safety: no generic methods on the boxed
|
|
handle — the [ADR-008](008-contract-v1-pinning.md) §2 boxed-future
|
|
posture applied to the one remaining surface it applies to). Outcome
|
|
posture is pinned as before: `Ok` ⇒ ack, `Err(e)` ⇒
|
|
`retry(err, None)` (the queue curve). The exact callable *encoding*
|
|
(`&mut dyn FnMut…`-form or a named one-method trait object) is
|
|
Rust mechanics with one workable answer under object safety — the
|
|
semantics above are the contract; the encoding follows §2's pinned
|
|
pattern at implementation.
|
|
|
|
### 6. The two `save_offset` forms compose by monotonicity
|
|
|
|
One op, two conveniences: `StreamHandle::save_offset(consumer,
|
|
offset)` / `save_offset_tx` write the checkpoint; the receiver's
|
|
`save_offset(&mut self)` ([ADR-008](008-contract-v1-pinning.md) §8)
|
|
writes **the receiver's last-yielded event's offset through the same
|
|
op**. Saves are monotone (a save whose offset is below the stored
|
|
checkpoint is a silent no-op — inherited: honker's
|
|
`ON CONFLICT … WHERE excluded.offset > existing` upsert), so the
|
|
forms interleave freely — they cannot regress or fight one another.
|
|
No-offset-regression is the pinned discipline: replay is a *read*
|
|
concern (`read_since(offset)` works from any offset; saved checkpoints
|
|
do not gate reads). Returns `Result<()>` — refusal (regression) is a
|
|
no-op, not caller-actionable (the act-differently rule,
|
|
[ADR-008](008-contract-v1-pinning.md) §5).
|
|
|
|
## Consequences
|
|
|
|
**Positive**
|
|
|
|
- Every handle a consumer constructs is now fully specified surface:
|
|
decomposition off the current text can write the contract-crate
|
|
task without inventing contract shapes.
|
|
- The last "pin at implementation" deferral in the contract surface
|
|
is closed, before the [ADR-017](017-contract-versioning.md) class-1
|
|
window that makes such pinning possible ends.
|
|
- Placement honesty: the contract mirrors the starting artifact that
|
|
every prior queue/lock/stream decision assumed, so no
|
|
re-interpretation debt accrues at implementation.
|
|
|
|
**Negative**
|
|
|
|
- Four more boxed-handle traits (plus `JobHandle`) for engines to
|
|
implement — the same per-trait cost `TxHandle` carries; measured
|
|
negligible against commit costs ([ADR-008](008-contract-v1-pinning.md)
|
|
§2), but real surface.
|
|
- Trait-method additions on five traits now ride ADR-017 class 2
|
|
lockstep duties (engine adoption releases are mandatory on trait
|
|
additions). Honest pricing, stated before consumers exist.
|
|
- `Job`'s field list is pinned wide (all stamps, both namespaces of
|
|
timestamps) — growth from here is class-2-minor via
|
|
`#[non_exhaustive]`, but the initial text is larger than a minimal
|
|
read type would be.
|
|
|
|
## References
|
|
|
|
- [ADR-008](008-contract-v1-pinning.md) §1/§2/§4/§5/§8 — the
|
|
partition this completes, the boxed-handle/object-safety posture,
|
|
the validation rules, the act-differently rule, the
|
|
`EventReceiver` shape and rename table this ADR's handle shapes
|
|
extend.
|
|
- [ADR-007](007-transactional-seam.md) — the auto-commit-vs-`_tx`
|
|
counterpart structure the handle forms slot into.
|
|
- [ADR-009](009-scheduler-collapse.md) §1 — the
|
|
`run_schedules(stop)` method whose stop type §4 pins; the return
|
|
semantics §4 restates unchanged.
|
|
- [ADR-010](010-queue-semantics-depth.md) §1/§2/§3a — the job
|
|
lifecycle, the uniform validity predicate §3's `JobHandle` ops ride,
|
|
the stamps `Job`'s fields carry.
|
|
- [ADR-017](017-contract-versioning.md) §2 class 1/§3 — the
|
|
amend-in-place window this ADR uses (and which its urgency derives
|
|
from), and the `#[non_exhaustive]` classifications §3's structs
|
|
satisfy.
|
|
- honker-rs surface (`/workspace/honker`
|
|
`packages/honker-rs/src/lib.rs` @ f4e53c6) — the placement record:
|
|
`Queue`/`Outbox`/`Stream`/`Lock` methods, `JobRow`/`Job` split,
|
|
`SaveOffset` upsert, `Lock::release(self)`, claim `worker_id`.
|
|
- [core-contract.md](../core-contract.md) — the spec this ADR's §1–§5
|
|
pin into place. |