Repository navigation
fix(service-messaging): read the outbox claim credential back from the store, so ack() lands on a real column - #23138
Conversation
…e store claim()/claimDigest() on SqlNotificationOutbox and SqlHttpOutbox now hand out claimedAt as the claimed_at value the store returned after the claiming UPDATE, not the in-memory now, and identify the claimed rows by exact predicates only (candidate id, holder, status). On a Postgres table whose epoch-ms columns are still `real`, the stored instant is a float32 rounding of now, so the old credential never equalled the stored value: every ack() was refused as a lost claim and the row was re-sent after each reap. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
A real engine behind a double that gives the epoch-ms columns Postgres real semantics (binary32 store, comparand coercion, shortest-decimal read-back, pinned to three live PostgreSQL 16.15 readings). Both outboxes: one send and one ack reach success, a claim lost to another node still refuses the late ack, and a row stuck in_flight behind an ack that could never land is sent once more and acked with no operator act. Exact-column and memory stores are the controls. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
…n the lossy-column pins Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
…dential Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
…tbox-claim-credential
…nstead of erasing their options Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3690c30c476dadb01335543e20c776d12339bc61 && git checkout 3690c30c476dadb01335543e20c776d12339bc61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 287fd6e5d054b9b1efa80f08f9e5be609edb32c2 82c0b1457f17b73b1cb91fc25180e3c79f1791f2 && git checkout -B drift-repro 287fd6e5d054b9b1efa80f08f9e5be609edb32c2 && git merge --no-ff 82c0b1457f17b73b1cb91fc25180e3c79f1791f2
node scripts/docs-audit/affected-docs.mjs --json 287fd6e5d054b9b1efa80f08f9e5be609edb32c2
|
Fixes #23122
Clause-②: no — a runtime fix to the outbox claim credential; no published accept set or public surface moves.
What changes
SqlNotificationOutbox(sys_notification_delivery) andSqlHttpOutbox(sys_http_delivery) now hand out the claim credential as the store holds it:claim()/claimDigest()returnclaimedAt= theclaimed_atvalue read back from the row after the claiming UPDATE, not the in-memorynow. The value written is unchanged.claimed_at: nowis no longer an equality predicate there.ack()is unchanged in code. Its read half (current.claimed_at !== claimed.claimedAt) now compares two reads of the same cell, and its compare-and-set (dispatcherAckCasOptions) now binds the stored value. Doc comments onIHttpOutbox.claimanddispatcherAckCasOptionssay so.ClaimedDeliveryRecord,HttpClaimCredentialand every export are unchanged. No schema change.Credential shape chosen: the value read back from the store, not a claim token. A token needs a new text column on both platform objects, with label translations, and a new member on the published credential types. That is a public-surface move the claim's
Clause-②: norules out. The read-back shape needs none of that. It was measured exact on live PostgreSQL 16realandnumericcolumns, and on SQLite and memory (the existing suites).Measurement: live PostgreSQL 16.15 (
extra_float_digits = 1),SqlDriver+ node-postgresFresh tables (
numeric), then the epoch-ms columns altered torealto recreate a table from before the new column mapping. Pre-fix code atbcebd8409:nowwrittenclaim()rowsclaimed_at = nowack()1.7918101e+12DELIVERY_NOT_ELIGIBLE; row staysin_flight,attempts01.7918135e+121.7918574e+12numericcolumnThe same three refusals happen on
sys_http_delivery(HttpAckError,DELIVERY_NOT_ELIGIBLE).ack()'s JS read half. The read-back is the shortest decimal that round-trips to the stored binary32. That is neithernownorMath.fround(now). A round instant such as1791810000000happens to read back equal and acks, which is why the loop is per row and not universal.find(claimed_at: now) matched: node-postgres sends the parameter untyped, so the server coerces it toreal. The reporter's loop therefore takes the ack path, not the claim path. This is consistent with an inbox row being written every cycle. The predicate is still removed, because the equality holds only when the store coerces the comparand to the column's own type.claimTtlMs + 1but does fire within one 131 072 ms step after it.Fixed code (
e2af29fd9;git diff --stat e2af29fd9 HEADover the four source files is empty), same server:realcolumn all three instants ack, for both outboxes. The row reachessuccesswithattempts1, and the credential is the read-back value. Thenumericcontrol acks.in_flight,attempts0). After the reap, the fixed code re-claims the row and the ack lands. A later claim takes nothing.DELIVERY_NOT_ELIGIBLE. Node B's ack lands.Pins:
outbox-claim-credential-lossy-column.integration.test.ts(15 tests)A real engine (
ObjectQL+SqlDriver, better-sqlite3) sits behind a double that gives the epoch-ms columns Postgresrealsemantics:A fidelity leg pins the three live readings above. Every write is still a real conditional UPDATE. Per outbox, on the
realdouble, on an exact SQLite column (control) and on the memory store (control):success, and three later reap windows send nothing;realdouble only: a row stuckin_flightbehind the pre-fix credential is sent exactly once more over five dispatcher-shaped passes (reap, claim, ack,DELIVERY_NOT_ELIGIBLEabsorbed), then left alone. No operator act is involved.Negative-pin ablation (committed first, at
dee1e11da)scripts/ablation-replace.mjs(anchor must hit, blob hash verified) put the pre-fix verification and credential back. Both files then ran under atraprestore.sql-outbox.ts.return this.readBackClaimed(ids, opts.nodeId);(x2) was replaced by the oldclaimed_at: nowfind plusclaimedAt: now. Blob8821ed0806e6becameef1e0996acd3. Result:Tests 3 failed | 12 passed (15). Exactly the threeSqlNotificationOutbox on a real columnlegs went red:sql-http-outbox.ts.claimedAt: now,and the oldclaimed_at: nowpredicate went back in, nested. Result:Tests 3 failed | 12 passed (15). The same three legs went red forSqlHttpOutbox on a real column, withHttpAckErrorandDELIVERY_NOT_ELIGIBLE.sql-outbox.tsis at8821ed0806e6, the HEAD blob.sql-http-outbox.tsis atceb424363276, the HEAD blob.git diff HEADis empty.Census: every platform claim or lease check comparing a stored epoch-ms
numbercolumn by equalityTree
bcebd8409(pre-fix), non-test.tsunderpackages/,examples/andapps/:git grep -E "(claimed|locked|leased|lease|lock|heartbeat|acquired|taken|picked|started)_at['\"]?\s*:\s*(now|claimedAt|claimed\.claimedAt|ts|stamp|t)\b"service-job/db-job-adapter.ts:547andservice-storage/metadata-store.ts:609arestarted_atwritten on insert (datetime), never compared===/!==on a*_at/*Atoperandsql-outbox.ts:269,sql-http-outbox.ts:380(ack read halves);memory-outbox.ts:150,memory-http-outbox.ts:162(JS numbers, no column, exact: unchanged). Out of class:approval-service.ts:6224andsuspended-run-store.ts:206(node-id strings),marketplace-install-local-plugin.ts:1761(ISO string from a JSON ledger file),verify/parent-write-reach.ts:824(diagnosticupdated_atcompare, not a claim)where: { ... }literal scanner: a*_atkey bound to a non-literal, non-operator value, 3325 filessql-outbox.ts:189is present.sql-outbox.ts:189,:227andsql-http-outbox.ts:259are the claim verifications: fixed (predicate removed, credential read back).outbox-dispatcher-scope.ts:290is the ack compare-and-set: fixed by construction (it now binds the read-back value; measured matching on PGreal)_at = ?/_at = $nmetadata-protocol/protocol.ts:2532, a doc comment, no codeField.numbercolumns holding time on platform objectssys_automation_run.start_time(no claim or lease reads it); five*_msdurationsNo same-class hit outside
packages/services/service-messaging. One adjacent shape is out of class:service-storage/metadata-store.ts:732, the upload-session progress compare-and-set. It compares byte counts, not epoch-ms, and its comparand is already a store read-back.Verification
pnpm --filter @objectstack/service-messaging test: 49 files, 564 tests passed, on the main-merged treee97bbb72e.pnpm --filter @objectstack/service-messaging typecheck: clean.tsc --listFilesincludes the new test file.pnpm --filter '@objectstack/service-messaging^...' build), then the package itself for the gates that readdist/.node scripts/pm/dispatch-gates.mjs --commands, no paths, identical ate97bbb72eand82c0b1457). First union ate97bbb72e: 63 exit 0;check:query-options-erasureexit 1 (test surface 236 to 240, from the new double's erased options), fixed in82c0b1457and re-run exit 0 (236, at the ceiling);check:i18nexit 3 PREREQUISITE NOT MET (CLI closure not built), prerequisite closure since built;check:dual-build-cjs-loadsexit 3 PREREQUISITE NOT MET (needs every package'sdist/): NOT MEASURED locally, declared to CI, narrowed proberequire('./dist/index.cjs')of this package loads (59 exports). The full union re-run at the final head82c0b1457was in progress when this PR opened; its verdict is in theos-dev-reportcomment on the card.Acceptance notes
claim()calls that carry the samenodeIdover overlapping candidates. Before, they were told apart only when theirnowdiffered by at least 1 ms. The dispatcher never does this: a node's ticks never overlap, andclaim/claimDigestsplit rows ondigest_key. On arealcolumn the read-back credential still separates a re-claim from the claim it replaced. The re-claim needs a reap first, the reap needs the stored instant to be older thannowminusclaimTtlMson the same column, and the re-claim then stores a later instant.extra_float_digits = 0prints six significant digits, which do not round-trip binary32. On arealcolumn there, the compare-and-set binding the read-back value would miss. Those versions are past upstream support.realcolumns stay as they are, so the reap keeps its ~131 s granularity;sys_inbox_messagerows.1791810100000for a binary32 of1791810076672. The conclusion is the same.Final verification (appended by the
domain:servicesseat 2 PM)The dev writes this body once, so the seat appends the dev's report text here (report
6137555861on #23122). The final union re-run, which was still in progress when this PR opened, finished at82c0b1457:check:i18nandcheck:dual-build-cjs-loadswere measured after their prerequisite closure was built;dispatch-gates --ranreports 66 run and 0 NOT-MEASURED.Generated by Claude Code