Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/claim-capability-probe-before-mutate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/service-automation": patch
---

`ObjectStoreSuspendedRunStore` no longer announces "no cross-replica advance guarantee is offered" *after* it has issued the guarded delete.

`claimSuspension` is the cross-replica half of the resume idempotency guard: it removes the `sys_automation_run` row only if the run is still parked where this replica read it, and the affected-row count names the winner. The refusal for an engine that does not resolve such a count was decided on the SHAPE of the return value — one line after the compare-and-set had already gone out. On such an engine that made the refusal a statement about a write that had already landed: the conditional delete was performed against the shared row and its verdict discarded, `AutomationEngine.claimAdvance` read `'unsupported'` as `unguarded`, and a replica that **actually lost** the claim (0 rows affected) resumed anyway — running every downstream side effect a second time, on the one composition that declares itself unable to prevent that.

The capability question is now settled before anything is claimed, and `'unsupported'` is retired as an answer once the row has been touched:

- **A one-time capability probe, before the compare-and-set.** Once per store instance, `claimSuspension` issues one delete down the very route the claim takes (`multi: true` with a `where` carrying keys besides `id`, which is what dispatches to `driver.deleteMany`) against a sentinel predicate that matches no row — the same value in `id`, `node_id` and `correlation` at once. An engine that resolves something other than a count is refused with **nothing consumed**, so `claimAdvance`'s `unguarded` reading is true when it is taken. Concurrent first claims share one probe, and a probe that *throws* is deliberately not memoized: a store that was unreachable for one second must not answer for the life of the process.
- **After the write, an unreadable verdict is `STORE_UNAVAILABLE`, not `unguarded`.** If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable — a winner and a loser both find the row gone, so no follow-up read can tell them apart. The store throws instead of answering `'unsupported'`; `claimAdvance` already maps that to `STORE_UNAVAILABLE`, whose text is written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is **refused** rather than continued. A claim that in fact won is then stranded until an operator retries — the deliberate direction, since a doubled side effect is the worse outcome.

**What this does not do, stated so it is not read into it.** It does not give an uncounted engine the guarantee. `ObjectQL.delete` declares `Promise<any>`, so "does a multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure — a probe can observe the route once, never promise what the next call resolves to. Closing that gap belongs to the engine boundary, where the count is contracted one layer down (`IDataDriver.deleteMany`, `Promise<number>`) and erased to `any` on the way up. On such a composition the store still degrades to an unguarded resume; what changed is that it says so before consuming anything, and the run's durable row is still removed by the consumption choke point exactly as before.

Every measured shipped composition already resolves a count (memory, sql/better-sqlite3, sqlite-wasm, turso local and remote transport, sql with the security plugin composed), so the observable cost there is one extra `DELETE … WHERE` that matches nothing, once per process. It emits no hook dispatch, no realtime event and no row change: the per-row before phase is "zero matched rows is zero dispatches", the after phase iterates the same empty set, and `publishBulkDataEvent` returns at `matched === 0` by design.
Loading
Loading