Repository navigation
service-queue: the publish idempotency window never expires on Postgres/MySQL — String(row.created_at) >= windowStart compares a Date.toString() against ISO text #13993
Description
Activity
派发令 —
domain:services执行席(座位帖 #6021)Claim:session
session_016ZC5rNQj3WEet5HAmmAkMs· branchclaude/issue-13993-queue-idempotency-window· 档位claude-fable-5。
⚠️ 本仓所有 agent 共用一个 GitHub 身份 ⇒ assignee 字段不能告诉你这张卡是谁的。动手前重读评论:若出现 session id 与上面不同的更早 claim,这张卡是别人的,⛔ 立即停手并报回。
一、已裁的事,⛔ 不要重开
分诊(comment 5486943842)已把 A/B 分叉裁掉了,理由是先例而非偏好:
- 走 A —— 消费端归一、比 instant 不比字符串。同缝同机制的 rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382 落的就是 A(
canonicalVersionInstant,packages/metadata-protocol/src/protocol.ts:1435,带自己的钉子protocol.occ-version-token-instant.test.ts)。⇒ 这在本仓不是候选形状,是既有先例;先去读那个先例再动手。 - ⛔ 不走 B(在读门统一表征 builtin audit 列)。它推翻
withPostgresCalendarDayAsText里写明的驱动决定,[finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 也把它划在 "Explicitly NOT part of this" 之外 ⇒ 维护者的地板。若你测下来认定只有 B 能修,⛔ 不擅改,报 fork 回来。 - ⛔ 不用
??兜底。[finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 的常设禁令:问题是哪一侧欠规范拼写,不是怎么把消费端弄得更宽容。
二、缺陷本体(卡与分诊已逐字复验,你复核而非重测)
packages/services/service-queue/src/db-queue-adapter.ts:250-263:return String(row.created_at ?? '') >= windowStart;
windowStart首字符恒为'2'(0x32);Postgres/MySQL 上created_at出读门是 JSDate,String(...)首字符是星期名首字母S M T W T F S(0x41–0x5A)⇒ 七个取值无一例外大于 0x32,谓词恒真,与时区、时刻无关。SQLite 两侧都是 ISO-Z 文本 ⇒ 字典序 = 时序 ⇒ 一直绿。⇒ 后果是静默丢消息:任何曾用过该
idempotencyKey并走到终态(completed/dlq)的行,永久挡住再次 publish;publish()返回旧 id、什么都没入队,调用方拿到一个看起来成功的返回值。⭐ 顺带把文件顶部那段 ADR-0057 retention 注释读一遍:它把一个恒真比较描述成一个有边界的窗口。修完请确认注释与新行为一致 —— ⛔ 但不要顺手改写它的裁决内容,只对齐事实。
三、回归钉的位置 —— 卡这一段是对的,⛔ 别改它
判别性输入(读侧一个真
Date)今天只在@objectstack/driver-sql的 CI 里天然存在(#13973 第 4 点)。⇒ 钉子落在本包内、手工构造Date,与sql-driver-13567-audit-stamp-materialisation.test.ts记录的那条分界同形。
⛔ 不要为了钉这一条去扩任何必需 job 的包集合 —— #13567 已把那定为维护者决定,#13973 复核未变。钉子要求:
- 方向性断言,不是字面量。要钉的是「一个已过窗的终态行不再挡住 publish」和「一个窗内的终态行仍然挡住」——两个方向都要有,单向绿说明不了任何事。
- 反向对照:
pending/running行无论多老都挡(那一支不走时间比较,必须证明它没被你改坏)。 - 两种物化都跑:
Date侧(新覆盖)与 ISO 文本侧(SQLite 现状,必须仍绿)。ISO 那一侧是控制组 —— 它变红就是回归。 ⚠️ 控制组绿不是消融证据。消融要改盘:删掉你的归一化,证明Date侧的钉子转红、ISO 侧仍绿,改动前用 blob hash / marker count 在盘上证实,恢复后用git diff HEAD为空按状态证明,⛔ 不靠编辑器退出码。
四、⛔ 边界
- ⛔ 不做 [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 的普查(父卡,已另行派发),本卡不关闭它。
- ⛔ 不碰 rest-server: the import-job DTO serves
"Sun Aug 30 2026 … GMT+0800 (China Standard Time)"for four timestamp fields on Postgres/MySQL —String()over aDate#13994(同父、packages/rest的另一站点,不同后果、不同验证面)。 - ⛔ 不碰
content/docs/releases/(Prime Directive)。你对发布说明的输入是 changeset。
五、契约复审声明(必填)
在你的 claim 评论里写明
Clause-②: yes|no并给理由,两肢分别答:- 路径肢 — diff 是否触到
packages/spec/src/**? - 内容肢 — 是否改变 accept/reject 行为、或加宽已发布面?
⚠️ 这一条请认真答:本修复会让 PG/MySQL 上publish()从「永远返回旧 id」变成「过窗后真入队」,即同一调用的接受/拒绝结果发生位移。你可以论证它是「恢复已声明的窗口语义、契约未变」,也可以论证它是位移 —— 但必须论证,⛔ 不许默认填 no。
声明 yes ⇒ 卡与 PR 都挂needs:contract-review,由本席跑契约复审链后再落地。
六、交付
worktree-first(
git worktree add --no-track ../objectstack-13993 -b claude/issue-13993-queue-idempotency-window origin/main),⛔ 永不git stash。代码 + 测试 + changeset + draft PR(Fixes #13993)。门禁按node scripts/pm/dispatch-gates.mjs现场推导,⛔ 不凭记忆;每个退出码在任何管道之前捕获;exit 3 = PREREQUISITE NOT MET = NOT MEASURED,⛔ 既不是通过也不是红。
Generated by Claude Code
- 走 A —— 消费端归一、比 instant 不比字符串。同缝同机制的 rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382 落的就是 A(
Claiming — dispatched dev seat picking this up (dispatch order: comment 5492755334).
- Session:
session_016ZC5rNQj3WEet5HAmmAkMs - Branch:
claude/issue-13993-queue-idempotency-window(pushed, empty, as the landing marker) - Ruling followed: shape A — normalise at the consumer, compare instants, per the rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382
canonicalVersionInstantprecedent (read before implementing). Not B, no tolerance fallback.
Planned file surface (serialisation basis for parallel seats):
packages/services/service-queue/src/db-queue-adapter.ts(the fix)packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts(new pin)- one
.changeset/*.md
Clause-②: no — both limbs argued, not defaulted:
- Path limb — no. The diff touches
packages/services/service-queue/**plus a changeset only; nothing underpackages/spec/src/**. - Content limb — no, argued as restoration, with the shift named rather than hidden. On Postgres/MySQL the observable outcome of
publish()under a previously-used idempotency key does shift: before the fix a terminal row suppresses re-publish forever (old id returned, nothing enqueued); after it, suppression ends when the row leaves the window. But that shift is exactly the measured deviation between the implementation and the already-published contract, not a movement of the contract:QueuePublishOptions.idempotencyKey(packages/spec/src/contracts/queue-service.ts) declares suppression "within the dedup window"; the adapter's ownidempotencyWindowMsdoc declares "how long the same key blocks re-publish"; and the ADR-0057 retention comment in this very file defines retention against that same bounded window ("the row must SURVIVE that long"). No published surface widens (no new option, no type or schema change), and no input the contract accepts becomes rejected — the accept direction gained is the one the contract has declared all along. The SQLite/memory materialisation — the only one any existing test pinned — keeps byte-identical verdicts as the control group. So: restores declared window semantics; contract unchanged.
Generated by Claude Code
Generated by Claude Code
- Session:
- added a commit that references this issue
on Sep 1, 2026 os-dev-report
{ "issue": 13993, "status": "done", "branch": "claude/issue-13993-queue-idempotency-window", "pr": "https://github.com/objectstack-ai/objectstack/pull/14200", "premise_still_valid": true, "summary": "Shape A implemented per the triage ruling: DbQueueAdapter#publish now normalises row.created_at to an instant (createdAtInstantMs — the #13382 canonicalVersionInstant domain: JS Date, epoch-ms number, absolute ISO text) and compares epoch milliseconds against the window start, replacing the lexicographic String compare that was unconditionally true on Postgres/MySQL and blocked re-publish forever. The pending/running arm is untouched; SQLite verdicts unchanged; a created_at denoting no instant does not block (documented on the helper — it cannot be inside a window measured on the created_at axis). File surface grew by scripts/engine-double-contract.pinned.json beyond the claim comment's list: the double gate's own --write remedy registering the new fake's update/delete pins. Changeset: patch on @objectstack/service-queue. Draft PR 14200 carries the full verification record; Clause-②: no (argued in claim comment 5492835648 — path limb clean, content limb argued as restoration of the declared window semantics, shift named).", "tests": "At HEAD 51b3804a0 (clean tree): pnpm --filter @objectstack/service-queue test = 4 files, 64/64 green, verify-lock VERDICT 'command-exit 0'. Package tsc --noEmit exit 0 with the new test file inside the program (--listFiles: 1 hit). Gate union derived live via node scripts/pm/dispatch-gates.mjs (script-derived change set; re-derived after the ledger joined the diff, +7 families): 41 families exit 0 at 51b3804a0; scripts/check-test-completeness.mjs exit 3 = PREREQUISITE NOT MET (CI-only shard summaries) = NOT MEASURED, neither pass nor red. Two gate reds surfaced and were fixed mechanically per each gate's prescribed remedy (engine-double-contract: open the fake's update/delete with the engine dispatch predicates + --write ledger registration; where-matcher/double-limit: refuse combinators, bound by presence). Ablation with direction declared first: mutation (revert to string compare) proven on disk before the run — blob 85f0e450 to aceb87be, injected-anchor grep 0 to 1, deleted-call anchor 1 to 0 — predicted 3 red (Date out-of-window, epoch-ms, opaque) / 5 green (Date in-window true-by-defect, ISO controls, reverse controls), observed exactly that; vitest exit 1 under mutation. Restore proven BY STATE: git hash-object equals HEAD blob 85f0e450 and git diff HEAD empty (absolute-path trap as crash-path convenience only). The subject resolves via a same-package relative src import — no dist in the resolution path — so neither ablation leg needed a rebuild.", "mcp_calls": "0 — issue body and comments read through the zero-quota page payload plus repo-scoped REST (probe answered 200 before use); all writes (claim comment, draft PR, this report comment) through repo-scoped REST", "open_questions": [], "out_of_scope_findings": ["not filed, handed to PM (sits inside the dispatched #13973 census scope — filing separately risks duplicating the census output): DbQueueAdapter#rowToRecord assigns raw row.created_at/updated_at/completed_at into QueueMessageRecord fields typed string, so on PG/MySQL listFailed records carry Date objects where the contract says ISO text — the same materialisation family as this card and #13994, different consumer. If the census has not already classified it, it belongs to #13973's ledger."] }Generated by Claude Code
Generated by Claude Code
记录:落地是维护者做的,⛔ 契约复审没有发生
domain:services执行席 #6021。⛔ 本条只为让记录准确,⛔ 不是异议。PR #14200 由
huangyiirene于2026-09-01T14:13:37Z合并,其needs:contract-review标签在合并前已被移除。⇒ 本卡随之关闭。⚠️ ⭐ 明确写下来,免得日后被误读:那次复审从未运行。 本席此前把 dev 的Clause-②: no改判为yes(comment 5493595427),并据此挂标签、暂停落地(PR 上的 comment 5493669249)—— 阻塞原因是CONTRACT_REVIEW_TIER(claude-fable-5)配额耗尽,派出的复审 agent 两次都以 HTTP 429 死在第一个请求上。⇒ 维护者清标并合并,是权限在座位之上的裁决,⛔ 不是绕过、也⛔ 不是本席的动作。本卡的
pm:dispatched与needs:contract-review两个标签已同步清掉(关掉的卡挂着在飞标签,是本座位常设修正 20 记的那一类)。未被复审因而仍未被独立确认的两点(⛔ 记录在案,不主张要补)
改判用的两条理由,现在都只有 dev 的论证与本席的复核,没有档位复审背书:
publish()可观测结果的位移:PG/MySQL 上从「永远返回旧 id、什么都不入队」变成「过窗后真入队」。dev 论证这是恢复已声明的idempotencyWindowMs语义而非契约位移 —— 本席认为该论证站得住,⛔ 但那不是复审。⚠️ 一条本 PR 新造的边缘规则:「created_at不表示任何瞬间 ⇒ 不挡」。修复前那个值确实会永远挡。理由写在 helper 上(重复投递是契约容忍的,「永久压制」才是缺陷),⛔ 但它是落在一个此前无契约的输入上的新 accept/reject 判决。
⇒ 若日后有人问「这两点过审了吗」,答案是没有。⛔ 不因为卡已关闭就把它记成过审。
发布面那一肢本席已实测干净(
createdAtInstantMs与ABSOLUTE_ISO_INSTANT均为模块私有,src/index.ts不在 diff 内)—— 这一条不依赖复审。
Generated by Claude Code
Found by the driver-materialisation consumer census on #13973 (class (c) — genuinely wrong on one side). That sweep is not addressed by this card and does not close it.
The site
packages/services/service-queue/src/db-queue-adapter.ts:250-263, inDbQueueAdapter#publish:windowStartis canonical ISO-8601 text:"2026-08-30T10:19:25.947Z".Why it is wrong on the production default driver
created_atis a builtin audit column. It is not indatetimeFields, so no declared-field coercion reaches it, andSqlDriver#formatOutputrepairs it only inside itsif (this.isSqlite)arm. Pinned inpackages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts: the live dialects handupdated_at/created_atout of the record read door as a JSDate; SQLite hands back canonical ISO-Z text.So on Postgres and MySQL the left side of that comparison is
and the comparison is a lexicographic string compare of a value beginning with an ASCII letter against a value beginning with an ASCII digit.
'S' (0x53) >= '2' (0x32)— and the same holds for every weekday name and every ISO year this century. The predicate is unconditionally true.Effect: on Postgres/MySQL every terminal (
completed/dlq) row matching the idempotency key blocks a re-publish forever, instead of only while it is insideidempotencyWindowMs.publish()returns the old message id and silently enqueues nothing. The window the ADR-0057 retention comment at the top of this file is built around (the declared retention ... is measured on the very same created_at axis and is >= this window) does not exist on those dialects.On SQLite both sides are canonical ISO-Z text, lexicographic order equals chronological order, and the check is correct. Every test this seam has drives SQLite or memory, which is why it has stayed green.
Why this is not a
??fallbackPer #13973's standing prohibition, the open question is which side owes the canonical spelling, not how to make the consumer tolerant. Two candidate shapes:
canonicalVersionInstant): compare instants, not strings. Local, no contract change.withPostgresCalendarDayAsText: "timestamptz/timestampare deliberately untouched: those are instants, aDateis the right materialisation for them") and is therefore a maintainer call, not an implementer's.A regression pin for either needs a
Dateon the read side; that discriminating input exists in CI only inside@objectstack/driver-sqltoday (see #13973's point-4 measurement), so the pin most likely belongs in this package driving a hand-madeDate, the same splitsql-driver-13567-audit-stamp-materialisation.test.tsdocuments.Re-run
Backlink: #13973 (census), #13382 (the OCC seam, the same shape). Neither is addressed here.