Repository navigation
Description
Summary
Within one transaction, _writeRelocate and _writeInvalidate execute their commit handlers at staging time, while a _writeUpdate staged before them can execute later, in the commit loop. So a same-key put K; relocate K runs as relocate K; put K, and whatever the relocate stored is overwritten by the put.
This is pre-existing and is not changed by harper#2235 (which fixes the same class of inversion for delete). It is filed separately because closing it needs a different, larger change.
Mechanism (confirmed in source)
DatabaseTransaction.addWrite() runs a write's commit handler immediately unless the write sets deferSave. _writeUpdate sets it, and the source/replication apply path calls resource._writeUpdate() directly without ever calling resource.save() — so an apply-path put waits for the commit loop. _writeRelocate and _writeInvalidate set no deferSave, so they run first regardless of staging order.
harper#2235 holds a write back only when it is flagged chainsStagedState, meaning it both reads priorStagedWrite() and publishes stagedEntry. Only the put and delete paths do:
| Write path | Reads staged basis | Publishes staged entry | Saves |
|---|---|---|---|
_writeUpdate |
yes | yes | deferred |
_writeDelete |
yes | yes | eager, held back by #2235 |
_writeInvalidate |
no | no | eager, never held |
_writeRelocate |
no | no | eager, never held |
_writePublish |
no | no | eager, never held |
_writeRelocate's commit handler reads const existingRecord = existingEntry?.value — the pre-transaction record, never the staged one — and when the residency list excludes this host it writes a stub of only the indexed attributes, marked INVALIDATED. A put executing afterwards overwrites that stub.
Impact
invalidate form — confirmed by inspection. A put K; invalidate K pair loses the invalidation: the put re-stores the record after the invalidate marked it stale, so reads stay stale until the TTL expires.
relocate form — suspected, not reproduced. If the put that runs afterwards stores the full record, the record body persists on a node the residency policy excludes. The put has its own residency handling, but it is gated on if (residencyId == undefined) and the apply path passes options.residencyId, which would skip that block. I did not build a repro and am not asserting the retention outcome — the ordering inversion is what is confirmed. That is the first thing to establish here.
Why it was left out of harper#2235
Simply deferring these writes is not the fix and makes things worse: they cannot read a staged basis, so a deferred invalidate after a staged put would build its partial record from the pre-transaction record's indexed values while the index already holds the put's new ones — record and index would then disagree. This exact counterexample blocked a first attempt during that PR's review.
The real fix is to teach _writeInvalidate and _writeRelocate (and possibly _writePublish) to consume priorStagedWrite() and publish stagedEntry, then flag them chainsStagedState, with tests for each pair (put→invalidate, put→relocate, delete→invalidate, …).
Suggested first step
Reproduce the relocate case on a table with getResidency configured, applying a replicated transaction that puts and relocates the same key, and record whether the stored record ends up full or stub. That answers whether this is a stale-read bug or a data-residency one.
Documented in DESIGN.md under the repeat-writes section as part of harper#2235.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Nov 10, 2026
QA reproduced the relocate form this issue lists as suspected (finding F-1213, re-checked on harper main c7572a5). resources/Table.ts _writeRelocate (~5144-5256) has no chainsStagedState, never calls priorStagedWrite or sets stagedEntry, starts from metadata = 0, reads existingEntry?.value / receivedIdentityRefs directly, takes expiresAt from options and passes entry: this.#entry. Effects: a same-transaction put + relocate orders wrong, and a standalone relocate wipes expiresAt, INVALIDATED, LOCAL_ONLY and residencyId (the broader exposure; see also #3489). #3671/#3780 fixed _writePublish only.
From QA finding F-1213, finding-triage wave 50.