Skip to content

Publishing a message no longer rewrites the record's data, expiration, residency or cache-stub state - #3671

Merged
kriszyp merged 11 commits into
mainfrom
fix/publish-preserves-record-state
Oct 30, 2026
Merged

kriszyp merged 11 commits into
mainfrom
fix/publish-preserves-record-state

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 27, 2026 •

Copy link
Copy Markdown
Member
Park-poster mountain post office: Dawson drops the parcel notice into the outgoing mail and Kyle points to the parcel still wearing its EXPIRY and HOME SHELF tags, while the old photocopier is crossed out and Kris and Bari cheer.

⊙ Problem

Table.publish is documented to leave the record unchanged, but it rewrote it from a stale copy. A put, patch or create earlier in the same transaction was undone in the stored row while the index kept the new value. A standalone publish cleared the record's expiration, residency id and invalidated-stub flag.

🤔 Is this the whole fix?

No. An older message still rewinds the record's version (#3637): keeping it would drop the message from RocksDB per-record replay.

💡 Solution

  • A publish rewrites the record as earlier writes in its transaction left it.
  • It keeps the record's expiration, residency id and local-only, invalidated and replica flags.
  • The message keeps its own residency for routing; the record keeps its own.
  • A re-delivered replicated patch after a publish still applies once.

🤔 Accept that [put K, publish K] in one transaction replays the put, not the message?

For now: RocksDB replay finds the first entry at their shared log key. Fix: #3661.

🤔 Accept publish K; relocate K; patch K re-storing the full body on an excluded host?

For now: a relocate stages nothing, so the patch bases on the publish's full record. Extends a known gap (DESIGN.md).

⚖️ Alternatives

  • Audit-only publish — messages would drop out of per-record replay.
  • Fix only the metadata — leaves the index inconsistent.

🤔 Should Cache-Control: max-age on a publish still set expiration?

Main applied it to the record on caching tables; now the record keeps its own expiration.

🔧 Changes

✅ Verification

PR QA: Do concurrent publishes and writes to the same keys across HTTP workers leave primary rows, indexes and expirations consistent? — queued @ 1b3b892, qa-pr-harper-3671-1b3b892-r2

Publish cost measurements

Single-transaction Table.publish to a warm key, µs per publish; load average 7–30, turbo on.

record main this PR
~300 B 42–117 54–99
64 KB 143–179 217–244

Uncached getEntry: 3.3–14 µs vs cached 0.4–1.6 µs (small record), 29–35 µs at 64 KB.

Docs: none; publish now matches them.

🤖 Generated by Anthropic Claude (Opus 5.5, dispatch dev agent); posted via @kriszyp.

Release target: v5.4 — fixes behavior on main; no reported need on an older line
Related PRs: #3453 overlaps (re-delivery dedup checks in _writeUpdate), #3506 overlaps (the unaudited tie rule), #3529 overlaps (changes its source-publish fill expectation), #3527 overlaps (publish carries the shard slot and complete record), 17 others independent
Complexity: complicated
Framing-Verdict: chosen-approach-sound (019fa91eec79)

Deferred findings

🤔 defer this PR's major correctness finding f2887ce6cd9751ed (older/tied/chained publish sets VERSION_REUSED on the unchanged record, hiding it from full-text res) to #3660 instead of fixing it here?

🤔 defer this PR's major performance finding 3d73554ed565a886 (keyed publish adds an uncached commit-base reload and decode per message) to #3665 instead of fixing it here?

Late Grok findings

