fix(cassettes): order ATM state events, and reconcile the bay set #44

Merged
padreug merged 2 commits from fix/cassette-state-reconcile into main 2026-09-22 20:30:45 +00:00
Owner

Closes #43

Phase 2 of the cassette synchronization audit, agreed with the bitspire side. No wire format change, so it ships independently of aiolabs/bitspire#104.

Ordering. The consumer's gate compared the incoming event id against the id on a single arbitrary row (SELECT ... LIMIT 1, no ORDER BY) — a one-event memory, so a re-delivered A, B, A applied three times. created_at was parsed, written to state_at and never compared, so an event arriving late overwrote newer state. Events are now applied only when strictly newer than the oldest stamp on file, compared as unix floats because SQLite returns integers, Postgres returns timestamps and the incoming value is tz-aware.

Reconciliation. Positions absent from the payload are now deleted. A machine whose bay count shrank used to leave a stale row, which made every later operator publish fail position-set validation with no remedy but DELETE FROM by hand.

On atomicity: the plan called for one transaction, but Connection.execute in the LNbits data layer commits per call, so that is not available here without reaching into internals. The gate compares against the oldest stamp instead, so a crash mid-apply is re-applied on the next event rather than mistaken for a complete one, and the ATM's heartbeat makes it converge.

Also renames apply_bootstrap_state to apply_reported_state and corrects the module comments: there has never been a once-per-machine guard, so calling it a one-shot bootstrap consumer described something the code did not do.

238 tests pass; ruff clean. The branch leaves the repo's pre-existing formatting state untouched.

Closes #43 Phase 2 of the cassette synchronization audit, agreed with the bitspire side. No wire format change, so it ships independently of aiolabs/bitspire#104. Ordering. The consumer's gate compared the incoming event id against the id on a single arbitrary row (SELECT ... LIMIT 1, no ORDER BY) — a one-event memory, so a re-delivered A, B, A applied three times. created_at was parsed, written to state_at and never compared, so an event arriving late overwrote newer state. Events are now applied only when strictly newer than the oldest stamp on file, compared as unix floats because SQLite returns integers, Postgres returns timestamps and the incoming value is tz-aware. Reconciliation. Positions absent from the payload are now deleted. A machine whose bay count shrank used to leave a stale row, which made every later operator publish fail position-set validation with no remedy but DELETE FROM by hand. On atomicity: the plan called for one transaction, but Connection.execute in the LNbits data layer commits per call, so that is not available here without reaching into internals. The gate compares against the oldest stamp instead, so a crash mid-apply is re-applied on the next event rather than mistaken for a complete one, and the ATM's heartbeat makes it converge. Also renames apply_bootstrap_state to apply_reported_state and corrects the module comments: there has never been a once-per-machine guard, so calling it a one-shot bootstrap consumer described something the code did not do. 238 tests pass; ruff clean. The branch leaves the repo's pre-existing formatting state untouched.
The gate on the ATM-state consumer compared the incoming event id against
the id stored on a single arbitrary row (SELECT ... LIMIT 1, no ORDER BY).
That is a one-event memory, not a watermark: a re-delivered A, B, A applied
three times. Worse, created_at was parsed, written to state_at and then
never compared, so an event arriving late overwrote newer state — nothing
in the path ever looked at the clock.

Events are now applied only when strictly newer than the OLDEST state stamp
on file. Strict '>' subsumes replay dedup, since a replay carries the same
stamp. Oldest rather than newest is deliberate: every execute in this data
layer commits on its own, so a multi-row apply cannot be made atomic here,
and gating on the oldest means a crash mid-apply is re-applied on the next
event instead of being mistaken for a complete one. The ATM republishes on
a heartbeat, so it converges.

Stamps are compared as unix floats because SQLite returns integers,
Postgres returns timestamps and the incoming value is tz-aware; comparing
raw would either raise or quietly mislead. An unparseable incoming stamp
fails closed.

Also renames apply_bootstrap_state to apply_reported_state and corrects the
module comments. There has never been a once-per-machine guard, so calling
it a one-shot bootstrap consumer described something the code did not do.

Refs #43

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(cassettes): drop bays the machine no longer reports
Some checks failed
ci.yml / fix(cassettes): drop bays the machine no longer reports (pull_request) Failing after 0s
106da5b46b
apply_reported_state upserted the positions in the payload and left every
other row untouched. When a machine's bay count shrank, the stale row
stayed: the dashboard showed a mix of old and new bays, and publish
validation then rejected every operator payload for a position-set
mismatch. The error text told the operator to fix it with atm-tui, which
could not propagate either, so the only way out was DELETE FROM by hand.

The payload is the machine's full bay set and the machine owns that layout,
so positions absent from it are now deleted. The delete runs before the
upserts: since this data layer commits per statement, a crash between the
two leaves rows missing rather than stale, and the next heartbeat
re-inserts them — the safe direction of the two.

Refs #43

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
padreug deleted branch fix/cassette-state-reconcile 2026-09-22 20:30:45 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/spirekeeper!44
No description provided.