TanStack

Content temporarily unavailable

Metadata rebuild after a nested transaction

Base revision: 65992aacd (main). Found while triaging state mutation round 4.

Law

Row metadata in one sync transaction follows last write wins, by the rules in collection-row-metadata-composition-oracle.test.ts. The rules apply to the write that applies. When the Collection reclassifies an insert, metadata follows the new classification.

The failure

A transaction begun inside an open transaction can commit first. The open transaction then applies after it, and the Collection rebuilds the open transaction against the new projection. The rebuild reclassifies each insert: an insert of a present key with an equal value is a re-insert, and a re-insert of an absent key is an insert.

ts
sync.begin() // T1, row 1 is present
sync.metadata.row.set(1, { m: `w0` })
sync.write({ type: `insert`, value: { id: 1, v: 0 } }) // equal re-insert
sync.begin() // T2
sync.write({ type: `delete`, key: 1 })
sync.commit() // T2 applies first, so T1's insert is now an insert
sync.commit() // T1
sync.metadata.row.get(1) // main: { m: `w0` }; expected: undefined

The reverse direction also fails. From an absent row, T1 sets metadata and inserts the row. T2 inserts the same row with metadata and commits first. T1's insert becomes a re-insert, which keeps the set value. main reads undefined.

Cause. rebuildAutomaticRowMetadataWrites skipped every key with an explicit write. It kept the write that was current when T1 wrote, which no longer follows from T1's operations.

Repair. Each explicit write records how many operations came before it. The rebuild clears the key's writes, restores the last explicit write, and replays the automatic write of each later operation. Hydrated metadata records a position after every hydrated row, so it still holds.

Why the oracles missed it

The round 3 record called RB2, a mutant that keeps stale automatic writes in the rebuild, equivalent within legal histories. The follow-up record then said that #2030 made it unreachable, because only the open last transaction can be canceled. Both records missed nested transactions. The invalidationError comment on PendingSyncedTransaction names the history: a later transaction begun inside an open one commits first. The metadata oracle's rebuilt lane changes the projection only through an earlier transaction, which cannot reclassify an insert that is still open.

Witness

A nested lane in the metadata composition oracle opens a transaction, T1. T1 writes up to three of set, unset, truncate, and an insert, update, or delete of one fixed row value, each with or without metadata. Then a nested write applies before T1 commits:

  • From a present row, a nested transaction deletes the row.
  • From an absent row, a nested transaction inserts the same row with its own metadata.
  • From an absent row, a hydration seed inserts the same row with its own metadata. The Collection places a seed ahead of the first open transaction.

A nested transaction can also call metadata.row.set after its row write. A history is legal when T1's writes are legal both where T1 writes them and where they apply.

The model reclassifies T1's inserts by the row's presence where they apply, and folds T1's writes over the value that the nested write leaves. It reads no production state. The lane runs 1,925 histories: 275 for each of seven variants.

RevisionNested lane (of 1,925)
main35 fail: all three nested writes, with and without a nested set
this fixpass
explicit write always wins (M1)279 fail; the rebuilt lane also fails
keep stale automatic writes (M2, RB2)21 fail
explicit position recorded as 0 (M4)267 fail; the rebuilt lane also fails
hydrated metadata recorded at position 07 fail, all hydration seeds
explicit write recorded in the first open transaction818 fail, all with a nested set
truncate keeps explicit positions63 fail; the immediate, held, and rebuilt lanes also fail

The hydration mutant also fails DbClient > hydrates pending collection rows when the collection materializes.

Review outcomes

A medium code review found no correctness bug and seven items. A simplifier pass found one item, the same as item 5.

  1. explicitRowMetadataWrites was optional, though every constructor supplies it. Fixed: the field is required, and two test fixtures now supply it.
  2. metadata.row.set still writes to a transaction that a replay has invalidated, while write ignores it. This predates the fix, and the commit discards the transaction. Open: it belongs with round 4's SY8 survivor, writes after invalidation, in the round 4 gaps change.
  3. Hydration could carry metadata on its operations instead of a parallel map. Not adopted: an insert without metadata would then clear metadata that a source kept for an absent key, which changes hydration.
  4. An ordered log of metadata writes would avoid positions. Not adopted: a transaction only appends operations, and a truncate clears both lists together.
  5. Hydration and the new helper spelled out PendingMetadataWrite. Fixed.
  6. The exported type was not formatted. Fixed.
  7. The nested lane had no timeout, unlike the other lanes. Fixed.

Second review

A second medium code review, of #2048 at 9e34d6c94, found no correctness bug and seven items.

  1. metadata.row.set writes to an invalidated transaction. The same as item 2 above. Open, with round 4's SY8.
  2. T1 never wrote an update, a delete, or a truncate. Fixed: the lane generates them where they are legal in both places.
  3. No lane reached a hydration seed ahead of an open transaction. A probe showed that main reads undefined where last write wins expects the explicit set. Fixed: the seed is a nested write.
  4. A nested transaction made no explicit metadata call, so a mutant that writes to the first open transaction survived. Fixed: the nested set.
  5. The rebuild rescans every operation of every queued transaction. main has the same loop, and this change adds no rebuild. No change.
  6. Hydration builds its explicit map in a second pass. A one-loop version added seven production lines to save one copy per chunk. Not adopted.
  7. The fix adds production lines against the bug-fix budget. A position-only map still needs the explicit value, so no smaller design was found.

ORC outcomes

  • ORC-001: met. The contract is the metadata oracle's opening prose. Its limits now state that the rules follow the reclassified write.
  • ORC-002: met. reclassify derives each insert's kind from the row's presence. It does not call classifyProjectedInsert.
  • ORC-003: met. The lane's grammar, model step, and driver step each have prose beside them.
  • ORC-004: met. The lane is exhaustive within its bound. A count control checks 1,925 histories, named witnesses cover each variant, and two exclusions check the legality rule. Every row write uses one value, so no reclassified insert is a duplicate.
  • ORC-005: met. The driver writes through a real Collection's sync API and reads metadata.row.get.
  • ORC-006: met. See the table above.
  • ORC-007: not applicable. The lane is exhaustive. It has no random campaign.
  • ORC-008: not applicable. The model gains no state.
  • ORC-009: met. "Nested transaction" means a sync transaction begun while an earlier one is open. It is the history that invalidationError names.
  • ORC-010: met. The driver cleans up each Collection in a finally block.
  • ORC-011: not applicable. No reviewer named a shared fault.
  • ORC-012: met by this record.
  • ORC-013: met. Each mutant in the table fails a distinct part of the lane. The hydration and first-open-transaction mutants fail only the seed and nested-set variants.
  • ORC-014: not applicable.

Limits

The lane covers one key. A nested truncate, a nested update, or a nested write to another key is not generated. A nested insert with a different value makes the open transaction a duplicate. The sync reentrancy oracle owns that invalidation.