Skip to content

test: add isolation, resource-accounting and lock-mode test tiers - #143

Open
gburd wants to merge 5 commits into
masterfrom
work/testtiers
Open

test: add isolation, resource-accounting and lock-mode test tiers#143
gburd wants to merge 5 commits into
masterfrom
work/testtiers

Conversation

@gburd

@gburd gburd commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Why

Five external bug reports (#136-#140) landed on v5.3.34 despite an extensive
test suite. The suite is strong on crash/durability (test/sim: 41 DST
scenarios, write-back crash model, 9 planted bugs) and on memory safety
against malformed input
(test/fuzz: 9 crash seeds, ASan-instrumented gate).
It was blind to two things:

  • isolation semantics as a checkable property — a write skew that
    commits produces no crash, no corrupt page and no sanitizer report, just a
    database state no serial execution could reach.
  • long-running resource accounting — a slot/mutex/locker leak only
    manifests after thousands of sequential transactions, when some later call
    returns ENOMEM.

This PR builds the missing tiers. Each one provably reproduces the issue it
was designed to catch, which is the proof it has teeth.

What

Tier Directory Catches Status on master
B1 isolation test/isolation/ #136 reproduces #136 (2 scenarios XFAIL, 7 PASS)
B2 soak test/soak/ #137, #138 reproduces both (2 XFAIL, 3 controls flat)
B3 lock matrix test/lockmatrix/ #140 reproduces #140 (ASan heap-buffer-overflow)

No src/ changes. All three tiers drive the public API only, so no engine
hook was needed — see below.

B1 — serializability checker

Runs concurrent schedules under DB_TXN_SNAPSHOT and computes the verdict:
each transaction declares its semantics as a pure function over an abstract
state vector; after the run the harness reads the durable state back and
enumerates every permutation of the committed transactions looking for one
that reproduces it. No serial order → not serializable, schedule printed.

The vector carries observation slots as well as record slots, which is what
makes the read-only anomaly checkable (there the stored state is fine and only
the read-only transaction's observation lacks a serial explanation).

Nine scenarios: write skew (trigger/control/late), same skew with the records on
different pages of one B-tree, G2-item on a predicate read, Fekete's 3-txn
read-only anomaly, lost update, and a read-your-writes sanity case that fails if
the harness ever stops driving the engine.

B2 — resource-accounting soak

One long-lived environment, default region sizes, N ≥ 2000 sequential
transactions, sampling seven counters through the public stat APIs
(mutex_stat, lock_stat, txn_stat, memp_stat). Verdict = least-squares
slope over post-warmup samples, in units per 1000 transactions, against a
documented per-counter tolerance. The full growth curve is always printed so a
CI log alone diagnoses a regression.

Kept general (a harness parameterised by workload) and in test/soak/ to
stay clear of the targeted leak tests going into test/c/ with the #137/#138
fix.

B3 — lock-mode matrix

Asserts the invariant rather than the symptom: the locks the release loop
skips must all be counted in nwrites, because nwrites sizes the objlist
allocation.
So the next mode added is covered too. Three sections: every
mode acquired/released; the whole conflict matrix checked for totality and
symmetry (plus st_nmodes vs the number of db_lockmode_t values); and
PUT_READ/UPGRADE_WRITE with an objlist over a sweep of mixed
write/SIREAD shapes.

run.sh builds an ASan libdb under build_asan_gate/, reusing
test/fuzz/check-crashes.sh's mechanism, because the OOB write is inside
libdb's own allocation.

Evidence — what each tier reproduces

B1 → #136. Both trigger shapes violate on the first attempt:

== write_skew_trigger ==
    T1    read bob=1; put alice=0 -> success; commit -> success
    T2    read alice=1; put bob=0 -> success; commit -> success
    initial : db[1,1]
    observed: db[0,0]
    committed txns = 2/2, serial order found = NO
    XFAIL (reproduces #136)

B2 → #137 and #138. 2000 transactions:

== ro_snapshot ==            (#137)
    mutex_inuse      +1000.00  (tol  20.00)   <== GROWING
    lock_lockers     +1000.00  (tol  20.00)   <== GROWING
== mvcc_retained ==          (#138)
    mutex_inuse      +1345.33  (tol  20.00)   <== GROWING
    lock_lockers      +672.75  (tol  20.00)   <== GROWING
    ENOMEM/RUNRECOVERY from txn_begin at transaction 1400
== rw_plain / aborted / cursor_churn ==   PASS (flat)

ro_snapshot leaks exactly one mutex + one locker per transaction.
mvcc_retained leaks mutex slots at 2x the locker rate — the extra
mvcc_mtx of #138 on top of #137 — and exhausts the region at txn 1400. The
three flat controls are what make the signal credible rather than a measurement
artefact.

B3 → #140. First mixed shape faults, after all pure-write controls pass:

    PUT_READ  nwrite= 0 nsiread= 1 nread= 0 ...
==...==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8 at ... thread T0
    #0 __lock_vec .../src/lock/lock.c:457:15
    #1 __lock_vec_api .../src/lock/lock.c:94:9

nwrite=0, nsiread=1 = a zero-sized allocation written with one DBT, the
minimal form. Also aborts an uninstrumented build via glibc's own heap
checks, so it is reachable in a stock build.

Notable finding for the #136 fix

The #136 reporter suspected a separate defect: two records on different
pages of one B-tree detecting no conflict even in the control. That does not
reproduce.
With the shape built explicitly and verified via
DB->statbt_leaf_pg (512-byte pages + filler keys → 33 leaf pages, alice
the min key and bob the max), the control correctly returns
DB_SNAPSHOT_CONFLICT; only the trigger timing commits both. On this
construction the different-pages case has the same root cause as #136 proper
(the commit-window race), not an extra page-granularity hole — so fixing the
commit-window atomicity should suffice. The reporter did not publish their
same-btree variant, so their shape may differ; the control is kept as a live
PASS expectation precisely so a real page-granularity regression would surface.

No engine hook was needed

The #136 interleaving — landing T2's write while T1 is inside
DB_TXN->commit — is reached entirely from the application side: barriers plus
an atomic flag T1 sets immediately before entering commit, as the reporter did.
No src/ change, no HAVE_DST site, zero production overhead. git diff --stat master touches no file under src/. The --enable-dst OFF build still
has 0 __db_sim_* symbols (nm build_unix/libdb.a), unchanged.

That window is genuinely racy, so those scenarios run multiple attempts under an
asymmetric rule: one violation is a reproduction, a pass needs every
attempt clean. A violation is a real counterexample; one clean run of a racy
schedule proves nothing.

Self-correcting expectations

Every tier fails both ways: on a new violation/leak, and when a scenario
marked as reproducing a known issue stops doing so. The message names the flag
to clear. So a fix cannot land without updating the expectation, and the tier
then gates the fix against regression.

CI wiring

New .github/workflows/test-tiers.yml, following the house style of
ci.yml/fuzz.yml:

Both build systems: make tier_tests (autoconf) and test/tiers/meson.build
entered from the root shim like test/pbt, with suites tiers (gating),
tiers-xfail (B3), tiers-slow (B2). Drivers are build_by_default: false, so
a plain ninja is unaffected.

Validation

  • test/fuzz/check-crashes.sh9/9 PASS
  • DST test_sim_rng + test_sim_crash_recoverPASS (--enable-dst build)
  • --enable-dst OFF build — 0 __db_sim_* symbols
  • ssi001, ssi009PASS (--enable-test build)
  • autoconf --enable-debug — builds
  • meson setup build && ninja -C build — builds; meson test --suite tiers green

Running locally

cd test/isolation  && ./run.sh      # B1
cd test/soak       && ./run.sh      # B2  (SOAK_N=10000 for a longer soak)
cd test/lockmatrix && ./run.sh      # B3  (builds an ASan libdb once)

Each directory has a README.md with the full detail, including which flag to
clear when the corresponding issue is fixed.

The suite was strong on crash/durability and on memory safety against
malformed input, but blind to isolation semantics as a checkable property:
a write skew that COMMITS produces no crash, no corrupt page and no
sanitizer report, just a database state no serial execution could reach.
Issue #136 shipped in v5.3.34 through that gap.

test/isolation/test_iso_anomaly.c runs concurrent schedules under
DB_TXN_SNAPSHOT and computes the verdict rather than hard-coding it: each
transaction declares its semantics as a pure function over an abstract
state vector, and after the run the harness reads the durable state back
and enumerates every permutation of the COMMITTED transactions looking for
one that reproduces it.  No serial order means the history is not
serializable, and the schedule is printed.

The state vector carries observation slots as well as record slots, so the
read-only anomaly is checkable -- there the stored state is fine and only
the read-only transaction's observation lacks a serial explanation.

Nine scenarios: write skew in trigger/control/late timings, the same skew
with the two records on different pages of one B-tree, G2-item on a
predicate read, Fekete's three-transaction read-only anomaly, lost update,
and a read-your-writes sanity case that fails if the harness ever stops
driving the engine.

The #136 interleaving -- T2's write landing while T1 is INSIDE
DB_TXN->commit -- is reached from the application side only, with barriers
plus an atomic flag T1 sets before entering commit, as the reporter did.
No engine change, no HAVE_DST site, zero production overhead.  That window
is racy, so those scenarios run several attempts under an asymmetric rule:
one violation is a reproduction, a pass needs every attempt clean.

On master both #136 shapes violate on the first attempt; the other seven
scenarios pass.  Note that the "separate defect" the reporter suspected --
different pages of one B-tree detecting no conflict even in the control --
does NOT reproduce: with the shape verified via DB->stat, the control
correctly returns DB_SNAPSHOT_CONFLICT, so on this construction the
different-pages case has the same root cause as #136 proper.

Expectations are self-correcting: the tier also fails when a scenario
marked as reproducing a known issue STOPS violating, so a fix cannot land
without updating the table.
…138)

A slot/mutex/locker leak produces no crash, no corrupt page and no
sanitizer report.  It only shows up after thousands of sequential
transactions, when some later call returns ENOMEM because the region
filled.  Neither the crash/durability tier nor the memory-safety tier can
see that shape; issues #137 and #138 are both of it.

test/soak/test_soak_resources.c is a general soak harness parameterised by
workload: one long-lived environment with DEFAULT region sizes, N (>= 2000)
sequential transactions, sampling seven counters through the PUBLIC stat
APIs (mutex_stat, lock_stat, txn_stat, memp_stat).  The verdict is a
least-squares slope over the post-warmup samples in units per 1000
transactions against a documented per-counter tolerance -- least squares so
one noisy endpoint cannot decide it, and post-warmup so the tier does not
fight normal lazy region allocation.  The full growth curve is always
printed, so a CI log alone diagnoses a regression.

ENOMEM/DB_RUNRECOVERY is recorded with the call name and transaction number
and fails the workload without aborting the run: "ENOMEM at transaction
1400" beats a stack trace.

Five workloads: read-only snapshot txns (#137), snapshot txns that read and
write so their details are MVCC-retained then reaped (#138), plus plain
read-write, all-abort and cursor-churn controls.

On master, 2000 transactions: ro_snapshot leaks +1000.00 mutex slots and
+1000.00 lockers per 1000 transactions -- exactly one of each per
transaction, never returned.  mvcc_retained leaks +1345 mutex slots against
+673 lockers (the extra mvcc_mtx of #138 on top of #137) and hits ENOMEM
from txn_begin at transaction 1400.  All three controls are flat, which is
what makes the leak signal credible rather than a measurement artefact.

cursor_churn deliberately uses a plain transaction: as a snapshot reader it
tripped the #137 leak and was just a second copy of ro_snapshot.

Lives in test/soak/ to stay clear of the targeted leak tests going into
test/c/ with the #137/#138 fix.  Those prove a specific path frees a
specific resource; this proves the aggregate accounting is stable over a
long run, which is the property that would have caught both before release.
Issue #140 is a heap out-of-bounds WRITE in __lock_vec, but the root-cause
class is what matters: the SSI work added a lock mode (DB_LOCK_SIREAD = 9)
without auditing pre-existing loops that enumerate modes exhaustively.
__lock_vec's DB_LOCK_PUT_READ / DB_LOCK_UPGRADE_WRITE path sizes an objlist
from sh_locker->nwrites while its release loop tests only DB_LOCK_READ and
DB_LOCK_READ_UNCOMMITTED; SIREAD matches neither those nor IS_WRITELOCK, so
it is never counted yet still consumes a slot.  The DB_ASSERT that would
catch it is diagnostic-only and compiled out of release builds.

test/lockmatrix/test_lock_matrix.c asserts the invariant instead of the
symptom -- the locks the release loop SKIPS must all be counted in nwrites,
because nwrites sizes the allocation -- so the NEXT mode added is covered
too.  Three sections, all through the public DB_ENV lock API:

  modes      every db_lockmode_t acquired and released via both
             lock_get/lock_put and lock_vec GET/PUT_ALL.
  conflicts  every (held, wanted) cell.  The verdict is not hard-coded; the
             engine's table is the specification.  What is asserted are the
             properties a table must have regardless of policy: totality
             (a definite granted/NOTGRANTED answer, never an internal error
             or a hang) and symmetry of conflict, plus st_nmodes against the
             number of db_lockmode_t values -- which catches "a mode was
             added without widening the table" directly.
  list       PUT_READ and UPGRADE_WRITE with an objlist, driven by a locker
             holding a MIX of write and SIREAD locks over a sweep of
             (nwrite, nsiread) shapes, with pure-write and write+read
             controls.  The overflow is (nsiread - nwrite) DBTs, so the
             sweep walks nsiread past nwrite.

Objects are genuine DB_LOCK_ILOCKs, since __lock_fix_list only coalesces
objects of exactly that size -- opaque blobs would skip that code.

run.sh builds an ASan-instrumented libdb under build_asan_gate/, reusing
test/fuzz/check-crashes.sh's mechanism and directory, because the OOB write
is inside libdb's own allocation and a harness-only ASan build cannot see
it.  Each shape is printed BEFORE it is attempted, so the last line of
output names the shape that faulted when ASan aborts.

On master this reproduces #140 on the first mixed shape:

    PUT_READ  nwrite=0 nsiread=1 nread=0 ...
    ERROR: AddressSanitizer: heap-buffer-overflow, WRITE of size 8
      #0 __lock_vec src/lock/lock.c:457:15

nwrite=0/nsiread=1 means a zero-sized allocation written with one DBT --
the minimal form.  All pure-write and write+read controls pass first, which
confirms SIREAD specifically is the trigger.  The corruption is severe
enough that an UNinstrumented build also aborts, via glibc's own heap
checks.

Expectations are written for the FIXED engine: no expect_fail flag, so once
#140 lands the matrix must complete and exit 0.
…uilds

New workflow .github/workflows/test-tiers.yml, following the house style of
ci.yml/fuzz.yml -- hard gates for the fast deterministic part, advisory plus
scheduled for the slow or currently-failing parts:

  B1 isolation   HARD GATE per push/PR.  ~6s, deterministic, and its
                 expectations are self-correcting (it fails both on a new
                 violation and when a known-issue scenario stops violating),
                 so a fix cannot land without updating the table.
  B3 lock matrix advisory (continue-on-error), because on master it
                 legitimately aborts under ASan -- that abort IS the #140
                 reproduction.  A summary step surfaces the faulting shape
                 as a warning annotation.  Flip to a hard gate in the PR
                 that fixes #140.
  B2 soak        scheduled (04:41 UTC, offset from ci.yml's 03:17) plus
                 workflow_dispatch with a configurable transaction count.
                 Advisory until #137/#138 land.

Build wiring, neither of which needs a configure option or library-side
hooks since all three tiers drive the public API only:

  autoconf  `make tier_tests` builds the three drivers (dist/Makefile.in).
  meson     test/tiers/meson.build, entered from the root shim like
            test/pbt.  The drivers are build_by_default:false so a plain
            ninja is unaffected.  Three suites: `tiers` (B1, gating),
            `tiers-xfail` (B3, reproduces #140), `tiers-slow` (B2).

Each tier keeps its own run.sh for local use and a README.md documenting
what it checks, how to run it, what it currently reproduces, and which flag
to clear when the corresponding issue is fixed.

.gitignore covers the per-tier build dirs and the scratch environment dirs
the drivers create (ISODIR.*, SOAKDIR.*, LOCKDIR).
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coccinelle convention checks

No new violations. ✅

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

…ures

Two wiring bugs that made the first CI run lie:

  - the three run.sh scripts were committed mode 100644, so B1 failed with
    exit 126 (not executable) rather than running, and B3 "passed" in 24s
    without ever building the ASan libdb or exercising a single shape.
  - `./run.sh | tee log` takes tee's exit status, so an ASan abort in B3 or a
    leak verdict in B2 would have been swallowed.  set -o pipefail on both.

Verified against test/fuzz/run.sh, which is 100755.
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ABI diff vs v5.3.34 (libabigail — authoritative)

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.

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.

libdb 5.3.34: incomplete replication commit lock lists can violate client transaction isolation

1 participant