Skip to content

fix(txn): validate predicate reads under SERIALIZABLE - #111

Merged
venkat1701 merged 4 commits into
mainfrom
fix/serializable-predicate-reads
Oct 27, 2026
Merged

venkat1701 merged 4 commits into
mainfrom
fix/serializable-predicate-reads

Conversation

@venkat1701

@venkat1701 venkat1701 commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

BEGIN SERIALIZABLE recorded only reads by atom id or slot key. Reads that went through a posting list or an index range were never validated at commit, so these anomalies committed on both sides:

Anomaly (two interleaved sessions) before after
write skew through an indexed predicate (WHERE n.oncall = true, each turns a different one off) both commit, 0 on call second commit: serializable read of property-index … between 1 and 1 was invalidated
double booking through HAS (each sees the room free, each ADDs it to a different booking) both commit serializable read of the incident edges of atom 3 was invalidated
count-then-insert by type caught only because both bumped the tenant usage counter serializable read of the atoms of type 2 in tenant 0 was invalidated
count-then-insert by HAS same accident caught by the incidence read
absent canonical key read, then concurrent insert not checked caught by the canonical-key bucket

SNAPSHOT is unchanged: no dirty or non-repeatable reads, lost updates rejected, write skew allowed as in any snapshot isolation.

How

  • Scan-style reads are recorded at the granularity they read and validated by content at commit:

    • an atom's reverse-index postings (incident, forEachIncidentEdge, incidentTree, degree);
    • a type's postings (atomsOfType, typeTree, countOfType);
    • a canonical-key hash bucket (resolve);
    • the key range of a property-index scan (View.nested), so changes outside the range do not conflict;
    • whole slots for unbounded scans (atoms(), scan(slot)).

    More than 8192 reads in one slot collapse into one whole-slot read. Promoted postings are compared by content, so a checkpoint between read and commit does not cause a conflict (tested).

  • Reads that only steer a plan run in an explicit unrecorded scope (View.unrecorded()): planner estimates, HORA domain estimates, the single-tenant shortcut in Reader, and the eager quota check in Security.charge (the merge precondition still enforces the quota at commit). That last one also removes a false conflict between any two SERIALIZABLE transactions inserting in the same tenant. Results that depend on the same calls (degree(), count(*)) are still recorded.

  • Nothing is recorded under SNAPSHOT; it allocates nothing new.

  • docs/transactions/transactions.md section 6 describes the read kinds, granularity, false positives and the unrecorded scope, with an anomaly table.

Tests

  • SerializableTest (13): each anomaly above conflicts under SERIALIZABLE; SNAPSHOT still allows write skew and prevents lost updates; no conflict for inserts of unrelated types, disjoint posting lists, changes outside an indexed range, or plan estimates on an index the plan did not use; degree() is still recorded. With the fix removed, 7 of them fail.
  • SerializableReadsTest (engine, 8): every read kind on inline and promoted postings, with a checkpoint between read and commit, unchanged (commits by rebasing) and changed (conflicts); range pruning, own writes, promotion.
  • Full build: engine 250, database 90, server 28, all passing.

Note for #108: it adds View.incidentCursor, which must record the same reverse-index read. I'll update #108 on top of this branch so the two cannot merge without it.

SERIALIZABLE only recorded reads by atom id or slot key, so reads that
went through a posting list or an index range were never validated.
Write skew through an indexed predicate and phantoms through incidence
or type scans committed on both sides.

Scan-style reads are now recorded at the granularity they read: an
atom's reverse-index postings, a type's postings, a canonical-key hash
bucket, the key range of a property-index scan, and whole slots for
unbounded scans (promoted to after 8192 reads in one slot). They are
validated by content at commit, so checkpoints do not cause conflicts.

Reads that only steer a plan (planner estimates, the single-tenant
shortcut, the eager quota check whose merge re-checks at commit, HORA
domain estimates) run in an explicit unrecorded scope, which also stops
every pair of inserting SERIALIZABLE transactions from conflicting on
the tenant usage counter.
A serializable read of a property no transaction had set returned
before reading anything, so a concurrent SET of that property did not
invalidate it. Reader.property now reads the atom's property record and
Reader.range reads the type's atoms when the view records reads.

Reader.range also scanned nothing for an unindexed JSON path, since
only indexed paths have a key; it now scans the documents.
- A keyed write that left its value unchanged did not count its slot
  as touched, so the commit could take the fast path past a concurrent
  change of that key. UNSET of an absent property lost its effect this
  way; every slot a transaction writes now counts.
- UNSET of a property name without a key reads the property record and
  fails at commit if another transaction set that property.
- Reader.range compares only values of the bound's type, as the index
  does, so an unindexed scan no longer returns other types or nulls.
@venkat1701
venkat1701 merged commit c3f0897 into main Oct 27, 2026
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.

1 participant