fix(txn): make the SSI commit-time pivot check atomic with the commit decision (#136) - #151
Merged
Conversation
A write skew committed under DB_TXN_SNAPSHOT when the second transaction's write landed while the first was inside DB_TXN->commit. __txn_commit's pivot check read both flags under TXN_SYSTEM_LOCK, but td->status stays TXN_RUNNING until __txn_end publishes TXN_COMMITTED -- far later, past cursor close, lease checks and the log write. Throughout that span T1 looked like a running transaction with its pivot check still ahead of it. The writer-side "the reader will abort itself" optimization trusted exactly that: seeing TXN_RUNNING plus TXN_DTL_WCONF, T2 deferred the conflict to a check that had already happened, skipped the branch that would have rejected T2 for its own TXN_DTL_RCONF, and only added TXN_DTL_RCONF to T1. T1 ended up holding both pivot flags with nobody left to look at them; both transactions committed and the stored state had no serial order. Close the window where it is created rather than re-checking later: a passing pivot check now publishes TXN_DTL_SICHECKED on the detail in the same TXN_SYSTEM_LOCK critical section as the check itself. The two writer-side fate tests (__lock_get_internal, __memp_si_rwconflict) ask "can the peer still resolve this edge?" via the new TXN_SI_PAST_CHECK predicate instead of "status == TXN_COMMITTED", so a committing-but-not- yet-committed peer is treated like a committed one and the writer aborts itself with DB_SNAPSHOT_UNSAFE. Re-checking after the point of no return is not an option: by then the commit record is written and aborting would be wrong. An aborted transaction is deliberately not "past check" -- its reads never committed, so an edge into it is not a conflict. In the narrow window where a commit published the flag and then failed, a writer may abort itself needlessly: a spurious DB_SNAPSHOT_UNSAFE, never a missed one, on a path where the peer is already failing. TXN_DTL_SICHECKED is a spare bit (0x80) in TXN_DETAIL's existing flags word: sizeof(TXN_DETAIL) stays 344 and every field offset is unchanged, so there is no region-layout, on-disk, log-format or ABI change. Lock ordering is unchanged -- both sides already serialized on TXN_SYSTEM_LOCK, and the commit critical section grows by one F_SET. test/isolation's write_skew_trigger and write_skew_samebtree_trigger no longer violate, so their expect_fail markers are cleared and the tier becomes a true regression gate. The reporter's control (DB_SNAPSHOT_ CONFLICT) and late (DB_SNAPSHOT_UNSAFE) timings are unchanged. Fixes #136
The PUBLIC prototype for __os_csprng (added with the CSPRNG IV seeding) never
had dist/s_include re-run, so src/dbinc_auto/int_def.in lacked its name-mangling
#define. The header-regen drift gate only runs on pull requests, so master
pushes never surfaced it; it fails on any PR branched from current master.
Pure regeneration output ('cd dist && sh s_include'), no hand edits.
Coccinelle convention checksNo new violations. ✅ Resolved since baseline (2) -- update dist/cocci/baseline.txt to lock these in. |
ABI diff vs
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
A write skew commits under
DB_TXN_SNAPSHOTwhen the second transaction'swrite lands while the first is inside
DB_TXN->commit. Both commits return 0and the stored state has no serial order. Reported in #136 with a complete
reproducer and a correct root-cause analysis.
Two halves of one race:
(a) The commit-side check was not atomic with the status transition.
__txn_commitread both pivot flags underTXN_SYSTEM_LOCK, then released it —but
td->statusstaysTXN_RUNNINGuntil__txn_endpublishesTXN_COMMITTED, far later, past cursor close, lease checks and the log write.Throughout that span T1 looked like a running transaction whose pivot check was
still ahead of it. The in-code comment claiming the test and the commit decision
are atomic was true only of the two flag reads, not of the decision.
(b) The writer-side "reader will abort itself" optimization trusted that
stale status. In
__lock_get_internal, T2 met T1's SIREAD marker, sawstatus == TXN_RUNNINGandTXN_DTL_WCONFalready set, and concluded T1would abort at a later pivot check. So it skipped the branch that would have
rejected T2 for its own existing
TXN_DTL_RCONF, did not setWCONFon itself,and only added
RCONFto T1 — a transaction that had already passed its one andonly pivot check. T1 ended up holding both pivot flags with nobody left to look
at them; T2 held only
RCONF; both committed.Design chosen: option 1 — publish a "checked/committing" state under the same mutex as the check
A passing pivot check now publishes
TXN_DTL_SICHECKEDon the detail in thesame
TXN_SYSTEM_LOCKcritical section as the check itself. The writer-sidefate tests ask "can the peer still resolve this edge?" through a new
TXN_SI_PAST_CHECK(td)predicate instead ofstatus == TXN_COMMITTED, so acommitting-but-not-yet-committed peer is treated like a committed one and the
writer resolves the edge itself (
DB_SNAPSHOT_UNSAFE).Why the alternatives are worse:
"last moment" before
status = TXN_COMMITTEDin__txn_endis after__txn_regop_log/log flush and after__lock_vec(DB_LOCK_PUT_READ)releasedthe read locks. Aborting there is not available:
__txn_endis documented asunable to return an error and panics on failure, and the commit record is
already durable, so a replica or a recovery pass would already have accepted
the commit. It also cannot use
goto err(which calls__txn_abort) becausethe transaction's locks are gone. The task brief anticipated this, and reading
the code confirms it: the window is not abortable, so the state must be
published at the decision, not re-examined after it.
status != TXN_RUNNING— wrong direction: it wouldtreat an aborted peer as "past check", turning every edge into a doomed
transaction into a spurious abort of the writer. An aborted transaction's reads
never committed, so an edge into it is not a conflict at all. Hence
TXN_SI_PAST_CHECKtestsTXN_COMMITTED || (TXN_RUNNING && SICHECKED).rejected converting
TXN_DETAIL.flagsto an atomic: it carries many non-SSIbits and atomics alone would not make the decision atomic. Both sides already
serialize on
TXN_SYSTEM_LOCK; the missing ingredient was state, not moremutual exclusion.
all) — arguably the better long-term shape, but a much larger behavioral change
than a bug fix should carry.
Also fixed the same staleness at the mirror site in mpool
(
__memp_si_rwconflict), which had the identicalwtd->status == TXN_COMMITTEDtest. The reported reproducer does not reach it, but it is the same defect one
mechanism over — the lazy fix is the one predicate used by both callers.
Constraints honoured
No layout/ABI/format change.
TXN_DTL_SICHECKEDis the spare bit0x80in
TXN_DETAIL's existingu_int32_t flagsword (bits through0x40weretaken). Verified by compiling the struct both ways:
sizeof(TXN_DETAIL)offsetof(flags)offsetof(links)offsetof(slots)sizeof(DB_TXNREGION)No on-disk, log or region-layout change; no public ABI change. No new message
IDs (no new user-visible strings), so
s_message_idwas not needed.Lock ordering unchanged. No new mutex and no new nesting: both sides
already took
TXN_SYSTEM_LOCKaround their flag access. The commit criticalsection grows by exactly one
F_SETon a word already in cache.Existing correct behaviors preserved:
controlstill yieldsDB_SNAPSHOT_CONFLICT,latestill yieldsDB_SNAPSHOT_UNSAFE.Proof the race is closed
The reporter's own reproducer, built against this branch, on both the debug
(
--enable-debug --enable-diagnostic) and the release (CFLAGS=-O2) trees:alice=0 bob=0, no serial orderDB_SNAPSHOT_UNSAFE,alice=0 bob=1DB_SNAPSHOT_CONFLICTDB_SNAPSHOT_UNSAFEIt is a race, so it was run repeatedly rather than once:
serializable, 0 violations. Every trigger run gave
DB_SNAPSHOT_UNSAFE;every control gave
DB_SNAPSHOT_CONFLICT.-O2build: 25 runs per mode -> 75/75 serializable.The isolation tier, expectations cleared
expect_failis now clear onwrite_skew_triggerandwrite_skew_samebtree_trigger(and the README table/exit-status sectionupdated), so the tier is a plain regression gate — any violation is a new bug.
Run 4x in full (each racy scenario doing 40 attempts) plus 5 extra runs of just
the two trigger scenarios: ~700 racy attempts, 0 non-serializable histories.
Both #136 shapes now show
T2 read alice=1; put bob=0 -> DB_SNAPSHOT_UNSAFE.On the reporter's "separate defect"
Confirmed not a second bug, with two independent constructions: the tier's
write_skew_samebtree_control(512-byte pages + filler keys, 33 leaf pagesverified via
DB->stat→bt_leaf_pg,alice=min key,bob=max key) and thereporter's own two-one-page-DB shape both return
DB_SNAPSHOT_CONFLICTatcontrol timing and only fail at trigger timing. So the different-pages shape has
the same single root cause. The reporter never published their same-btree
variant, so their exact shape is unverified — with two records at the default
page size those sit on one page, a case they themselves report behaves
correctly. The
samebtree_controlPASS expectation is kept live so a realpage-granularity hole would surface there.
Regression matrix
ssi001–ssi009txn001/txn002/txn003lock001/lock002/lock003recd001/recd002(btree)test001btree / hash / queue / recnossi009+ ssi001/002/004 under ASantest/isolation/run.shtest/soak/run.shtest/lockmatrix/run.shtest/fuzz/check-crashes.shtest_sim_crash_recover(--enable-dst)ASan runs used
LD_PRELOAD=$(cc -print-file-name=libasan.so) ASAN_OPTIONS=detect_leaks=0as required for the instrumentedlibdb_tcl.Builds:
--enable-debug --enable-diagnostic --enable-test,--enable-diagnostic, releaseCFLAGS=-O2,--enable-debug --enable-dst,ASan, and
meson setup build && ninja -C build— all clean, 0 errors.Measured commit-path cost
The fix adds one
F_SETinside an already-held critical section, on theSSI-only path (
F_ISSET(txn, TXN_SNAPSHOT_SAFE)); non-SSI commits executeidentical code.
test/bench/ssi_abort_bench, A/B against master built the sameway (
-O2), alternating runs:Honest reading: this bench cannot resolve the cost. Run-to-run spread within
one config is ±2x (e.g. master alone ranged 1534–3066 txn/s at hot=4096), which
swamps any effect of a single store; the fix is ahead in two of three configs
and behind in one, which is noise, not signal. What is structural: the
critical section is not widened by any lock acquisition, loop or I/O — one bit
set on a word the same line already read — and the SSI abort rate is unchanged
(12.3–13.0% both sides at hot=16), so the fix is not converting benign
schedules into aborts at scale. It only aborts the writer in the specific
commit-window schedule that previously produced a non-serializable history.
Docs
rfc/0003-ssi-serializable-snapshot-isolation.md— removed the libdb 5.3.34: DB_TXN_SNAPSHOT commits a write skew when the second write starts during the first commit #136 bulletfrom Known limitations (retitled to libdb 5.3.34: sequential read-only DB_TXN_SNAPSHOT transactions eventually cause DB_ENV->txn_begin to return ENOMEM #137–libdb 5.3.34: incomplete replication commit lock lists can violate client transaction isolation #140; the other items are left
intact) and documented the delivered mechanism in Design.
docs_src/api/c/txnbegin.md— removed the libdb 5.3.34: DB_TXN_SNAPSHOT commits a write skew when the second write starts during the first commit #136 half of theDB_TXN_SNAPSHOTknown-limitation caveat, including the different-pagesclaim; the libdb 5.3.34: sequential read-only DB_TXN_SNAPSHOT transactions eventually cause DB_ENV->txn_begin to return ENOMEM #137/libdb 5.3.34: __txn_reap_si_details leaks MVCC mutex slots during snapshot cleanup #138 mutex-exhaustion caveat stays.
test/isolation/README.md— expectation table, exit-status section.Fixes #136
Supersedes #150, which was opened from a pre-rebase base. That branch is 3 commits stale and merging it would have deleted the 14 coverage-driver files added in #147 (-5304 lines); this branch is the same change rebased onto current master.