test(metadata-protocol): re-measure the deleted-datasource prolongation across replicas after #13331's fix - #14431
Conversation
…ross replicas WIP — measurement harness for #13609: two protocol replicas over one shared sys_metadata store, measuring how long the peer's /api/v1/meta/datasource read door keeps serving a deleted datasource, with and without the landed #13331 publisher + cluster bridge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ss replicas Re-verification for #13609, the carrier the maintainer's ruling left open for it: with #13331's publisher + service-cluster bridge landed, how long does a peer replica keep serving a deleted datasource on the /api/v1/meta/datasource read door? Two protocol replicas over one shared sys_metadata store, each with its own registry, overlay cache and write epoch, joined by a real cross-instance transport. Declared as a PROXY for a live multi-node deployment, which is not reachable from this container. Measured: the bridge does heal the peer's registry within one bus hop, but the peer's overlay cache is not invalidated by the convergence, and any read of the peer's own door inside that residue window re-hydrates the deleted row back into the untimed registry. So the prolongation is bounded by one 30s TTL window only when no read lands in the window, and unbounded when one does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…lete-fanout-reverification
… ledger Gate scaffolding for check:engine-double-contract, produced by `node scripts/check-engine-double-contract.mjs --write`. The three rows are the delete/findOne/update doubles of the #13609 measurement harness, all already routed through assertEngine*Dispatch; the ledger just has to learn about the file or it never protects it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…lete-fanout-reverification
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
Provenance ( Generated by Claude Code |
Part of #13609
Tests only. No source change:
packages/metadata-protocol/src/protocol.ts,packages/metadata/src/metadata-manager.tsandpackages/services/service-cluster/**are untouched, as the card's dispatch requires. The one non-test file is gate scaffolding (see "Gate union").What this is
The maintainer's ruling (director batch A, 2026-09-01, 「同意。」) made #13609 the re-verification carrier for #13331's fix:
#13331's fix landed (PR #14183,
1403d943). This PR is that measurement, shipped as a test rather than a comment. It is not a fix.The discriminator is DURATION, and it is sharp: bounded (≲ one cache TTL window) = fixed; unbounded = not fixed. "It cleared eventually" is not a reading unless the bound is stated, so the harness does not assert a boolean —
measureProlongationMs()returns a number of milliseconds, ornullfor unbounded, and the cases assert the number.The harness, and what door it measures
packages/metadata-protocol/src/protocol.datasource-delete-prolongation.test.ts— one new file, besideprotocol.cluster-mutation-fanout.test.ts, reusing its two-replica pub/sub shape.Two protocol instances ("replicas") over ONE shared
sys_metadatastore, each with its OWN registry, its OWN overlay cache and its OWN write epoch, joined by a transport that delivers every publish to every subscriber (the publisher included — what a real remote driver does, and what theoriginNodeloopback guard exists for). Replica A deletes throughdeleteMetaItem; replica B is measured.The door under measurement is the protocol's, not the manager's.
GET /api/v1/meta/:typeis served by the REST list route whose body isconst items = await p.getMetaItems(listRequest). That answer is built from two local, per-replica sources, which is what lets a peer serve a row the shared DB no longer has:engine.registry.listItems(type)— the in-memory registry. It is the base of the answer;sys_metadatarows are merged on top (mergePackageAwareOverlay), and nothing in that merge prunes a base item whose row is gone. No TTL — the unbounded seam.meta-overlay-cacherow-set cache — keyed on the engine's write epoch plus a TTL (OS_METADATA_OVERLAY_CACHE_TTL_MS, default 30s). The epoch is local, so a peer's write never moves it. The bounded seam.Premise re-confirmed on this tree, not inherited:
datasourceisallowRuntimeCreate: true(packages/spec/src/kernel/metadata-plugin.zod.ts), sodeleteMetaItem'suseRepoPathfork takes the repository exit, which emits. The first case asserts exactly onemetadata.mutatedpublish carrying{type: 'datasource', name, state: 'deleted', organizationId: null}— if the delete took a non-emitting exit, this re-verification would be measuring a channel the delete never uses.The measurement
env_prod)My pre-run prediction for Arm B was BOUNDED, and it was falsified — in the worse direction. The prediction's first half held and is pinned: the bridge really does heal the peer's registry, within one bus hop, before the clock moves at all. The second half did not:
So the prolongation is neither unconditionally bounded nor unconditionally unbounded, and the discriminator is not time but traffic. Both halves are pinned side by side, differing in exactly that one step, because either alone reads as a clean verdict and neither alone is true.
On a replica actually serving
/api/v1/meta/datasource— the door QA was watching — a read inside a 30s window is the ordinary case, not the unlucky one. The bounded arm is the quiet-replica arm.Why the re-hydration happens
Nothing on the bridge's receipt path touches the write epoch that keys the overlay cache.
applyRemoteMetadataMutationre-reads the row and repairs the registry; it performs no engine write, and a peer does no writing of its own on this path, so the peer's epoch does not move and its pre-delete row set stays "fresh" for the rest of its TTL.getMetaItemsthen does what the cache's own header says it always does, hit or miss — it runshydrateOverlayIntoRegistryover those rows.The comparison that makes this a gap rather than a design: the sibling bridge over the same substrate does bump it.
packages/objectql/src/authz-invalidation-bridge.ts:71callsepoch.bump('remote')when it applies a peer hint, andmeta-overlay-cache.ts's own header cites that as the reason cross-node convergence "narrows for free" there. Themetadata.mutatedbridge added for #13331 contains no epoch reference at all. The two cross-node paths over one substrate disagree, and this door sits on the half that does not invalidate.Not fixed here. The card is a measurement carrier and its source surface is read-only, so the reading is reported and the repair is left to a card that can be decided on it.
Controls
analytics_db) is still served on the peer in all three shapes after 11 windows, so the readings are about the deleted row and not about a peer that stopped answering.settle()drains microtasks without moving the clock; the registry-heal case asserts the heal did happen after exactly that drain, so an inadequate drain fails loudly there instead of silently inflating a prolongation elsewhere.Four-seam checklist — a verdict on each
restoreRuntimeDatasourcesre-seedingmeta-overlay-cache, notMetadataManager.listCache— and it does not merely delay the correct answer, it feeds the untimed registry, converting a 30s residue into an unbounded one on any read.Neither the QA observation nor the source counter-evidence is discarded, and the file is written so it cannot be: Arm A and the read-during-window case reproduce the observed cluster-wide prolongation, and the heal case reproduces the counter-evidence that the delete path really does fan out. Both readings are true, then and now.
⛔ Live multi-node run: NOT MEASURED
No live cluster driver or multi-process deployment is reachable from the dev container (
OS_TEST_*unset, no cluster driver beyondmemory). The in-process two-instance harness with a real cross-instance transport is declared as the proxy, per the allowance in the card's own state-hygiene note; it is never presented as the live measurement.What the proxy leaves open, named rather than papered over — the residual sub-question from PR #13883: whether the QA deployment was on the shipped in-process
memorycluster driver (nothing listening) or hit a second defect in a genuinely distributed driver. Only a real-driver run separates those. Reported NOT MEASURED, not as a pass.Ablation — direction predicted before the run
Predicted, in writing before running: flipping
attach: truetofalsein the two bridge-dependent cases (the registry-heal case and the bounded no-read case), neutering the bridge in the harness, never in source, turns exactly those two RED while every other case stays GREEN. The already-unbounded cases cannot move — that is the point: they are unbounded with the bridge as well, so an ablation that reddened them would mean the harness, not the bridge, was deciding the outcome.Measured — matched exactly:
Tests 2 failed | 7 passed (9), the two failures beingArm B — WITH the landed publisher + bridge, the bridge DOES heal the peer registryandArm B measured, door NOT read during the residue window: BOUNDED by one TTL window.Mutation confirmed on disk before the run, anchored on both the injected and the removed text (an editor's exit code proves nothing — a zero-hit replace exits 0):
injected=2 (expect 2), remaining_attach_true=3 (expect 3, was 5), blob2a30331…becoming00e7ee4…. No rebuild leg applies: the subject is imported as./protocol.js, a relative specifier inside its own package that vitest resolves tosrc/protocol.ts, so nothing resolves throughexportstodist/. Restore ran from an absolute-pathtrap … EXIT INT TERMpinned toHEAD(never a baregit checkout --, which restores from a possibly-polluted index) and was verified by state, not exit code: worktree blob back to2a30331…= HEAD blob,git diff HEADempty, zeroABLATEDmarkers, cleangit status.Gate union
All of the below ran on the final commit
55b725689(git rev-parse --short HEAD), with the family list re-derived on that same tree bynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— the script's own change-set derivation, never a hand-rolledgit diff, and with no STALE TREE warning. 34 families: 31 exit 0, 3 exit 3 (NOT MEASURED), 0 failures.Exit codes captured after redirect (
cmd > log 2>&1; ec=$?), never through a pipe.pnpm lint(the full repo scan,eslint . --no-inline-config): exit 0, zero problems. Run whole rather than narrowed, so no narrowing argument is owed.pnpm --filter @objectstack/metadata-protocol test:Test Files 155 passed | 2 skipped (157),Tests 2134 passed | 10 skipped (2144).pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. The new file is in the program, measured rather than assumed:tsc --noEmit --listFiles | grep -c protocol.datasource-delete-prolongation.test.ts= 1.check:engine-double-contract— initially exit 1: the new file'sdelete/findOne/updatedoubles (all already routed throughassertEngine*Dispatch) were not in the shrink-only ledger. Repaired the way the gate names, bynode scripts/check-engine-double-contract.mjs --writeand committing the three rows inscripts/engine-double-contract.pinned.json— never by raising a baseline. Re-run: exit 0. That commit is the one non-test file here, and it is why 7 further gate-script families joined the union; all 7 green.check-test-completeness.mjs("PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named"),check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured"),check:type-check-debt("PREREQUISITE NOT MET"). All three need a full-farm build or a saved CI log that a local worktree does not produce; CI runs them on this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code