Skip to content

fix(service-storage): tombstone attachments on a predicate delete - #10951

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-10240-predicate-delete-tombstone
Aug 21, 2026
Merged

fix(service-storage): tombstone attachments on a predicate delete#10951
os-warren merged 1 commit into
mainfrom
claude/issue-10240-predicate-delete-tombstone

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#10240

What was broken

installAttachmentLifecycleHooks handed file ids from beforeDelete to afterDelete on the hook context itself (ctx['__attachmentFileIds']), on the premise stated in its own comment: "the engine passes the SAME HookContext object to both events". That was true of the pre-#5574 batch dispatch. Since ADR-0058 Addendum II (D1/D2) a predicate (multi: true) write dispatches one fresh context per matched row in each phasedispatchPerRowBeforeHooks / buildPerRowAfterContexts in objectql's engine.ts, which says so outright: "a per-row context is a fresh object, so a stash written on the context itself dies with the row that held it".

So on a predicate delete the stash never arrived, the orphan list was empty, and no tombstone was ever written. The bytes were stranded permanently rather than late: sys_file's declared lifecycle nominates a sweep candidate only via ttl { field: 'deleted_at' } or retention { onlyWhen: { status: 'pending' } }, and an untombstoned orphan matches neither — so the reap guard is never asked about it.

The premise, measured before touching anything

Both verbs, one tree, one run, on the wired ObjectQL engine:

casedispatchresult on the departed file
delete(where: { id })recordstatus: "deleted", deleted_at set
delete({ multi: true, where })per-rowstatus: "committed"no tombstone
update(where: { id }) re-pointrecordstatus: "deleted", deleted_at set
update({ multi: true, where }) re-pointper-rowstatus: "deleted", deleted_at set

The two update rows are green because the update verb's detach leg had already reached this conclusion; the delete verb is the half that was still leaking. Those four cases are now a pinned test (both verbs now behave alike on BOTH dispatch paths), so the table cannot quietly stop being true.

The repair

Read the departed id from ctx.previous.file_id, which the engine binds to the row's pre-image on both phases and both dispatch paths — by-id unconditionally since #7867 (it is the read that also produces the 404, so it is never skipped), per-row from the batch's single doomed-row read. This is the slot the update verb's detach leg already reads, so the module now carries one mechanism for "what file did this join row point at before?" instead of two that drift apart.

The beforeDelete registration existed only to write the stash, so it is removed. A test pins its absence: a second beforeDelete on sys_attachment reappearing means the stash mechanism came back with it.

The MULTI_DELETE_RESOLVE_LIMIT limb — measured unreachable, not assumed

The old beforeDelete carried a second branch (else if (ctx.input.options.where)) resolving the doomed set itself under a 1000-row cap, for a batch-shaped context binding no input.id. Triage asked for confirmation rather than assumption, so both branches of the live handler were instrumented with counters and every delete shape the engine offers was driven through the wired engine. The sibling id-branch is the positive control — it proves the instrument can see a hit:

delete shapeid-limb (control)where-limb (under test)
by-id10
predicate multi (1 row)10
predicate multi (3 rows)30
multi, where: { id: { $in: [..] } }20
multi, where: {} (match-all)20
multi, no where (unscoped)20
non-multi, non-id whereengine refuses: Delete requires an ID or options.multi=true

