Repository navigation
service-messaging on Postgres: sys_notification_delivery.claimed_at is a real (binary32) column, so every ack() fails the claim check and the inbox row is re-delivered every 131 s forever #23122
Description
Summary
On a Postgres database whose sys_notification_delivery table was created by @objectstack/driver-sql ≤ the release that still mapped number → table.float() (verified on 17.3.0), the epoch-ms columns claimed_at, next_attempt_at, last_attempted_at are physical real (IEEE-754 binary32).
A 13-digit epoch-ms value cannot be represented in binary32. Around today's timestamps the spacing between adjacent representable values is 131 072 ms, so whatever claim() writes into claimed_at reads back rounded to a multiple of ~131 s.
SqlNotificationOutbox.ack() then compares the stored value with the in-memory claim credential using strict equality:
if (current.claimed_by !== claimed.claimedBy || current.claimed_at !== claimed.claimedAt) {
throw new NotificationAckError(notificationAckLostClaimMessage(...), 'DELIVERY_NOT_ELIGIBLE');
}That comparison can never succeed, so every ack is refused as a "lost claim". NotificationDispatcher.ackAttempt() swallows DELIVERY_NOT_ELIGIBLE with a warn, so:
- the inbox channel has already inserted the
sys_inbox_messagerow (the send succeeded), - the delivery row stays
in_flightwithattempts = 0(ack never writes, so it never reachesdead), - the visibility-timeout reap (
claimed_at < now - claimTtlMs) also runs on the quantized column, so it releases the row once per 131 072 ms step, - the row is claimed and sent again → another
sys_inbox_messagerow → back to 2.
Each stuck notification is re-delivered ≈ 659 times per day (86 400 s ÷ 131.072 s), indefinitely.
Production evidence (PostgreSQL 16, platform 17.3.0, 3 app replicas)
information_schema.columnsforsys_notification_delivery:claimed_at,next_attempt_at,last_attempted_at,attempts,partition_keyare allreal.sys_notification_deliverystatus distribution:in_flight / inbox = 301withmax(attempts) = 0;success / inbox = 1097.- Write interval of one notification into
sys_inbox_message: 2:10.79 – 2:11.32, stable. - Top-10 notifications in the last 24 h: 659 rows each. Oldest stuck notifications (created 2026-09-16): ~17 550 inbox rows each.
sys_inbox_messagegrew to ~3.6 M rows / ~1.8 GB in a month, ~180 k rows/day, while the producing business table received a few thousand rows total.n_tup_del = 0, i.e. nothing is rolled back or deleted — it is pure re-delivery.- The only DB clients are the app replicas; no external writer.
Quick numeric check of the quantization:
const t = Date.parse('2026-10-12T13:00:00Z'); // 1791810000000
Math.fround(t); // 1791809945600 (Δ = -54 400 ms)
// spacing between neighbouring binary32 values at this magnitude: 131 072 msWhat is already fixed upstream, and what is not
- [finding] The NUMERIC column family diverges from driver-sql in both migration formats — driver
real, generatorsnumeric, andratingisrealagainstinteger#16318 changed thenumberarm ofcreateColumntonumericColumnFor()→numeric(65,30), so new tables are fine (confirmed in 17.7.0 dist). - Existing tables keep their
realcolumns:detectManagedDrift()has no numeric/base-type check for this family, and there is noos migrate …command for it (cf. the [Decision] 老客户把「选一个人」的字段改成「选多个人」时,平台该自己动客户的库,还是只报警要人来动?(#11535 的自动迁移半边) #11700 ruling: warn + operator-run migration). A deployment that created its schema on an affected release stays in the infinite re-delivery loop after upgrading, silently. ack()still comparesclaimed_atwith strict!==(currentpackages/services/service-messaging/src/sql-outbox.ts), and the claim-verificationfindusesclaimed_at: nowas an equality predicate — both are only correct if the column round-trips epoch-ms exactly.
Suggested fixes
- Detection / migration for existing
realcolumns of theNUMERIC_VALUE_TYPESfamily (at minimum the platform's own epoch-ms columns onsys_notification_deliveryandsys_http_delivery): a boot warning from drift detection plus an operator-runos migratestep (ALTER COLUMN … TYPE numeric(65,30)), consistent with the [Decision] 老客户把「选一个人」的字段改成「选多个人」时,平台该自己动客户的库,还是只报警要人来动?(#11535 的自动迁移半边) #11700 policy. - Make the claim credential robust to the column: e.g. use a random claim token (text) instead of
claimed_atfor the CAS, or compare after a round-trip read of the value written byclaim()rather than against the in-memorynow. - Never let a refused ack loop silently forever: a delivery that has been reaped N times without a recorded attempt should be surfaced (error log / metric) or moved to
dead, instead of re-sending withattempts = 0indefinitely. - Consider checking
sys_http_deliveryfor the same pattern (its time columns arenumbertoo).
Workaround we are applying
ALTER TABLE sys_notification_delivery
ALTER COLUMN claimed_at TYPE numeric(65,30),
ALTER COLUMN next_attempt_at TYPE numeric(65,30),
ALTER COLUMN last_attempted_at TYPE numeric(65,30);
UPDATE sys_notification_delivery
SET status = 'success', claimed_by = NULL, claimed_at = NULL, attempts = 1
WHERE status = 'in_flight';(17.3.0's read path already coerces numeric strings back to JS numbers via numericFields, and its drift detector does not flag the type change.) Followed by de-duplicating sys_inbox_message per (notification_id, user_id).
Activity
objectstack-fleet commentedon Oct 13, 2026
Triage: first grade,
bug·priority:p1·domain:services·area:workflow, intopm:queue. The claim check must survive the column it reads. No migration of existing columnsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-13T01:54Z. ⛔ Not a claim, ⛔ not a dispatch.Thank you for the production evidence and the arithmetic. Re-read on
origin/mainnow:sql-outbox.tsstill verifies a claim withclaimed_at: nowas an equality predicate (:189,:227), andack()still comparesclaimed_atwith!==(:269).real, generatorsnumeric, andratingisrealagainstinteger#16318's column table, which is new tables only. A workaround exists, as the report shows, so it is not p0.ack()stop comparing a client-side epoch-ms value against anumbercolumn by equality.sql-http-outbox.ts(sys_http_delivery) gets the same fix in the same PR.numbercolumn by equality. A same-class hit is fixed here. ⛔ No single-point card for it.numbercolumn quantises likereal(Math.fround).ack()succeeds, and the row reachessuccessafter one send;numericcolumn still acks.in_flightrow stuck today is reaped and claimed again under the fixed code. Its next ack then succeeds and the loop ends without an operator act. The PR shows this with one pin.realcolumns (your fix 1): the maintainer's ruling on [finding] The NUMERIC column family diverges from driver-sql in both migration formats — driverreal, generatorsnumeric, andratingisrealagainstinteger#16318 is "new tables only" (「不考虑现有数据」). Once the claim no longer depends on the column, the column's remaining effect is about 131 s of granularity on retry scheduling. Reopening that ruling is the maintainer's choice, not this card's.sys_inbox_messagerows: that is the operator's data. Your(notification_id, user_id)dedupe stays your act.packages/services/service-messaging/src/sql-outbox.tsandsql-http-outbox.ts(domain:services); rationale: a CAS on a lossy column can never succeed.objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 5 · 2026-10-13T02:02Z
Session:
session_01FzQy26KznSa5j2gnf3AzsEAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-23122-outbox-claim-credentialWorktree:
objectstack-issue-23122Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)Queue-jump: the maintainer's direct dispatch, verbatim 「23122 插队」, in this seat's session chat at 2026-10-13T02:00Z. It goes first in the pick order; the claim protocol is unchanged.
File surface:
packages/services/service-messaging/src/sql-outbox.tsandsql-http-outbox.ts(the claim verification andack()), their tests underpackages/services/service-messaging/, one.changeset/23122-*.md; plus any same-class claim or lease check the census finds (equality on a stored epoch-msnumbercolumn). A hit outsideservice-messagingis fixed in this PR and declared to its lane before the PR leaves draft (stop on breach; explain in the report)Container & model:
M (two outboxes, one credential shape measured on both drivers, plus a census),mode:subagent,model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"Clause-②: no — a runtime fix to the outbox claim credential; no published accept set or public surface moves.
Responsibility:
service-messaging'sSqlNotificationOutbox/SqlHttpOutbox(the platform's own code) verifies a claim by exact equality on an epoch-msnumbercolumn | none: drift detection does not flag therealcolumn, and existing columns are not migrated by ruling (#16318: new tables only) | every Postgres deployment whosesys_notification_delivery/sys_http_deliverytables were created before #16318's column mapping (the reporter's 17.3.0 production: 659 re-deliveries a day per stuck notification)Thread-read: 6136365627
Serial constraints cleared: no
pm:dispatchedclaim declaresservice-messaging(23 cards read), and no open PR touchespackages/services/service-messaging/(11 open PRs' file lists, read at this claim). The last landing on these files wasa7a48b784(#22045), not today. In flight in this lane: #23093 (PR #23114, in the merge queue) onplugin-security, disjoint.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{
"issue": 23122,
"status": "done",
"branch": "claude/issue-23122-outbox-claim-credential",
"pr": "#23138",
"session": "session_01FzQy26KznSa5j2gnf3AzsE (shared with the dispatching seat, mode:subagent; identity = the branch)",
"premise_still_valid": true,
"summary": "SqlNotificationOutbox and SqlHttpOutbox now hand out claimedAt as the claimed_at value the store read back after the claiming UPDATE, not the in-memory now. The claim read-back filters on candidate id, holder and status only, without the claimed_at: now equality. ack() is unchanged in code: its read half now compares two reads of one cell, and its compare-and-set binds the stored value. No schema, type or export change. Credential shape chosen: read-back, not a token. A token needs a new column on both platform objects and a new member on the published credential types, which the claim's Clause-② no rules out. Read-back was measured exact on live PostgreSQL 16.15 real and numeric columns, and on SQLite and memory. Live PG16 at bcebd84: H1 confirmed (the ack read half is the failing check; the JS read-back is the shortest round-tripping decimal, e.g. 1791810100000 for now=1791810012345, so ack is refused with DELIVERY_NOT_ELIGIBLE and the row stays in_flight with attempts 0, on both outboxes). H2 falsified on PG16: the claim verification find matched, because node-postgres parameters are coerced to real server-side, so the reporter's loop takes the ack path. The predicate is removed anyway, because the equality holds only when the store coerces the comparand. H3 confirmed: the reap does not fire at TTL+1 and fires within one 131072 ms step. On the fixed code the same server measured: real-column acks land; a stuck row is reaped, re-claimed and acked, and a later claim takes nothing; another node's re-claim still refuses the late ack with DELIVERY_NOT_ELIGIBLE. Census: no same-class hit outside service-messaging. Trade, stated in the PR's Acceptance notes: two CONCURRENT claim() calls with the same nodeId over overlapping candidates are no longer told apart by claimed_at. The dispatcher never does this, and before they were told apart only when their now differed by at least 1 ms. Not measured: PostgreSQL 11 or older, where extra_float_digits defaults to 0.",
"tests": "All at the final head 82c0b14 unless named. (1) pnpm --filter @objectstack/service-messaging test under os-verify-lock: 'Test Files 49 passed (49) / Tests 564 passed (564)', VERDICT command-exit 0. typecheck: tsc --noEmit plus the scripts program clean, VERDICT command-exit 0; tsc --listFiles includes the new test file (count 1). (2) New pin file src/outbox-claim-credential-lossy-column.integration.test.ts, 15 tests. A real ObjectQL+SqlDriver(better-sqlite3) engine sits behind a double with Postgres real semantics: binary32 store, comparand coercion, shortest-decimal read. A fidelity leg pins the three live PG16 readings. Per outbox, on the real double, an exact column and memory: one send and one ack reach success and three later reap windows send nothing; a claim lost to another node refuses the late ack ({name, code: DELIVERY_NOT_ELIGIBLE}) and the holder's ack lands. Real double only: a row stuck behind the pre-fix credential is sent exactly once more over five dispatcher-shaped passes. (3) Ablation, committed first at dee1e11, via scripts/ablation-replace.mjs (anchor hit and blob change verified) inside a trap restore. Leg A, sql-outbox.ts: the old claimed_at: now find and claimedAt: now restored (anchor x2, blob 8821ed0806e6 to ef1e0996acd3). Leg B, sql-http-outbox.ts: the same, nested (blob ceb424363276 to 31d0adc06902 to c9cdfee54d1d). Each leg gave 'Tests 3 failed | 12 passed (15)': exactly the three real-column legs of that outbox went red. 'AssertionError: promise rejected "NotificationAckError: ..." instead of resolving / Caused by: ... is no longer held by the claim this ack completes ... Serialized Error: { code: DELIVERY_NOT_ELIGIBLE }' (HttpAckError for leg B), plus the stuck leg 'expected [ ...(5) ] to deeply equal [ Array(1) ]', the same id sent on all five passes. Exact and memory controls stayed green. Restore proven: blobs equal HEAD (8821ed0806e6, ceb424363276) and git diff HEAD is empty. No dist involved: the pins import ./sql-outbox.js from src. (4) Live PG 16.15 one-off measurement on a private cluster, port 54731, stopped and deleted afterwards. Pre-fix bcebd84: 3 of 3 non-round instants refused per outbox; numeric control acks. Fixed e2af29f, source unchanged since (git diff --stat e2af29f HEAD over the 4 source files is empty): all ack; stuck-row and lost-claim legs as in the summary. (5) Gates: dispatch-gates --commands (no paths) derived 66 at e97bbb7 and at 82c0b14 (the PM's list of 65 plus pnpm check:dispatcher-error-vocabulary). Union at the final head 82c0b14: 66 of 66 EXIT=0, exit captured before any pipe, per-command log. check:i18n ('check-i18n-bundles: OK (10 package(s) ...)') and check:dual-build-cjs-loads ('108 published require entry point(s) across 67 package(s) load') were measured after building their prerequisite closure. The first union at e97bbb7 had check:query-options-erasure exit 1 ('test surface grew 236 to 240'), caused by the new double's erased options; fixed in 82c0b14 by typing them; now '... ratchet holds ... test surface: 236 site(s)'. dispatch-gates --ran: '66 derived famil(ies) accounted for, 66 run, 0 NOT-MEASURED'. Lint: NOT MEASURED locally, a repo-wide scan owned by CI. Also check:engine-double-contract EXIT 0 on the new double. CI: in_progress at report time; not waited on.",
"mcp_calls": "0",
"api_writes": "2 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, run as objectstack-fleet[bot]). (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23138/assignees [zhuangjianguo]; the run read back 11615 bytes sent and 11615 stored, identical. (2) comment = POST /repos//issues/23122/comments (this os-dev-report). Label writes: 0. The dispatch named none, and skip-changeset does not apply because the package publishes. git push x7 (not REST).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed · PostgreSQL 11 and older: extra_float_digits defaults to 0 (6 significant digits), so the compare-and-set that binds the read-back value would miss on a legacy real column. Not measured; versions past upstream support. Recorded in the PR's Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed · the census found one adjacent shape 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. A legacy real column there would store sizes over 2^24 lossily; that belongs to the ruled 'new tables only' family, not measured.",
"PR-body amendment for the seat (dev writes the body once): the Verification section says the final union re-run 'was in progress when this PR opened'. Its result is 66 of 66 EXIT=0 at 82c0b14, with check:i18n and check:dual-build-cjs-loads measured after their prerequisites were built. Optional edit: replace that sentence with this result."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Seat ACCEPT: PR #23138 at
82c0b1457· read-back claim credentialdomain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE· 2026-10-13T03:32Z, under claim6136508167(queue-jump, the maintainer's 「23122 插队」). The dev's report is6137555861. Checked on GitHub and onorigin/main, not from the report.Checklist
main, assigneezhuangjianguo. The first line isFixes #23122, thenClause-②: no. The only closing keyword is on service-messaging on Postgres:sys_notification_delivery.claimed_atis areal(binary32) column, so everyack()fails the claim check and the inbox row is re-delivered every 131 s forever #23122.packages/services/service-messaging. Not governed, and under the 3000-line threshold. The code change is insql-outbox.tsandsql-http-outbox.ts:claimed_at: nowfrom itswhere;claimedAtbecomesr.claimed_at, the value the store returned.http-outbox.tsandoutbox-dispatcher-scope.tschange doc comments only.ClaimedDeliveryRecordandHttpClaimCredentialare unchanged, soClause-②: nostands, and no contract review is owed.updatekeepsstatus: 'pending'in its sweep predicate, so a row is claimed once.nodeIdclaims are no longer told apart byclaimed_at. Read onorigin/main, it is unreachable today.notify-PID-RANDOM/http-PID-RANDOMinmessaging-service-plugin.ts), and its loop serialises ticks (a wake during a tick queues one follow-up).@objectstack/service-messaging: patch(the package publishes). Read sentence by sentence against the diff:realcolumn;DELIVERY_NOT_ELIGIBLE;real, generatorsnumeric, andratingisrealagainstinteger#16318 ruling).numericcontrol acking; post-fix, all ack;real-semantics double, an exact column and memory, with a lost claim to another node still refused and a stuck row sent exactly once more;real-column legs, and the restore was proven by blob;service-messagingpasses 564 of 564;service-messaging, with the positive controlsql-outbox.ts:189present.82c0b1457(latest run per name): 34 success, 6 skipped (expected roster), 0 red.mcp_calls0; the relay writes werepr_createand the report.findalso misses onrealwas falsified on PG16 (node-postgres coerces the parameter server-side). The predicate is removed anyway, because it is correct only when the store coerces.Out-of-scope findings
extra_float_digits0):Acceptance notes. Not measured, on versions past upstream support.service-storageupload-session compare-and-set:dropped. It compares byte counts against a read-back comparand, so it is out of class. A legacyrealcolumn there belongs to the ruled "new tables only" family.Landing:
pr_ready+automerge_enablethrough the relay. This seat follows it to MERGED.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Landed · PR #23138 merged through the merge queue at 2026-10-13T03:58Z as
5d63c37c56, and that commit is onorigin/main(read 2026-10-13T03:59Z).domain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE, under claim6136508167(the maintainer's queue-jump) and ACCEPT6137592480.main: theclaimed_at: nowclaim predicate has 0 hits insql-outbox.tsandsql-http-outbox.ts, andclaimedAt: r.claimed_athas 1 hit in each. The control,readBackClaimed, has 4 hits.ready_for_review, thenauto_merge_enabled, thenadded_to_merge_queueat 2026-10-13T03:35Z. The PR's only closing keyword wasFixes #23122.completedby that keyword. This write clearspm:dispatchedand the assignee.domain:services,area:workflow,priority:p1and the type label stay.maindoes not publish.@objectstack/service-messagingships.changeset/23122-outbox-claim-credential-read-back.mdat the next version release (the v18 line, per triage's release note). The reporter's SQL workaround covers 17.x until then. A stuckin_flightrow heals by itself once the fixed code runs.Generated by Claude Code