diff --git a/docs/adr/0074-delayed-sends-across-a-persisted-resume.md b/docs/adr/0074-delayed-sends-across-a-persisted-resume.md new file mode 100644 index 00000000..02ca72c2 --- /dev/null +++ b/docs/adr/0074-delayed-sends-across-a-persisted-resume.md @@ -0,0 +1,175 @@ +# ADR-0074: Delayed sends across a persisted resume: the host records the fire time, a cancel commits with the step's position, and the host drops a stale fire + +Status: proposed (2026-09-26) - decides three points that ADR-0060 +decision 7, ADR-0054 and ADR-0069's 2026-09-23 Note leave to the durable +host without stating them; amends none of their decisions; changes no +function, struct, effect field or position field; the one `lib/` change is +a pointer in `Statifier.Send.Processor`'s moduledoc + +## Context + +A session resumed from a persisted position does not restore its +delayed-send timers. ADR-0060 decision 7 makes re-arming them the durable +host's job, driven off the `%Statifier.Effect.SendDelayed{}` and +`%Statifier.Effect.Cancel{}` effects ADR-0054 publishes. ADR-0069's +2026-09-23 Note adds that routing a `` for a registered-type send +handed out before the save is the host's too, and +`Statifier.Send.Processor`'s moduledoc states the same rule. A host doing +that job has to answer three questions none of those records answers: + +1. **Where the fire time lives.** `%Statifier.Effect.SendDelayed{}` + carries `delay_ms`, a relative `non_neg_integer()`, and no absolute + instant (`Statifier.Effect.SendDelayed`'s struct and type). The + library has no clock in the core (ADR-0034), and `%MachineState{}` + carries no pending-send table: its struct has `send_counter` and + `timer_counter` and no field naming an armed send + (`Statifier.MachineState`'s `defstruct`). The live session's timer + table is session state (`Statifier.Session`'s `timers` field) and a + resumed session starts it empty (ADR-0060 decision 7). A host that + re-arms from the effect at resume, as `now + delay_ms`, fires up to one + full delay late. +2. **Which transaction a cancel joins.** Nothing says whether the host's + removal of a cancelled send must commit together with the persisted + position of the step that ran the ``, or may land before or + after it. +3. **Who drops a stale fire.** A fire can race a cancel, or arrive after + the session has left the state that armed it. Nothing says whether the + engine drops such a fire or the host checks before delivering. + +The SCXML specification (read from the local cache, +`$(git rev-parse --path-format=absolute --git-common-dir)/spec-cache/scxml-rec.html`) +says, in 6.2: "If the SCXML session terminates before the delay interval +has elapsed, the SCXML Processor MUST discard the message without +attempting to deliver it." In 6.3: "The Processor SHOULD make its best +attempt to cancel all delayed events with the specified id. Note, however, +that it can not be guaranteed to succeed, for example if the event has +already been delivered by the time the tag executes." It says +nothing about a save and a resume, and nothing ties a pending delayed send +to the state whose content sent it. + +Every `lib/` cite in this record was read at `eb114947`. + +## Decision + +**1. The absolute fire time lives in the host's timer store, computed by +the host once, when it is first handed the send.** When a host (or a +registered processor, ADR-0069 decision 4) is handed a +`%Statifier.Effect.SendDelayed{}`, it reads its own clock and stores +`due_at = now + delay_ms` on the row it writes for that send, beside the +dedup key's components (ADR-0054 decision 3, with ADR-0059's `ordinal`). +Re-arming after a resume reads `due_at` from that row and never recomputes +it from `delay_ms`. A row whose `due_at` has already passed when the host +re-arms fires at once: late, never dropped for lateness, because 6.2 names +termination, not lateness, as the reason to discard. + +The library still computes and stores no instant. ADR-0060 decision 7's +statement that no deadline is written "anywhere a position could carry" +stands: the deadline lives in the host's store, never in +`Statifier.Position`'s blob, and `delay_ms` stays the only timing field on +the effect. A host that reads `delay_ms` again at resume time has no +correct answer to compute; the row it wrote at hand-off is the only place +the instant can be recovered from. + +**2. A host's timer-store writes for a step commit in the same transaction +as the persisted position that step produced; a cancel is one of them.** +The removal of every row under ADR-0054 decision 3's cancellation key, +`{session scope, send_id}`, for a `%Statifier.Effect.Cancel{}` commits in +the transaction that saves the position after the step whose effects +carried that cancel. The insert of a `%Statifier.Effect.SendDelayed{}` +row, with its `due_at` from decision 1, commits in the same transaction +for the same reason. The rule holds whether the cancel reaches the host +off the effect stream or through `c:Statifier.Send.Processor.cancel/2`. + +Both orders apart from one transaction lose something: + +- *Position first, cancel after.* A crash between the two leaves a saved + position past the `` with the send's row still pending. A resume + from that position does not run the `` again: a resumed session + starts at the saved, quiescent position and emits no initialization + effects (ADR-0060 decisions 4 and 6), so nothing re-emits the cancel and + the cancelled send fires. +- *Cancel first, position after.* A crash between the two leaves the row + removed while the saved position is the one before the step. If the step + is re-driven, the re-emitted cancel matches nothing, which is a no-op + (ADR-0054 decision 3); if it is not, the position waits on a send that + can no longer fire. + +A host whose timer store and position store cannot share a transaction +does not meet this decision, and says so to its own users; the library +cannot close the gap for it, because it sees neither store. + +**3. The host drops a stale fire, on the cancellation key; the engine does +not.** Before the host delivers a fired send, it checks, in this order: + +1. **The send's own row is still pending, and the fire claims it + atomically.** The row is found by its dedup key, whose first two + components are the cancellation key `{session scope, send_id}`. The + claim moves the row out of the pending set in one write, so a cancel + that committed first (decision 2) has already removed it and the fire + is dropped, and a cancel that commits after the claim matches nothing + and loses the race, which is the loss 6.3 allows. The session scope is + `_sessionid`, which a resumed session keeps by default (ADR-0060 + decision 3), so the key is the same on both sides of a resume. +2. **The execution is live.** ADR-0054 decision 4's two-step check, + unchanged: terminated or halted, the message is discarded without + delivery (6.2). + +The host does **not** check the session's current configuration. A +delayed send is not scoped to the state whose content sent it: 6.2 and 6.3 +make `` and termination the only ways to stop one, so a fire that +arrives after the session left the arming state is delivered. A chart that +wants the send scoped to a state writes the `` in that state's +``, and decision 2 makes that cancel durable. + +The engine cannot make this check, for three reasons read at `eb114947`: + +- The persisted position carries no set of armed sends to check a fire + against (`Statifier.MachineState`'s `defstruct`); the live session's + tables, `timers` and `held_sends`, are session state and start empty on + resume (ADR-0060 decision 7, ADR-0069's 2026-09-23 Note). +- A fired self-routed send re-enters as an ordinary event through + `Statifier.Session.send_event/2` (ADR-0054 decision 2), and the built + event names its send only when the author wrote the id: + `Statifier.Send.Event.build/3` sets `sendid` from `id_from_author?` + (C.1's empty-`sendid` rule), and `%Statifier.Event{}` carries no + `ordinal`. The engine cannot tell a fire from any other event, or one + send from another. +- Giving the engine that view would add a pending-send field to the + position and a fire door carrying the key: new public surface. This + record rejects it; the host already holds the row the check needs. + +No engine test pins a drop, because the engine drops nothing. What the +host checks is the list above. + +## Consequences + +- A host that re-arms from `delay_ms` at resume is nonconformant with + decision 1, and one that commits a cancel outside the step's position + transaction is nonconformant with decision 2. Both were unstated before + this record. +- `Statifier.Send.Processor`'s moduledoc points at this record from its + `cancel/2` section; a registered processor that owns a delayed send + owns all three decisions for it. +- Nothing else in `lib/` moves. No effect, position or recording field is + added, and no conformance result moves. +- The host-side code these decisions call for lives in the durable host + packages, not here: storing `due_at` at hand-off, committing timer rows + with the step's position, and the atomic claim at fire time. Whether a + given package already does each is that package's to verify. +- What would reopen this record: the position gaining a pending-send + table or the effect gaining an absolute instant (reopens decisions 1 and + 3, and is ADR-0034's and ADR-0060's territory first); a fired send + gaining a public door that carries its dedup key (reopens decision 3's + engine half). + +## Related + +- ADR-0060 (decision 7: timers are not restored by resume; decision 3: + the kept `_sessionid`; decisions 4 and 6: a resumed session starts at a + quiescent position and emits no initialization effects) +- ADR-0054 (decision 3: the cancellation key and the dedup key; decision + 4: the fire-time liveness check this record's decision 3 keeps) +- ADR-0059 (the `ordinal` the dedup key carries) +- ADR-0069 (decision 4 and its 2026-09-23 Note: registered-type delayed + sends and the cancel a resumed session no longer routes) +- ADR-0034 (no clock in the core) diff --git a/docs/adr/README.md b/docs/adr/README.md index 10b440bd..0486d333 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -75,6 +75,7 @@ | [0071](0071-chart-event-vocabulary-and-accepts-check.md) | A chart's event vocabulary and its accepts check are pure functions on `Statifier.Chart`, outside `Statifier.Validator`: `events/1` returns every descriptor on a transition from a state some path enters, patterns kept whole; `check_accepts/2` lists declared names nothing matches and descriptors nothing declares; a statifier case's `host` object carries the declaration and the expectation | accepted (extends 0070) | | [0072](0072-chart-diff-classes-and-position-compatibility.md) | Two compiled charts diff into four classes with their reasons: `Statifier.Chart.diff/3` answers Identical (`Identity.matches?/2`), Compatible, Mapped (from a caller-supplied `mapping:` option) or Breaking, over normalized fields; `Statifier.Position.compatible_at?/3` answers whether one execution's position is untouched, by byte-identical source slices of each active state's transitions, `` and ``, and is called by nothing in the library | accepted | | [0073](0073-one-publish-findings-function-holds-every-publish-time-check.md) | One pure publish findings function, `Statifier.Publish.findings/2`, over a compiled chart and the host's declaration (`send_types:`, `invoke_types:`, `accepts:`), returning findings that each name their row of `docs/publish-time-checks.md`; composes `Statifier.Send.Types.unsupported_sends/2` (S1) and `Statifier.Chart.check_accepts/2` (S15) unmoved; every literal-decided NONE row lands as one check inside it, never a module or a public function; `Statifier.Validator` and `compile/2` untouched | accepted | +| [0074](0074-delayed-sends-across-a-persisted-resume.md) | Delayed sends across a persisted resume: the host stores the absolute fire time on its own timer row when first handed the send, never in the position; a step's timer-store writes, a cancel included, commit in the transaction that saves that step's position; the host drops a stale fire by an atomic claim on the send's row under `{session scope, send_id}` plus ADR-0054's liveness check, never by the session's configuration; the engine drops nothing | proposed | New ADRs: next number, same three-section format (Context, Decision, Consequences), drafted or reviewed at the direction level per `docs/workflow.md`. Pick the number diff --git a/lib/statifier/send/processor.ex b/lib/statifier/send/processor.ex index 51f59f5c..ea8bcc93 100644 --- a/lib/statifier/send/processor.ex +++ b/lib/statifier/send/processor.ex @@ -64,6 +64,12 @@ defmodule Statifier.Send.Processor do entry, and a generated id adds one per uncancelled send. The entries go with the session process when it stops. + Where the fire time of such a send lives, which transaction a cancel + commits in, and which side drops a fire that races a cancel are + ADR-0074's: the host stores the absolute fire time when first handed + the send, commits the cancel with the position of the step that ran it, + and drops a stale fire itself; the session drops none. + ## When the host cannot deliver A processor that cannot deliver a send while its sender still exists