Zero hits on the branch under test, while the control fires on the very predicate path that branch was written for — including on where: { id: { $in: [...] } }, the batch-shaped delete it existed to serve. The mechanism agrees: all three sites that dispatch beforeDelete bind input.id to a scalar, and the unscoped-multi dispatch (#9719) reaches only registrations declaring dispatchUnscopedMultiWrite, which this file never did — and by definition carries no where at all. The limb is removed; the instrumentation was reverted byte-identically (git hash-object verified) before the fix was written.

Pins, in both directions

  • a predicate delete now writes the tombstone — on the fake and on the wired engine;
  • a by-id delete still tombstones — an implementation handling only the multi path passes the first and fails this one;
  • a predicate delete leaving another reference behind tombstones nothing, and never touches a non-attachments scope;
  • the update verb's four existing cases are unchanged;
  • no pre-image → tombstone nothing. Fail-safe direction chosen to match the update leg: a missed tombstone leaves an orphan lingering, while a tombstone written off a guess puts real bytes on the reap path 30 days later. Retention wins.

Ablation: reverting the handler to lose the id on dispatch.mode === 'per-row' turns exactly the four predicate-delete pins red (expected [] to deeply equal [ 'f1' ], expected status "committed" to match "deleted") and leaves the by-id, update and wiring pins green.

Upgrade note

Files already stranded by the old behaviour are not retro-actively tombstoned by this change — the repair is forward-only. That backlog is tracked separately in #10950, which is out of scope here and intentionally left for its own review: backfilling writes tombstones that become irreversible byte deletes 30 days later, which does not belong in a hook-context bug fix. The changeset says the same thing for upgraders.

Verification

Final sha 3fecc3eaa, clean tree.

  • @objectstack/service-storage suite: 405 passed (24 files).
  • Gate union derived with node scripts/pm/dispatch-gates.mjs (no path arguments) on the final commit; all 18 families green, exit codes captured before any pipe.
  • check:i18n first answered PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked; recorded as not-measured, the CLI was built, and it then reported check-i18n-bundles: OK (9 package(s) — all bundles in sync…).
  • check:type-check-debt --re-measure green: 33 ledger entr(ies) re-measured … none above its recorded number. Its note that @objectstack/plugin-auth TEST_DEBT records 109 while tsc now reports 97 was left alone--lower was not run.
  • The [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309 pair (check:route-envelope, check:dispatcher-error-vocabulary) was run explicitly with --self-test, both green. The path derivation did not name either of them.

Generated by Claude Code

The beforeDelete -> afterDelete file-id hand-off rode on the hook context
itself, on the premise that the engine passes the same HookContext to both
events. Since #5574 (ADR-0058 Addendum II D1/D2) a predicate write dispatches
one fresh context per matched row in each phase, so on a `multi: true` delete
the stash never arrived, the orphan list was empty, and no tombstone was ever
written -- leaving the file at status='committed' with its bytes stranded
permanently, since an untombstoned orphan matches neither declared sweep
policy on sys_file.
Read the departed id from `ctx.previous.file_id` instead, which the engine
binds on both phases and both dispatch paths (by-id unconditionally since
#7867, per-row from the batch's doomed-row read). That is the slot the update
verb's detach leg already reads, so the module now carries one mechanism for
"what file did this join row point at before?" rather than two.
The `beforeDelete` registration has no work left and is removed, together with
its `MULTI_DELETE_RESOLVE_LIMIT` limb. That limb's unreachability was measured,
not assumed: both branches of the live handler were counted while every delete
shape the engine offers was driven through the wired engine, and the sibling
id-branch (the positive control) fired on all six dispatchable shapes while the
where-branch fired zero times -- including on `where: { id: { $in: [...] } }`,
the batch-shaped delete it was written for.
Fixes#10240
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 7 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via deleted_at (literal))
  • content/docs/api/data-flow.mdx(via beforeDelete (literal))
  • content/docs/data-modeling/formulas.mdx(via beforeDelete (literal))
  • content/docs/data-modeling/queries.mdx(via deleted_at (literal))
  • content/docs/data-modeling/validation.mdx(via beforeDelete (literal))
  • content/docs/kernel/events.mdx(via beforeDelete (literal))
  • content/docs/permissions/attachments-access.mdx(via sys_attachment (literal))
  • content/docs/permissions/rls.mdx(via deleted_at (literal))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleted_at (literal))
  • content/docs/protocol/kernel/realtime-protocol.mdx(via deleted_at (literal))
  • content/docs/protocol/objectql/schema.mdx(via beforeDelete (literal))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via sys_attachment (literal))
  • content/docs/releases/v15.mdx(via deleted_at (literal), sys_attachment (literal))
  • content/docs/releases/v16.mdx(via beforeDelete (literal))
  • content/docs/releases/v17.mdx(via sys_attachment (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c2b97c2a188d3a5798f5ee224a943ccb413c6396packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9d413315fcf89b6f75a51bc508b5b4b7524388ab — the merge of head 3fecc3eaaaa18b162765eb6c2da7672d681e8696 into base c2b97c2a188d3a5798f5ee224a943ccb413c6396, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9d413315fcf89b6f75a51bc508b5b4b7524388ab && git checkout 9d413315fcf89b6f75a51bc508b5b4b7524388ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c2b97c2a188d3a5798f5ee224a943ccb413c6396 3fecc3eaaaa18b162765eb6c2da7672d681e8696 && git checkout -B drift-repro c2b97c2a188d3a5798f5ee224a943ccb413c6396 && git merge --no-ff 3fecc3eaaaa18b162765eb6c2da7672d681e8696
node scripts/docs-audit/affected-docs.mjs --json c2b97c2a188d3a5798f5ee224a943ccb413c6396

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c2b97c2a188d3a5798f5ee224a943ccb413c6396 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32524701294 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.03s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 48 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attachment tombstoning silently no-ops on a PREDICATE delete — the beforeDelete→afterDelete stash dies with the per-row context

2 participants

@os-warren@claude