2 surviving (highest major) — Grok reviewed 71e2425
  • major — resources/Table.ts:8295 — a put/patch/delete/invalidate followed by a publish to the same key in one transaction gives both audit entries the same log key (appendedLogKey === txnTime, so no additionalAuditRefs). On RocksDB, resolveAuditHead (resources/Table.ts:1707) then calls auditStore.getSync(txnTime, …), and if
  • major — resources/Table.ts:8307 — an older publish, or a same-version tie (including the RocksDB same-transaction put→publish case, where the reloaded base's version equals txnTime), writes newVersion <= existingEntry.version. That sets VERSION_REUSED (resources/RecordEncoder.ts:1002-1003) on a record whose value did not

Not standing: none. Grok ran 14m (14m from launch to report); the fold adjudication (cursor-grok@71e2425b88ea-563a813f, claude) took 2m.

Late Grok findings

7 surviving (highest major) — Grok reviewed 71e2425
  • major — resources/Table.ts:8287 — A put followed by a publish on the same record in one transaction writes both audit entries under the same log key. On RocksDB, getSync returns the first entry matching the record at that key (resources/RocksTransactionLogStore.ts:222-225), which is now the put, because the publish chains af
  • major — resources/Table.ts:8307 — An older publish rewrites the record at existingEntry.version, which satisfies versionIsReused (resources/RecordEncoder.ts:157-158) and sets VERSION_REUSED (resources/RecordEncoder.ts:1002-1003) on a record whose value did not change. isFullTextSearchEntryCurrent (`resources/Table.ts:194
  • major — resources/Table.ts:1741 — collectMessageBranches reads only the direct refs and never follows previousAdditionalAuditRefs. The encoder drops middle log-key refs when they exceed its byte budget (resources/RecordEncoder.ts:416-419, :463). A message whose only remaining link is a later message's `previousAdditionalAu
  • major — resources/Table.ts:8261 — Every keyed publish now takes a commit-base read transaction and reloads the base entry at commit (baseReadTxn, reloadCommitBase: keyed). This is extra read I/O on every publish to a record, and the PR includes no measurement of it.
  • major — resources/Table.ts:7940 — Each RocksDB subscribe with a start time now allocates a Map, builds one string key per replayed entry (:7967), and does one log getSync per non-head ref (:1753). The PR includes no measurement of this replay path.
  • major — resources/Table.ts:8330 — The publish stagedEntry does not carry the removal identity. With delete → publish → delete on the same record and identity in one transaction, the second delete is logged again. Severity is a nit on the merits; it is graded major only because of the unmeasured hot-path floor. Follow-up issue alre
  • nit — resources/Table.ts:8254 — Several added comments restate the code: resources/Table.ts:1739, :8254, :8263, :8288, and the test-description comments in unitTests/resources/publishRecordState.test.js and unitTests/resources/subscriptionBufferedState.test.js. One existing comment is reported stale near `resources/Tab

Not standing: 2 separate-issue. Grok ran 13m (13m from launch to report); the fold adjudication (cursor-grok@71e2425b88ea-8398ace6, claude) took 2m.

Origin — the dispatch brief this PR was written from

Table.publish / record.publish (_writePublish in resources/Table.ts) is not a chained write and rewrites metadata. It sets entry: this.#entry without chainsStagedState/reloadCommitBase/priorStagedWrite, passes flags & LOCAL_ONLY only, expiresAt: context?.expiresAt, residencyId: options?.residencyId and newVersion = txnTime, and updateRecord's message path drops HAS_EXPIRATION, residency id and INVALIDATED. Effects: same-transaction put/patch then publish writes a stale base back (primary/index divergence); deferred writes before a publish are dropped; a standalone publish wipes expiresAt, residency id and the INVALIDATED flag (promoting a stub to a full-looking record); an out-of-order publish rewinds the record version so a stale delete passes. Make it chain like _writeInvalidate (#3322): chainsStagedState and reloadCommitBase, base from priorStagedWrite(write)?.stagedEntry ?? existingEntry, publish stagedEntry unchanged, deferred create/update/put applied first; carry the existing expiresAt, residencyId and flags & (LOCAL_ONLY|INVALIDATED|REPLICA); newVersion = Math.max(txnTime, existing version) while the message audit entry keeps the txn timestamp; no updateIndices. Update the resources/DESIGN.md note that publish was left unchained.

Acceptance

Tests on rocksdb and lmdb: put(v2) then publish in one transaction leaves primary store and index on v2; standalone publish on a record with expiration, one with a residency id, and an INVALIDATED stub leaves each unchanged; publish at t1 < t2 keeps version t2 and a stale delete at tMid is rejected. Show each new test failing on origin/main sources (forced rebuild) before the fix.

Dispatch: task harper-writepublish-chain-preserve · queued by finding-triage/wave-40 · ran by claude/opus/high · worker kzyp-xps-1

Late Grok findings

3 surviving (highest major) — Grok reviewed 1b3b892
  • major — unitTests/resources/sourceFillConditionalWrite.test.js:284 — On RocksDB, a fill commit after a same-transaction publish probably throws the fill away. If so, this assertion fails on the default-engine (RocksDB) CI shard, and resources/DESIGN.md:532 documents an engine split that does not exist. Disputed and not executed. Scope: in-scope — Convergence-Class:
  • minor — resources/Table.ts:5600 — A replicated transaction publish K; relocate K; patch K stores the full record on a host the residency policy excludes. Documented at resources/DESIGN.md:528 but not fixed. Scope: in-scope — Convergence-Class: original-change
  • nit — resources/Table.ts:9498 — New publish comments partly repeat the publish section of resources/DESIGN.md. Scope: in-scope — Convergence-Class: previously-adjudicated

Not standing: 1 merged. Grok ran 8m (8m from launch to report); the fold adjudication (cursor-grok@1b3b892f5700-4c3fd75e, claude) took 1m.

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=11; full=3 @ 1b3b892

Review-Attention: deep ~25m (critical: DatabaseTransaction.ts, RecordEncoder.ts +1; decisions: publish-ignores-context-expiresat, keyed-publish-joins-chain, document-relocate-gap-not-fix, engine-fork-to-issue, sparse-staged-entry, do-less-alternative, deferred-f2887ce6, deferred-3d73554e) @ 1b3b892

@kriszyp kriszyp added this to the v5.4 milestone Oct 27, 2026
gemini-code-assist[bot]

This comment was marked as resolved.

@kriszyp
kriszyp marked this pull request as ready for review October 29, 2026 17:17
kriszyp and others added 11 commits October 29, 2026 11:35
A publish rewrote the record from a stale base with fresh metadata: a
same-transaction put, patch or create before it was written back to its
old value (the index kept the new one), a publish before a put on an
unaudited table turned the put into a skipped tie, and a standalone
publish cleared the record's expiration, residency id and
INVALIDATED/REPLICA flags.

_writePublish now joins the same-key write chain (chainsStagedState,
reloadCommitBase), rewrites the base priorStagedWrite or the stored entry
gives it unchanged, and stages that base. The record keeps its residency
through a new recordUpdater recordResidencyId option, while the message's
audit entry keeps the message's residency, which replication routes by.
A publish-staged base is marked unchanged, so the re-delivery checks do
not treat it as an earlier change. Update, invalidate and delete stage
the metadata a following publish carries.

An older message still rewinds the version, as before (#3637).

Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The publish now leaves the invalidated stub invalidated, so on RocksDB
the fill completes it with the source's value instead of yielding to a
stub that only looked complete; LMDB's fill still yields to the earlier
same-key write.

Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A publish now restages its base's localTime, nodeId and refs, so a copy
of a delete after a message in the same origin transaction still matches
the removal it duplicates and logs once.

Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The publish copied identity from any null base, including an
INVALIDATED stored stub and an unstaged relocate's null row, which a
delete's re-delivery check deliberately rejects; a following delete with
that identity was then skipped and left the stub invalidated. It now
carries what removalBefore returns, as if the publish were absent.

Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-writepublish-chain-preserve
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAm4fA9xir5jYrcQr5QCpo
Dispatch-Task: pr-maint-f6e8fed94aa4da790dd2494e0ff02c73
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAm4fA9xir5jYrcQr5QCpo
Dispatch-Task: pr-maint-f6e8fed94aa4da790dd2494e0ff02c73
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAm4fA9xir5jYrcQr5QCpo
Dispatch-Task: pr-maint-f6e8fed94aa4da790dd2494e0ff02c73
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAm4fA9xir5jYrcQr5QCpo
Dispatch-Task: pr-maint-f6e8fed94aa4da790dd2494e0ff02c73
…e relocate and fill gaps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAm4fA9xir5jYrcQr5QCpo
Dispatch-Task: pr-maint-f6e8fed94aa4da790dd2494e0ff02c73
@kriszyp
kriszyp force-pushed the fix/publish-preserves-record-state branch from defa4a5 to 1b3b892 Compare October 29, 2026 19:38
@kriszyp
kriszyp merged commit dede2d3 into main Oct 30, 2026
99 of 106 checks passed
@kriszyp
kriszyp deleted the fix/publish-preserves-record-state branch October 30, 2026 14:51
kriszyp added a commit that referenced this pull request Oct 30, 2026
main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Oct 30, 2026
…md to bring it under its line budget (#3802)

#3671 left resources/DESIGN.md at 1003 lines against check:design-docs'
1000-line budget, failing Lint Source Code on main. The note "getFromSource()
keeps source versions separate from fill ordering" was the one hard-wrapped
section in that part of the file; joining each paragraph onto one line, as the
rest of the file does, drops it to 966 lines with the text unchanged.

Dispatch-Task: main-red-kriszyp_harper_dede2d3da_5684deb1

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Oct 30, 2026
main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 2, 2026
main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 3, 2026
main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 3, 2026
main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 6, 2026
… newer write (#3780)

* Apply a re-delivered increment once on audit:false tables after a newer write

An audit:false record's receivedIdentityRefs is the only proof a commutative op
was already applied, but only the out-of-order merge path kept it. An in-order
patch, a publish, a relocate, and a second same-key write in one LMDB
transaction all dropped it, and an in-order add never listed itself, so a
re-delivered add applied twice once a newer write landed.

Every audit:false patch now keeps the list of the record it applies to (the
staged entry on LMDB) and lists its own identity when it applied a commutative
op; publish and relocate keep it; a full write still starts over. A listed
patch is skipped before any version comparison, since an LMDB merge stores the
older version and an applied write can then compare as newer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Check a re-delivered audit:false patch against the committed identity list

An earlier publish in the same transaction disabled the listed-identity check,
so a re-delivered [publish, add] source transaction applied its add again after
a newer write. The check now reads the committed record whenever another write
of the transaction saved (identityBase), which excludes only identities this
transaction listed itself. The carry tests for a commutative op without
allocating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Re-apply a sibling patch after a same-transaction full write reset the list

The listed-identity check now also requires the body a patch applies to (an
earlier staged write of the transaction, if any) to still name the identity: a
full write earlier in the transaction started a new list, so its sibling patch
builds on that body and must apply. Publish-first source transactions now run
on LMDB too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Skip a listed audit:false patch only when no earlier write of its transaction staged a body

A re-delivered transaction's patches are skipped by the committed list and stage
nothing, so an earlier staged write of the same transaction can only be one that
replaced the body (a re-delivered put compares as newer after an LMDB merge).
Every patch after it now applies on that body; requiring the staged list to name
the identity dropped the second increment once the first had listed it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Stage the identity list only on audit:false tables

An audited staged entry keeps its old shape; only an audit:false write with a
list adds it. Comment and test-message tidying from review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Keep the identity list when a 304 revalidation re-stores the cached record

getFromSource re-stores the cached body on a 304 but passed no identity list,
so an audit:false sourced record forgot which increments it had applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Take a 304 revalidation's identity list from the body it stores

The 304 path stores the record read before the source call, so its list must
come from that same entry, not the commit base: a RocksDB no-audit merge that
commits during the call keeps the version, and its listed increment would then
be skipped on re-delivery against a body that lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Bound the 304 race test's wait for the source call

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Give the 304 race test a deadline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Return the identity list on the entry a 304 revalidation resolves

On LMDB a resource that loaded the record through a 304 fill commits its next
write against that returned entry, which carried no list, so the write cleared
the stored one. The race test now also settles its read on every path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-received-identity-refs-carry

* Observe the 304 race test's read rejection at creation; fix two stale comments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1

* Reword hasCommutativeOp's JSDoc without a null/undefined equivalence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1

* Carry the identity list through a keyed publish's staged entry

main's publish now stages an unchanged entry (#3671), which made priorStaged
set after a publish: the committed-list skip no longer ran for a re-delivered
[publish, patch], and a patch after it read a staged entry with no list.
Skip on priorChange, and let the publish stage and store its base's list.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Read a first attempt's committed list from the effective body; cover a saved-sibling recommit

A first attempt has staged only unchanged publishes by the time the skip runs,
so the staged-or-reloaded entry already holds the committed list; only a retry
needs identityBase's committed-row read. The recommit arm saves two same-key
increments, forces a native conflict on another key, and asserts one application.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Give the native-recommit arm a test deadline

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Record the audit:false identity list in the restructured DESIGN.md

Main cut resources/DESIGN.md to invariant notes, so the rebase takes main's
file and restates the list's carry, restart and listed-patch skip rules in
the audit-reference bullet. The Table.ts skip comment now carries the
same-transaction exception itself.

Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Gemini <noreply@google.com>

* Keep receivedIdentityRefs on a stale-on-error fallback entry

Co-Authored-By: Gemini <noreply@google.com>
Dispatch-Task: pr-maint-6f13e1c678687230b257b26b0db8f0e1

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gemini <noreply@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-record replay delivers only the first of a transaction's writes to one record

1 participant