Skip to content

Eager relocate/invalidate execute before a same-key put staged earlier in the transaction #2236

Open

Description

@kriszyp

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.

Activity

  1. added theissue type on Aug 20, 2026
  2. kriszyp commented on Nov 10, 2026

    @kriszyp
    MemberAuthor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions