Skip to content

S1: SSI production-readiness — fix cross-leaf write-skew miss, add Adya checker, bindings, docs - #246

Merged
gburd merged 10 commits into
masterfrom
test/ssi-production
Nov 4, 2026
Merged

gburd merged 10 commits into
masterfrom
test/ssi-production

Conversation

@gburd

@gburd gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Workstream S1: make "SSI is production-ready if well tested and functional"
(decision Q4) a checkable claim, then check it. This PR adds the test
infrastructure, finds and fixes a real SSI correctness bug, closes the API
and documentation gaps, and records the verdict.

The bug (fixed here)

On a multi-level B-tree, DB_TXN_SERIALIZABLE silently admitted a write
skew
between records on different leaf pages — both transactions committed a
non-serializable history, no error returned. Found by a new deterministic
reproducer and an Adya cycle checker; independently reproduced by the lead
on master and lts/2026.10.7.

Root cause (a plain bug, no RFC): __lock_get_internal's SSI SIREAD scan
removed a transaction's own SIREAD marker when it write-locked a page it
held that marker on ("upgrading my read to a write"). Conflict detection is at
PAGE granularity, so holding a WRITE lock on a page does not mean the
transaction wrote the record it read there — a point update of key X write-locks
X's leaf, and the B-tree write descent also write-locks a different leaf
carrying an earlier read's SIREAD. Dropping it erased "T read Y", so a later
writer of Y's page recorded no rw-antidependency and the skew committed.

Fix: keep the own marker, skip it for self-conflict (a txn is never its own
pivot); it persists and is reclaimed by the normal GC, so the issue-#137
si_ref accounting is preserved. One hunk in src/lock/lock.c. No format or
ABI change.
Backport candidate (PR against lts/2026.10.7 filed separately).

FAIL-before / PASS-after

test/isolation/ssi_multiread_skew (deterministic, two threads + barriers):

tree before after
1 leaf / 1 level 0/40 0/40
2+ levels 20–40/40 skews 0/40

Both commit timings, with and without extra reads, every page size. Wired into
the isolation tier as a standing gate at both lock-partition counts.

What else is in this PR

  • test/isolation/test_ssi_cycle.c — an Adya dependency-cycle serializability
    oracle (random workloads, many threads/seeds, B-tree + Hash, multi-process),
    scaling past test_iso_anomaly's permutation oracle. Documented soundness
    limit: BDB MVCC is per-page, so the per-key stamp oracle defaults to ~1
    record/leaf.
  • API completeness (e): DB_TXN_SERIALIZABLE + DB_SNAPSHOT_CONFLICT now
    reachable and idiomatic from Rust (libdb-sys const + drift-gate row;
    high-level TxnOptions.serializable, typed Error::SnapshotConflict,
    transact retry) and Zig (consts.zig + Error.SnapshotConflict +
    transact retry). C/Java/Tcl already had it; C++ reaches it via the raw
    flags word.
  • Docs (f): retry guidance for the SSI aborts, and the MVCC × optimistic-read
    (RFC 0007) interaction, in docs_src.
  • Report: test/audit/SSI-PRODUCTION-2026-11.md — the production-readiness
    checklist with evidence and the verdict.
  • RFC 0023 is Withdrawn (the defect was a plain bug; kept as a postmortem
    with the localizing trace in rfc/0023/).

Verdict

DB_TXN_SERIALIZABLE is production-ready on B-tree with this fix; the full
checklist (correctness incl. the cycle checker, crash safety, resource bounds,
false-abort documented, API, docs) is green. Hash stays qualified via ssi006
pending two separate, pre-existing issues this work surfaced (a
concurrent-MVCC-writer hang that reproduces under plain DB_TXN_SNAPSHOT, and a
checker-side single-partition segfault) — neither caused by this fix.

Gates

Draft — for lead review and merge.

libdb-agent added 7 commits November 2, 2026 17:55
Add test/isolation/test_ssi_cycle.c, a general serializability oracle for
DB_TXN_SERIALIZABLE that scales past the existing permutation-based
test_iso_anomaly: many worker threads (or processes) run random read/write
transactions over a btree or hash DB, each committed txn's read set, write
set and the version it read are recorded, and the Adya dependency graph
(ww + wr + rw-antidependency edges) is checked for a cycle.  A cycle is a
committed anomaly.  Anti-vacuity: the same workload under plain SI must find
a cycle, proving the checker has teeth.

Add the qualification report test/audit/SSI-PRODUCTION-2026-11.md as a
checklist (correctness, crash safety, resource bounds, false-abort rate, API
completeness, documentation); evidence fills in as the work lands.

Workstream S1 (Q4: SSI is production-ready if well tested and functional).
test_ssi_cycle is now sound (3 harness bugs fixed and documented in-file: a
shared static write buffer raced by threads; a tag scheme that collided with
the load sentinel and the commit-seq space; and commit-seq assigned outside
the commit critical section so version order could invert). It records each
committed txn's read/write sets + begin/commit order and checks the Adya
dependency graph (ww+wr+rw) for a cycle. Anti-vacuity: the same workload under
plain SI must find a cycle. SSI is clean at low-to-moderate concurrency; under
HIGH concurrency (many threads, large read sets) the checker reports apparent
2-txn write-skew cycles under DB_TXN_SERIALIZABLE -- under investigation, not
yet confirmed as an engine bug vs a remaining oracle gap.

Bindings (deliverable e): add DB_TXN_SERIALIZABLE to lang/rust/libdb-sys (and
its drift-gate table) and lang/zig/src/consts.zig (+ DB_SNAPSHOT_CONFLICT/
UNSAFE, which Zig lacked); add typed SnapshotConflict/SnapshotUnsafe errors and
TxnOptions.serializable / flag::TXN_SERIALIZABLE in the high-level Rust crate
and matching Error variants + Env.transact retry in Zig, so a serializable txn
is retried on a pivot abort exactly like a deadlock.

Workstream S1.
…S on master)

test/isolation/ssi_multiread_skew.c is an independent, deterministic probe (two
threads + barriers + a direct durable-state check; no cycle checker, no version
model). It runs the canonical write skew -- T1: read Y, write X; T2: read X,
write Y -- on ONE btree with X and Y on different leaves.

Result on master: SSI prevents the skew while the tree is a single leaf
(1 level), but once the tree splits to 2+ levels it admits the skew in 20/20
and 40/40 attempts, INCLUDING strict sequential timing where T1 fully commits
before T2 writes (so it is NOT the #136 commit-window race -- it is a
page-granularity SIREAD conflict-detection MISS across tree levels). This FAILs
on master and is the deliverable-(a) blocker for SSI production readiness.
Root-cause analysis and the fix are RFC 0023.

Workstream S1.
…descent

