HarperFast / HarperFast/harper
Eager relocate/invalidate execute before a same-key put staged earlier in the transaction
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
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.
Contributor guide
Research direction
Start with DatabaseTransaction.addWrite() and the _writeUpdate, _writeInvalidate, and _writeRelocate entry points, then review the repeat-writes section in DESIGN.md. Reproduce a replicated transaction that puts and relocates the same key on a table with getResidency, and record whether the result is a full record or stub. Done requires establishing the ordering and residency behavior, then covering the affected write pairs with tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- databases, distributed-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100