Document the confirmed SSI correctness bug (deliverable (a) of the production-
readiness report): DB_TXN_SERIALIZABLE silently admits a write skew when the
two conflicting records are on different leaves of a MULTI-LEVEL B-tree. Detection
works at 1 level, fails at 2+ levels, in strict sequential timing (so NOT the
#136 commit-window race). Root cause localized to lock-coupling during descent
(bt_search.c:613 root read-lock, :1393 LCK_COUPLE_ALWAYS) leaving the committed
reader's SIREAD marker where the leaf writer's WRITE-meets-SIREAD scan
(lock.c:1251-1345) never finds it. No format/ABI change; the fix is placement
only. RFC corrects RFC 0003's retired claim that this symptom was only #136.

rfc/0023/ has the finding notes and a marker-probe. The FAIL-before gate is
test/isolation/ssi_multiread_skew.c; the general oracle is test_ssi_cycle.c.
Fix deferred to a follow-up (engine + recovery sensitive); SSI stays experimental.

Workstream S1.
…page

Fixes a silent write-skew miss under DB_TXN_SERIALIZABLE: on a multi-level
B-tree, two transactions whose read and write records sit on different leaves
could both commit a non-serializable history, with no error returned.

Root cause: __lock_get_internal's SIREAD scan had a branch that, when a writer
write-locked an object on which it already held its OWN SIREAD marker, REMOVED
that marker ("upgrading my own read to a write"). Conflict detection is at PAGE
granularity, so holding a WRITE lock on a page does NOT mean the transaction
wrote the record it had read there: a point update of key X write-locks X's
leaf, and the B-tree write descent can also write-lock a different leaf that
carries the transaction's SIREAD from an earlier read of key Y. Dropping that
marker erased the record of "this transaction read key Y", so a later writer of
Y's page by another transaction found no marker and never recorded the
rw-antidependency -- both transactions committed a write skew.

Fix: keep the transaction's own SIREAD marker and merely skip it for
self-conflict purposes (a transaction never forms an rw-edge with itself). The
marker persists exactly like any committed reader's and is reclaimed by the
normal GC (__lock_sicommit at commit, __lock_siclean_obj once its snapshot is no
longer visible), which also balances si_ref -- so the issue-#137 accounting the
old eager drop maintained is preserved by the ordinary path, not lost.

FAIL-before / PASS-after gate: test/isolation/ssi_multiread_skew (0/40 skews at
every tree depth and timing after this change; 20-40/40 before). No format/ABI
change. Backport candidate for lts/2026.10.7.

Workstream S1.
…tier

- ssi_multiread_skew becomes the regression gate for the cross-leaf write-skew
  fix (0/40 at every tree depth and both timings; FAILed on every pre-fix
  build). Runs at both lock-partition counts.
- test_ssi_cycle (Adya dependency-cycle checker) runs as an SSI fuzzer at the
  sound default (~1 record/leaf). Documented in-file: BDB MVCC versions whole
  PAGES, so the per-key stamp oracle is only sound at ~1 record/leaf; with many
  records per leaf a read of K1 + write of K2 on one page is a page-granularity
  interaction the per-key model cannot represent, so a reported cycle there is
  NOT by itself an engine bug (the deterministic ssi_multiread_skew carries the
  authoritative teeth). B-tree, default partitions; the Hash workload hits a
  SEPARATE pre-existing concurrent-MVCC hang that also reproduces under plain
  DB_TXN_SNAPSHOT (not SSI, not this fix) and is tracked separately.
- MANIFEST entries for the new verdicts.
- RFC 0023 Withdrawn: the defect was a plain bug, fixed directly with no design
  or format change; the file is kept as a postmortem (rfc/0023/ROOT-CAUSE.md has
  the localizing trace).

Full isolation tier green (run.sh exit 0: scenarios 0-unexpected at both levels
and partition counts, cross-leaf 0/40, Adya clean, GC-pressure, crash-pivot).
Soak green (lock_lockers slope <= +1.12 / 1000 txns, tol 20) -- the fix keeps
markers but the normal GC still reclaims them, no #137 regression.

Workstream S1.
…ction-on-btree

Update the qualification report (test/audit/SSI-PRODUCTION-2026-11.md) and the
txnbegin.md note to reflect that the cross-leaf write-skew defect found by this
qualification is fixed (plain bug in src/lock/lock.c, no format change) with a
standing regression gate, backported to lts/2026.10.7 with L1-L4 + soak passing.
Verdict: DB_TXN_SERIALIZABLE is production-ready on B-tree once the fix ships;
Hash stays qualified via ssi006 pending two separate pre-existing issues (a
concurrent-MVCC-writer hang under plain DB_TXN_SNAPSHOT, and a checker-side
single-partition segfault), neither caused by this fix.

Workstream S1.
…dings

SSI (DB_TXN_SERIALIZABLE) and the DB_SNAPSHOT_CONFLICT/DB_SNAPSHOT_UNSAFE
returns were reachable from C, Java and Tcl but not the new Rust/Zig bindings
(the Cahill review flagged this).

- lang/rust/libdb-sys: add DB_TXN_SERIALIZABLE (0x00200000) and its drift-gate
  table row (the gate static-asserts the baked value against db.h; verified it
  matches).
- lang/rust/libdb (high level): TxnOptions.serializable, flag::TXN_SERIALIZABLE,
  typed Error::SnapshotConflict / Error::SnapshotUnsafe mapped in check(), and
  Env::transact/transact_opts retry a pivot abort exactly like a deadlock
  (including a commit-time abort, since SSI's pivot check runs at commit).
- lang/zig: consts.zig gains DB_TXN_SERIALIZABLE + DB_SNAPSHOT_CONFLICT/UNSAFE
  (and their drift rows); libdb.zig gains Error.SnapshotConflict/SnapshotUnsafe,
  maps them in check(), and Env.transact retries them via a new isRetryable().

C++ reaches DB_TXN_SERIALIZABLE today via the raw flags word of DbEnv::txn_begin
(the binding has no typed DbTxnConfig for any flag, matching legacy BDB).

Workstream S1 (deliverable e).
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (3) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/btree/bt_curadj.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

ABI diff vs v2026.10.8 (libabigail — authoritative)

abidiff exit bitmask: 0

Removed exported symbols (nm -D, _NNNN version suffix normalized)

None.


Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Independent adversarial review — #246 SSI cross-leaf write-skew fix (commit cced5c481)

Verdict: MERGE. The fix is correct, has real teeth, and introduces no false self-conflict and no measurable rise in the false-abort rate.

Reviewer wrote none of this code; read-only on the branch; built + ran on the shared dev box. Every produced database passed DB->verify.

The fix, and why the three self-conflict sites are safe

The change (src/lock/lock.c, one hunk): when a txn write-locks a page on which it holds its own SIREAD marker, it now continues (skips itself for conflict) and keeps the marker, instead of dropping it + decrementing si_ref. The brief's question is whether the retained marker can now produce a FALSE self-conflict in the three sites that read SIREAD state. I read each:

  • __lock_get_internal SSI scan (the fix site): our own marker matches sh_off == sireadlp->holder and hits the continue — never the rw-edge branch. No self-conflict.
  • __memp_si_rwconflict (src/mp/mp_fget.c): operates on the MVCC buffer version chain (SH_CHAIN), not on SIREAD lock markers, and explicitly continues on wtd == rtd (our own newer version). The retained marker is invisible to it. No self-conflict.
  • commit-time pivot check (src/txn/txn.c): reads only td's TXN_DTL_WCONF && TXN_DTL_RCONF. Those flags are set on us by other transactions' rw-conflict recording; our own continued marker never sets our own flags. No self-pivot.
  • Accounting / libdb 5.3.34: sequential read-only DB_TXN_SNAPSHOT transactions eventually cause DB_ENV->txn_begin to return ENOMEM #137: the marker is detached once by __lock_sicommit at commit (persist) / abort (free); the old eager-drop path is gone, so no double-decrement of si_ref/nlocks. The SIREAD marker and the page WRITE lock are independent heldby entries, released by their own paths.

Teeth — independently confirmed

I ran the branch's ssi_multiread_skew (filler=400 ⇒ 22 leaf pages, X and Y on different leaves) against master and the branch:

MASTER:  attempt 0..199  WRITE SKEW -- both committed, (X,Y)=(1,1) under DB_TXN_SERIALIZABLE   (bug)
BRANCH:  PASS: 0/200 attempts -- SSI prevented the multi-read write skew in every attempt        (fixed)

(My own first independent reproducer, rev246_a3_skew.c, used only 60 filler keys and did not reproduce — X and Y shared a leaf, so it was caught by same-page detection on master too; that is a property of my test, not the fix. The branch's reproducer is correctly engineered to force the cross-leaf shape, and I confirm it reproduces the miss on master and the fix closes it.)

False self-conflict and false-abort rate — measured (/scratch/libdb/rev-work/246/)

  • rev246_a1_self.c — 3 000 single-thread serializable RMW transactions that read then write the same keys (maximum own-marker-vs-own-write overlap). A self-only txn must never abort. Branch: 0 spurious self-aborts. Master: 0. The retained marker causes no false self-conflict.

  • rev246_a2_falseabort.c — 8 threads, each on a disjoint key range (no true rw-antidependency), serializable, ~17 k commits/run. False-abort rate conflict/(commit+conflict):

    arm false-abort rate
    master 0.21%
    branch 0.05% – 0.13%

    The branch is no higher than master (comparable, within run-to-run noise). The brief's "does the extra marker change the false-abort rate measurably?" — no.

Suite evidence

  • Isolation tier (test/isolation/run.sh, --enable-diagnostic, both lk_partitions=default and =1 — the parts=1 self-deadlock gate the fix region touches): ISO_DONE rc=0, 0 unexpected outcomes; GC-pressure PASS (every skew pair aborted, sweep fired → no libdb 5.3.34: sequential read-only DB_TXN_SNAPSHOT transactions eventually cause DB_ENV->txn_begin to return ENOMEM #137 regression), crash-pivot PASS (serializable after recovery at every kill point), Adya cycle checker PASS (every seed serializable), ssi_multiread_skew PASS.
  • si/ssi TCL: si001 si002 si004 si006 (btree) and ssi001 all OK.
  • Diagnostic build green.

Not re-litigated

Per the brief, the leak gate (test/c/leak-run.sh) fails identically on master and branch on a 32-vCPU host — pre-existing, host-sensitive threshold, not this fix. I did not re-run it.

No concerns. Ships.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead: the lock.c fix is good, but the "production-ready" verdict is wrong. SSI still commits write skews.

The report's caveat says that with many records per leaf, the per-key cycle oracle "can report a cycle that is a page-granularity artifact". That reasoning does not hold:

  • Read-from is exact: the reader records the producer's unique tag, taken from the stored bytes.
  • Per-key version order is exact: commit order, taken under the oracle's lock, with page write locks and first-committer-wins serialising writers of a page.
  • So a cycle in the per-key DSG is a genuine non-serializable history (Adya).
  • Page-granularity MVCC can only make SSI more conservative, giving more aborts. It cannot make the oracle report a cycle that is not there.

Defaulting CYC_VALPAD to one record per leaf hides exactly the realistic configuration: many records per leaf.

Measured on the dev box, test_ssi_cycle with CYC_VALPAD=200 (several records per leaf), DB_TXN_SERIALIZABLE:

build 2 threads 16 threads
master 2/20 seeds non-serializable 5/6
this branch 2/20 3/6
either, VALPAD=1500 — 0/10

Plain SI with the same configuration also admits cycles, as it should, so the workload has teeth. One cycle from the branch at 2 threads is a textbook write skew; both transactions committed:

T991  r(k18 @T962) r(k11) r(k2) r(k22)  w(k35)        commit seq 992
T992  r(k35 @T988)                       w(k59) w(k18) commit seq 993
T991 --rw(k18)--> T992 --rw(k35)--> T991

Conclusion:

  • Keep the lock.c fix: it is a real bug fix, it has a deterministic test, and it reduces the rate at 16 threads.
  • Correct the report before merge: SSI is not production-ready, because a second miss remains.
  • The second miss is pre-existing on master and on the LTS line.
  • Follow-up agent S2 is dispatched to root-cause it.

gburd added 2 commits November 3, 2026 03:35
…le txn

DB_SNAPSHOT_CONFLICT/DB_SNAPSHOT_UNSAFE are returned under DB_TXN_SERIALIZABLE
(SSI); the old text referenced DB_TXN_SNAPSHOT.  Handle them like
DB_LOCK_DEADLOCK: abort and re-run the entire transaction, reads included,
because the snapshot is stale -- retrying only the failing op is wrong.
The qualification's "production-ready on B-tree" verdict was wrong. SSI still
commits a write skew with several records per leaf, distinct from the cross-leaf
miss the lock.c fix in this PR closed. It is pre-existing on master and on the
LTS line (workstream S2, fix/ssi-second-miss, is root-causing and fixing it).

Corrections:

- The report verdict becomes "NOT production-ready: a second miss is under
  investigation"; the public label for DB_TXN_SERIALIZABLE stays experimental
  until S2 lands.

- Remove the claim that a cycle at small CYC_VALPAD is a page-granularity
  artifact, from both the report and the test_ssi_cycle.c comment block. The
  oracle is sound at any CYC_VALPAD: read-from comes from unique stored tags,
  per-key version order is commit order, so a per-key DSG cycle is a genuine
  anomaly (Adya). Page MVCC can only add aborts, never invent a cycle.

- Hold the "~100% false" interpretation of the false-abort measurement while a
  genuine detection miss is open; the abort-rate magnitude still stands.

- Wire a CYC_VALPAD=200 run (btree + hash) into the isolation tier, run for
  visibility but emitted as skip (advisory / expected-fail) so it does not fail
  the gate until the second miss is fixed; MANIFEST rows marked optional.
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

S2: pushed the verdict correction (commits ac0d26dde, caa8eb206).

Per the lead's review, the "production-ready" verdict was wrong and is corrected:

  • Report verdict → NOT production-ready: a second write-skew miss is under investigation; DB_TXN_SERIALIZABLE stays experimental until S2 lands.
  • Removed the page-granularity-artifact claim from both SSI-PRODUCTION-2026-11.md and the test_ssi_cycle.c comment block. The oracle is sound at any CYC_VALPAD (read-from from unique stored tags; per-key version order is commit order; page MVCC only adds aborts), so a per-key DSG cycle is a genuine Adya anomaly.
  • Held the "~100% false" interpretation of the false-abort measurement while a real detection miss is open; the magnitude still stands.
  • Wired a CYC_VALPAD=200 run (btree + hash) into the isolation tier, run for visibility but emitted as skip (advisory / expected-fail) so it does not fail the gate until the second miss is fixed.

The lock.c cross-leaf fix (cced5c481) stays — it is a real fix with a deterministic gate. S2 now root-causes and fixes the second, pre-existing miss on master (fix/ssi-second-miss) and backports to the LTS line.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Update: the second miss is now fixed — in #257 (master) and #258 (LTS backport, supersedes #247).

Root cause was two defects: a lazy-read_lsn boundary (strict >/<= where the MVCC visibility boundary is inclusive) and a SIREAD rw-conflict scan that ran only on a fresh WRITE grant — skipping grants that waited on a conflicting writer (your promote-path hypothesis, confirmed), were re-granted, or were upgraded. With both fixes, test_ssi_cycle at the new multi-leaf default (CYC_VALPAD=200) is clean: btree 2 threads 0/40, 16 threads 0/40, hash 16 threads 0/40; SI anti-vacuity still finds cycles.

This branch's report is updated to production-ready on B-tree. The CYC_VALPAD default moved 1500 → 200 (1500 pushed values to overflow, putting every key on one leaf — the layout that hid the miss).

@gburd
gburd merged commit caa8eb2 into master Nov 4, 2026
73 of 75 checks passed
@gburd
gburd deleted the test/ssi-production branch November 4, 2026 01:58
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