diff --git a/.github/workflows/test-tiers.yml b/.github/workflows/test-tiers.yml index cf8bdc160..53c949eab 100644 --- a/.github/workflows/test-tiers.yml +++ b/.github/workflows/test-tiers.yml @@ -16,11 +16,13 @@ # serializability violation appears AND when a scenario marked as # reproducing a known issue stops violating, so a fix cannot land without # updating the expectation. -# - B3 is advisory (continue-on-error) for now, because on current master it -# legitimately aborts under ASan: that IS the #140 reproduction. Flip it to -# a hard gate in the same PR that fixes #140. -# - B2 (soak) is scheduled/nightly plus manual dispatch: it is a long run and -# the same expectation-flip applies once #137/#138 land. +# - B3 is a HARD GATE. It was advisory while #140 was open (on that master it +# legitimately aborted under ASan -- that abort WAS the #140 reproduction); +# #145 fixed the lock-list sizing, so the matrix passes and any future ASan +# fault in the lock list is a real regression. +# - B2 (soak) is scheduled/nightly plus manual dispatch: it is a long run. It +# was advisory while #137/#138 were open; those are fixed, so when it runs it +# is a hard gate. name: Test tiers (isolation / soak / lock matrix) @@ -89,13 +91,13 @@ jobs: if-no-files-found: ignore # -------------------------------------------------------------------------- - # Tier B3 -- lock-mode matrix under ASan. Advisory until #140 lands, because - # the ASan heap-buffer-overflow it finds in __lock_vec IS the bug report. + # Tier B3 -- lock-mode matrix under ASan. A HARD GATE since #145 fixed the + # __lock_vec lock-list sizing (#140): the matrix passes, so any ASan fault in + # the lock list is now a real regression. # -------------------------------------------------------------------------- lock-matrix: name: B3 lock-mode matrix (ASan) runs-on: ubuntu-latest - continue-on-error: true # advisory until #140 is fixed steps: - uses: actions/checkout@v4 @@ -141,7 +143,6 @@ jobs: name: B2 resource-accounting soak if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - continue-on-error: true # advisory until #137/#138 are fixed timeout-minutes: 60 steps: - uses: actions/checkout@v4 diff --git a/dist/Makefile.in b/dist/Makefile.in index 422f7c6e9..88befb2d9 100644 --- a/dist/Makefile.in +++ b/dist/Makefile.in @@ -218,6 +218,9 @@ SIM_OBJS= sim_core@o@ sim_os_hooks@o@ # by configure only when --enable-faultinject. Compile rule below. FI_OBJS= fi_alloc@o@ +# SSI resource-accounting leak tests -- test/c/. Compile rules below. +LEAK_TESTS= leak_si_locker leak_si_mvcc_mtx + BTREE_OBJS=\ bt_compare@o@ bt_compress@o@ bt_conv@o@ bt_curadj@o@ bt_cursor@o@ \ bt_delete@o@ bt_method@o@ bt_open@o@ bt_put@o@ bt_rec@o@ \ @@ -1308,6 +1311,7 @@ mostly-clean clean: $(RM) -r ALL.OUT.* PARALLEL_TESTDIR.* $(RM) -r RUN_LOG RUNQUEUE TESTDIR TESTDIR.A TEST.LIST $(RM) -r logtrack_seen.db test_micro test_mutex .libs + $(RM) -r $(LEAK_TESTS) $(RM) -r $(LIB_INSTALL_FILE_LIST) $(RM) compile_commands.json @subdir_cmd@ @@ -1563,6 +1567,30 @@ test_mutex: test_mutex@o@ $(DEF_LIB) $(CCLINK) -o $@ $(LDFLAGS) test_mutex@o@ $(DEF_LIB) $(TEST_LIBS) $(LIBS) $(POSTLINK) $@ +################################################## +# SSI resource-accounting leak tests -- test/c/. +# +# Regression gates for the SIREAD cleanup resource leaks (issues #137, #138). +# They assert through the public statistics APIs that locker / mutex / detail +# slot counts stay bounded across thousands of sequential snapshot txns. +# `make leak_tests` builds them; test/c/leak-run.sh builds+runs both modes. +################################################## +leak_tests: $(LEAK_TESTS) + +leak_si_locker@o@: $(testdir)/c/leak_si_locker.c + $(CC) $(CFLAGS) $(DEPFLAGS) $< +leak_si_locker: leak_si_locker@o@ $(DEF_LIB) + $(CCLINK) -o $@ \ + $(LDFLAGS) leak_si_locker@o@ $(DEF_LIB) $(TEST_LIBS) $(LIBS) + $(POSTLINK) $@ + +leak_si_mvcc_mtx@o@: $(testdir)/c/leak_si_mvcc_mtx.c + $(CC) $(CFLAGS) $(DEPFLAGS) $< +leak_si_mvcc_mtx: leak_si_mvcc_mtx@o@ $(DEF_LIB) + $(CCLINK) -o $@ \ + $(LDFLAGS) leak_si_mvcc_mtx@o@ $(DEF_LIB) $(TEST_LIBS) $(LIBS) + $(POSTLINK) $@ + ################################################## # Deterministic Simulation Testing (DST) -- test/sim/. # diff --git a/src/dbinc_auto/int_def.in b/src/dbinc_auto/int_def.in index 7a8bd914d..fe0097178 100644 --- a/src/dbinc_auto/int_def.in +++ b/src/dbinc_auto/int_def.in @@ -1120,6 +1120,7 @@ #define __lock_getlocker __lock_getlocker@DB_VERSION_UNIQUE_NAME@ #define __lock_getlocker_int __lock_getlocker_int@DB_VERSION_UNIQUE_NAME@ #define __lock_addfamilylocker __lock_addfamilylocker@DB_VERSION_UNIQUE_NAME@ +#define __lock_sireap_lockers __lock_sireap_lockers@DB_VERSION_UNIQUE_NAME@ #define __lock_freelocker __lock_freelocker@DB_VERSION_UNIQUE_NAME@ #define __lock_familyremove __lock_familyremove@DB_VERSION_UNIQUE_NAME@ #define __lock_fix_list __lock_fix_list@DB_VERSION_UNIQUE_NAME@ diff --git a/src/dbinc_auto/lock_ext.h b/src/dbinc_auto/lock_ext.h index 387095832..c76d2c261 100644 --- a/src/dbinc_auto/lock_ext.h +++ b/src/dbinc_auto/lock_ext.h @@ -32,6 +32,7 @@ int __lock_id_set __P((ENV *, u_int32_t, u_int32_t)); int __lock_getlocker __P((DB_LOCKTAB *, u_int32_t, int, DB_LOCKER **)); int __lock_getlocker_int __P((DB_LOCKTAB *, u_int32_t, int, DB_LOCKER **)); int __lock_addfamilylocker __P((ENV *, u_int32_t, u_int32_t, u_int32_t)); +int __lock_sireap_lockers __P((ENV *)); int __lock_freelocker __P((DB_LOCKTAB *, DB_LOCKER *)); int __lock_familyremove __P((DB_LOCKTAB *, DB_LOCKER *)); int __lock_fix_list __P((ENV *, DBT *, u_int32_t)); diff --git a/src/lock/lock.c b/src/lock/lock.c index 1e7d2005b..72353ee69 100644 --- a/src/lock/lock.c +++ b/src/lock/lock.c @@ -161,8 +161,31 @@ __lock_siclean_obj(env, obj, old_lsnp) * detail and the (DB_LOCKER_FREED) locker are reclaimed by * __lock_sicleanup once their last marker is gone. */ - if (sh_locker->td_off != INVALID_ROFF) - (void)atomic_dec(env, &LOCKER_TD(env, sh_locker)->si_ref); + if (sh_locker->td_off != INVALID_ROFF && + atomic_dec(env, + &LOCKER_TD(env, sh_locker)->si_ref) == 0 && + F_ISSET(sh_locker, DB_LOCKER_FREED)) + /* + * We just removed the LAST marker of a locker whose + * reclamation __lock_freelocker_int deferred (that + * deferral is why DB_LOCKER_FREED is set, and it always + * happened already: __txn_end frees the locker before it + * publishes status != TXN_RUNNING, which is what let this + * sweep consider the marker at all). Nothing references + * the locker any more, so mark it reclaimable by dropping + * its detail link, and let __lock_sireap_lockers free it + * after this partition mutex is released -- freeing a + * locker needs LOCK_LOCKERS, and the established order is + * LOCK_LOCKERS -> partition, never the reverse. + * + * Clearing td_off here (rather than re-reading si_ref + * later) is what makes the reclaim UAF-free: mpool's + * __txn_remove_buffer may free this detail the instant + * si_ref reaches zero, so the reclaim pass must never + * dereference it again. atomic_dec's return value is the + * last safe observation of the detail. + */ + sh_locker->td_off = INVALID_ROFF; if (sh_locker->nlocks > 0) sh_locker->nlocks--; if ((ret = __lock_freelock(lt, lp, sh_locker, @@ -216,6 +239,16 @@ __lock_sicleanup(env) OBJECT_UNLOCK(lt, region, i); } + /* + * Reclaim committed-reader lockers whose last marker was just removed + * (marked by __lock_siclean_obj), then free the details those markers + * were pinning. Both are done here, with no object partition mutex + * held: locker frees need LOCK_LOCKERS and detail frees need the txn + * region lock, and the established order puts both outside a partition + * mutex. Lockers first, so no locker is left naming a freed detail. + */ + (void)__lock_sireap_lockers(env); + /* * Free committed-reader details whose last SIREAD marker was just * reclaimed above. __txn_end parked them on the mvcc_txn list @@ -1030,12 +1063,32 @@ again: if (obj == NULL) { /* * Upgrading our own SIREAD to WRITE: drop the * SIREAD marker to avoid self-conflicts. + * + * Account for the detail reference the marker + * held, exactly as the other two removal sites + * (__lock_sicommit, __lock_siclean_obj) do and + * with the same td_off guard the grant used. + * Without it si_ref stays permanently above the + * true marker count, so the owning detail (and + * the locker deferring on it) can never be + * reclaimed: a snapshot txn that reads then + * writes the same key leaks a detail, a locker + * and their mutex slots on every iteration until + * txn_begin returns ENOMEM. + * + * This is the reader's OWN marker (sh_off == + * holder), so the detail is its live, running + * transaction: si_ref cannot reach zero here and + * no reclaim can trigger underneath us. */ SH_TAILQ_REMOVE(&sh_obj->sireaders, sireadlp, links, __db_lock); if (atomic_read_relaxed(®ion->nsireaders) > 0) (void)atomic_dec(env, ®ion->nsireaders); + if (sh_locker->td_off != INVALID_ROFF) + (void)atomic_dec(env, + &LOCKER_TD(env, sh_locker)->si_ref); if ((ret = __lock_freelock(lt, sireadlp, LOCK_HOLDER(env, sireadlp), DB_LOCK_UNLINK | DB_LOCK_FREE)) != 0) diff --git a/src/lock/lock_id.c b/src/lock/lock_id.c index 17de40b84..e419922e8 100644 --- a/src/lock/lock_id.c +++ b/src/lock/lock_id.c @@ -544,6 +544,65 @@ __lock_freelocker_int(lt, region, sh_locker, reallyfree) return (0); } +/* + * __lock_sireap_lockers -- + * Free committed-reader (SSI) lockers whose last SIREAD marker has been + * reclaimed. __lock_siclean_obj marks such a locker while holding the + * object partition mutex, by clearing its td_off once the marker count + * reaches zero; here, with no partition mutex held, we take LOCK_LOCKERS + * and release the locker and its logical mutex. Without this the + * DB_LOCKER_FREED locker stayed allocated for the life of the environment, + * so sequential read-only snapshot transactions eventually exhausted the + * mutex region (DB_ENV->txn_begin returning ENOMEM). + * + * This deliberately dereferences no TXN_DETAIL: mpool may free the detail + * as soon as si_ref reaches zero, so the marker-count observation has to + * happen (and does) in __lock_siclean_obj, not here. + * + * PUBLIC: int __lock_sireap_lockers __P((ENV *)); + */ +int +__lock_sireap_lockers(env) + ENV *env; +{ + DB_LOCKER *sh_locker, *next_locker; + DB_LOCKREGION *region; + DB_LOCKTAB *lt; + int ret; + + if (!LOCKING_ON(env)) + return (0); + lt = env->lk_handle; + region = lt->reginfo.primary; + ret = 0; + + LOCK_LOCKERS(env, region); + for (sh_locker = SH_TAILQ_FIRST(®ion->lockers, __db_locker); + sh_locker != NULL; sh_locker = next_locker) { + next_locker = SH_TAILQ_NEXT(sh_locker, ulinks, __db_locker); + /* + * (DB_LOCKER_FREED && td_off == INVALID_ROFF) is set only by + * __lock_siclean_obj: a locker whose reclamation was deferred + * for SIREAD markers that are now all gone. A live locker never + * carries DB_LOCKER_FREED, and a still-deferred one still has + * its td_off. heldby must be empty (__lock_sicommit detached + * the markers, DB_LOCK_PUT_ALL released everything else) -- + * __lock_freelocker_int would return EINVAL rather than free a + * locker with locks, so skip it instead of failing the sweep. + */ + if (!F_ISSET(sh_locker, DB_LOCKER_FREED) || + sh_locker->td_off != INVALID_ROFF || + !SH_LIST_EMPTY(&sh_locker->heldby)) + continue; + if ((ret = + __lock_freelocker_int(lt, region, sh_locker, 1)) != 0) + break; + } + UNLOCK_LOCKERS(env, region); + + return (ret); +} + /* * __lock_freelocker * Remove a locker its family from the hash table. diff --git a/src/lock/lock_stub.c b/src/lock/lock_stub.c index 7a3fb0ef2..eb4fa3522 100644 --- a/src/lock/lock_stub.c +++ b/src/lock/lock_stub.c @@ -493,6 +493,14 @@ __lock_addfamilylocker(env, pid, id, is_family) return (0); } +int +__lock_sireap_lockers(env) + ENV *env; +{ + COMPQUIET(env, NULL); + return (0); +} + int __lock_freelocker(lt, sh_locker) DB_LOCKTAB *lt; diff --git a/src/txn/txn.c b/src/txn/txn.c index 29b29e57a..2524e08c2 100644 --- a/src/txn/txn.c +++ b/src/txn/txn.c @@ -268,10 +268,12 @@ __txn_begin(env, ip, parent, txnpp, flags) * Trigger the marker sweep when live SIREAD markers pass * half the allocated lock objects, so the committed-reader * marker footprint stays bounded instead of growing until - * the next checkpoint. (Committed-reader locker structs are - * not yet reclaimed -- see the SSI known-issues note.) - * st_objects is always non-zero, so the bound holds whether - * or not a max is configured. + * the next checkpoint. The sweep also reclaims the + * committed-reader locker and detail structs the markers + * were pinning (__lock_sireap_lockers / + * __txn_reap_si_details), so those do not accumulate + * either. st_objects is always non-zero, so the bound + * holds whether or not a max is configured. */ u_int32_t nobj = lkreg->stat.st_objects; if (nobj != 0 && atomic_read_relaxed(&lkreg->nsireaders) > nobj / 2) diff --git a/src/txn/txn_region.c b/src/txn/txn_region.c index 07b429530..01c638e8d 100644 --- a/src/txn/txn_region.c +++ b/src/txn/txn_region.c @@ -453,6 +453,13 @@ __txn_oldest_reader(env, lsnp) * (mvcc_ref == 0); genuine MVCC-version holders are freed by * __txn_remove_buffer when their last page is evicted. Best effort. * + * TXN_DTL_SNAPSHOT is the free claim, and it is taken under td->mvcc_mtx + * -- the latch every writer of that flag holds (__txn_end takes it nested + * inside TXN_SYSTEM_LOCK; __txn_remove_buffer takes it alone). This + * routine and __txn_remove_buffer test the same predicate, so without a + * shared claim both could free the same detail. Ordering here is + * TXN_SYSTEM_LOCK -> mvcc_mtx, matching __txn_end. + * * PUBLIC: int __txn_reap_si_details __P((ENV *)); */ int @@ -462,25 +469,58 @@ __txn_reap_si_details(env) DB_TXNMGR *mgr; DB_TXNREGION *region; TXN_DETAIL *td, *next_td; + db_mutex_t mvcc_mtx; + int free_it, ret, t_ret; if ((mgr = env->tx_handle) == NULL) return (0); region = mgr->reginfo.primary; + ret = 0; TXN_SYSTEM_LOCK(env); for (td = SH_TAILQ_FIRST(®ion->mvcc_txn, __txn_detail); td != NULL; td = next_td) { next_td = SH_TAILQ_NEXT(td, links, __txn_detail); - if (F_ISSET(td, TXN_DTL_SNAPSHOT) && - td->mvcc_ref == 0 && atomic_read(&td->si_ref) == 0) { - SH_TAILQ_REMOVE(®ion->mvcc_txn, - td, links, __txn_detail); - __env_alloc_free(&mgr->reginfo, td); - } + if (!F_ISSET(td, TXN_DTL_SNAPSHOT)) + continue; + /* + * Re-test and claim under mvcc_mtx: mvcc_ref is only stable + * under it, and the claim has to exclude __txn_remove_buffer, + * which holds only that mutex. A read-only committed reader + * never allocated one (MUTEX_INVALID); then MUTEX_LOCK is a + * no-op and TXN_SYSTEM_LOCK alone is the serializer, which is + * all __txn_end holds for that case too. + */ + mvcc_mtx = td->mvcc_mtx; + MUTEX_LOCK(env, mvcc_mtx); + free_it = F_ISSET(td, TXN_DTL_SNAPSHOT) && + td->mvcc_ref == 0 && atomic_read(&td->si_ref) == 0; + if (free_it) + F_CLR(td, TXN_DTL_SNAPSHOT); + MUTEX_UNLOCK(env, mvcc_mtx); + if (!free_it) + continue; + + SH_TAILQ_REMOVE(®ion->mvcc_txn, td, links, __txn_detail); + STAT_DEC(env, + txn, nsnapshot, region->stat.st_nsnapshot, td->txnid); + /* + * Release the detail's MVCC mutex before freeing the detail -- + * otherwise the slot is leaked for the life of the environment + * and repetition exhausts the mutex region (a later valid + * operation then gets ENOMEM). The two other detail-free paths + * (__txn_end and __txn_remove_buffer) already free it; doing so + * here under TXN_SYSTEM_LOCK matches __txn_end, so no new lock + * ordering is introduced. Safe to free now: we hold the claim, + * mvcc_ref is 0, and the detail is off every list. + */ + if ((t_ret = __mutex_free(env, &td->mvcc_mtx)) != 0 && ret == 0) + ret = t_ret; + __env_alloc_free(&mgr->reginfo, td); } TXN_SYSTEM_UNLOCK(env); - return (0); + return (ret); } /* @@ -533,21 +573,28 @@ __txn_remove_buffer(env, td, hash_mtx) * We free the transaction detail here only if this is the last * reference and td is on the list of committed snapshot transactions * with active pages. + * + * Claim the free by clearing TXN_DTL_SNAPSHOT here, while mvcc_mtx is + * still held: __txn_reap_si_details tests the same predicate and takes + * the same claim under this mutex, so exactly one of us frees the + * detail. Claiming later (under TXN_SYSTEM_LOCK, which we cannot hold + * yet -- hash_mtx must be dropped first) would leave a window in which + * the reaper frees td and we then touch freed region memory. */ need_free = (--td->mvcc_ref == 0) && F_ISSET(td, TXN_DTL_SNAPSHOT) && atomic_read(&td->si_ref) == 0; + if (need_free) + F_CLR(td, TXN_DTL_SNAPSHOT); MUTEX_UNLOCK(env, td->mvcc_mtx); if (need_free) { MUTEX_UNLOCK(env, hash_mtx); - ret = __mutex_free(env, &td->mvcc_mtx); - td->mvcc_mtx = MUTEX_INVALID; - TXN_SYSTEM_LOCK(env); SH_TAILQ_REMOVE(®ion->mvcc_txn, td, links, __txn_detail); STAT_DEC(env, txn, nsnapshot, region->stat.st_nsnapshot, td->txnid); + ret = __mutex_free(env, &td->mvcc_mtx); __env_alloc_free(&mgr->reginfo, td); TXN_SYSTEM_UNLOCK(env); diff --git a/test/c/leak-run.sh b/test/c/leak-run.sh new file mode 100644 index 000000000..4d527a524 --- /dev/null +++ b/test/c/leak-run.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# test/c/leak-run.sh -- build and run the SSI resource-accounting leak tests. +# +# Regression gate for GitHub issues #137 (committed-reader lockers never +# reclaimed) and #138 (MVCC mutex slot leaked by __txn_reap_si_details). +# Both are resource-exhaustion bugs: they produce no crash and no corrupt +# page, only slot counts that grow once per transaction until a later valid +# operation returns ENOMEM. The two drivers therefore read the counts back +# through the public statistics APIs (DB_ENV->lock_stat, ->mutex_stat, +# ->mutex_stat_print) and fail if they grow with the transaction count. +# +# Each driver also has a control mode that never creates a SIREAD marker; +# the control passes both before and after the fix, so a control failure +# means the harness (not the fix) is wrong. +# +# Usage: ./leak-run.sh [build_dir] (default: ../../build_unix) +# Env: CC, TIMEOUT (seconds per driver run, default 300) +# +# Run from test/c/ inside a `nix develop` shell. + +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUILD=${1:-"$HERE/../../build_unix"} +CC=${CC:-cc} +TIMEOUT=${TIMEOUT:-300} +RUNDIR="$HERE/leak-run" + +[ -f "$BUILD/libdb.a" ] || { + echo "error: $BUILD/libdb.a not found -- build libdb first:" >&2 + echo " (cd $BUILD && ../dist/configure && make -j8 libdb.a)" >&2 + exit 1 +} + +LDF=$(sed -n 's/^LDFLAGS=[[:space:]]*//p' "$BUILD/Makefile" | head -1) +LIBS=$(sed -n 's/^LIBS=[[:space:]]*//p' "$BUILD/Makefile" | head -1) + +mkdir -p "$RUNDIR" +rc=0 +for t in leak_si_locker leak_si_mvcc_mtx; do + echo "=== building $t" + # shellcheck disable=SC2086 + $CC -g -O1 -Wall -Wextra -Wno-unused-parameter \ + -I"$BUILD" "$HERE/$t.c" "$BUILD/libdb.a" \ + $LDF $LIBS -ldl -o "$RUNDIR/$t" +done + +run() { + t=$1; mode=$2; dir="$RUNDIR/$t-$mode" + # Start from an empty directory: the driver creates its environment in a + # TESTDIR_* subdir, and stale region files would carry over state (a + # previous run's exhausted mutex region) into the new run. + if [ -d "$dir" ]; then + find "$dir" -mindepth 1 -delete + else + mkdir -p "$dir" + fi + echo "=== running $t $mode" + if ( cd "$dir" && timeout "$TIMEOUT" "$RUNDIR/$t" "$mode" ); then + echo "--- $t $mode: PASS" + else + echo "--- $t $mode: FAIL (exit $?)" + rc=1 + fi +} + +run leak_si_locker control +run leak_si_locker snapshot +run leak_si_mvcc_mtx no-read +run leak_si_mvcc_mtx read + +[ "$rc" = 0 ] && echo "ALL LEAK TESTS PASS" || echo "LEAK TESTS FAILED" +exit "$rc" diff --git a/test/c/leak_si_locker.c b/test/c/leak_si_locker.c new file mode 100644 index 000000000..9f2f6c446 --- /dev/null +++ b/test/c/leak_si_locker.c @@ -0,0 +1,267 @@ +/*- + * See the file LICENSE for redistribution information. + * + * leak_si_locker.c -- resource-accounting regression test for the SSI + * committed-reader locker leak (GitHub issue #137). + * + * Runs many *sequential* read-only DB_TXN_SNAPSHOT transactions in one + * long-lived environment -- one transaction active at a time, no checkpoint, + * no injected fault -- and asserts through the public statistics APIs that + * the locker / mutex slot counts stay bounded instead of growing once per + * transaction, and that DB_ENV->txn_begin never returns ENOMEM. + * + * Before the fix: __lock_sicleanup reclaimed a committed reader's obsolete + * SIREAD markers but never the DB_LOCKER_FREED locker (nor its logical + * mutex) they were deferring, so st_nlockers grew ~1 per transaction until + * the mutex region was exhausted. + * + * Usage: leak_si_locker [snapshot|control] (default: snapshot) + * snapshot -- DB_TXN_SNAPSHOT (the trigger) + * control -- flags 0 (must be flat both before and after the fix) + * + * Self-bounded and deterministic: fixed transaction count, single thread. + */ +#include +#include + +#include +#include +#include +#include + +#include "db.h" + +#define HOME "TESTDIR_leak_si_locker" +#define ATTEMPTS 2500 /* sequential read-only txns */ +#define SAMPLE_EVERY 250 + +/* + * Bounds. The marker sweep is best-effort and triggers when live SIREAD + * markers pass half the allocated lock objects, so the steady state is a + * sawtooth, not the baseline -- but its height is a function of the lock + * region size, never of ATTEMPTS. Pre-fix the counts track ATTEMPTS (they + * hit ENOMEM at ~1400); post-fix they plateau far below. The sharp test is + * the plateau check (late sample vs. an early one); these are backstops. + */ +#define MAX_LOCKERS 1000 +#define MAX_MUTEXES_OVER_BASE 1000 + +static DB_ENV *env; +static DB *db; + +static void +fail(const char *op, int ret) +{ + fprintf(stderr, "FAIL %s: %s (%d)\n", op, db_strerror(ret), ret); + exit(1); +} + +static u_int32_t +lockers(void) +{ + DB_LOCK_STAT *sp; + u_int32_t n; + int ret; + + if ((ret = env->lock_stat(env, &sp, 0)) != 0) + fail("DB_ENV->lock_stat", ret); + n = sp->st_nlockers; + free(sp); + return (n); +} + +static u_int32_t +mutexes(void) +{ + DB_MUTEX_STAT *sp; + u_int32_t n; + int ret; + + if ((ret = env->mutex_stat(env, &sp, 0)) != 0) + fail("DB_ENV->mutex_stat", ret); + n = sp->st_mutex_inuse; + free(sp); + return (n); +} + +int +main(int argc, char *argv[]) +{ + DB_TXN *txn; + DBT key, data; + u_int32_t base_lk, base_mtx, high_lk, high_mtx, lk, mtx, txn_flags; + u_int32_t first_lk, first_mtx, second_lk, second_mtx; + char keybuf[] = "key", valbuf[64]; + const char *mode; + int completed, enomem, i, ret; + + mode = argc > 1 ? argv[1] : "snapshot"; + if (strcmp(mode, "snapshot") != 0 && strcmp(mode, "control") != 0) { + fprintf(stderr, "usage: %s [snapshot|control]\n", argv[0]); + return (2); + } + txn_flags = strcmp(mode, "control") == 0 ? 0 : DB_TXN_SNAPSHOT; + + /* The caller is expected to run this in a fresh scratch directory. */ + (void)mkdir(HOME, 0755); + + if ((ret = db_env_create(&env, 0)) != 0) + fail("db_env_create", ret); + env->set_errfile(env, stderr); + env->set_errpfx(env, "leak_si_locker"); + if ((ret = env->set_lk_detect(env, DB_LOCK_DEFAULT)) != 0) + fail("DB_ENV->set_lk_detect", ret); + if ((ret = env->open(env, HOME, DB_CREATE | DB_INIT_LOCK | + DB_INIT_LOG | DB_INIT_MPOOL | DB_INIT_TXN, 0600)) != 0) + fail("DB_ENV->open", ret); + + if ((ret = db_create(&db, env, 0)) != 0) + fail("db_create", ret); + if ((ret = db->open(db, NULL, "data.db", NULL, DB_BTREE, + DB_CREATE | DB_AUTO_COMMIT | DB_MULTIVERSION, 0600)) != 0) + fail("DB->open", ret); + + /* Seed the one record every transaction reads. */ + memset(&key, 0, sizeof(key)); + memset(&data, 0, sizeof(data)); + key.data = keybuf; + key.size = sizeof(keybuf); + data.data = valbuf; + data.size = sizeof(valbuf); + memset(valbuf, 'v', sizeof(valbuf)); + if ((ret = db->put(db, NULL, &key, &data, 0)) != 0) + fail("DB->put(seed)", ret); + + high_lk = base_lk = lockers(); + high_mtx = base_mtx = mutexes(); + first_lk = first_mtx = second_lk = second_mtx = 0; + printf("baseline lockers=%lu mutexes=%lu mode=%s attempts=%d\n", + (u_long)base_lk, (u_long)base_mtx, mode, ATTEMPTS); + + completed = enomem = 0; + for (i = 0; i < ATTEMPTS; i++) { + if ((ret = env->txn_begin(env, NULL, &txn, txn_flags)) != 0) { + if (ret == ENOMEM) { + printf("ENOMEM at txn_begin after %d txns\n", + completed); + enomem = 1; + break; + } + fail("DB_ENV->txn_begin", ret); + } + memset(&key, 0, sizeof(key)); + memset(&data, 0, sizeof(data)); + key.data = keybuf; + key.size = sizeof(keybuf); + data.data = valbuf; + data.ulen = sizeof(valbuf); + data.flags = DB_DBT_USERMEM; + if ((ret = db->get(db, txn, &key, &data, 0)) != 0) { + (void)txn->abort(txn); + if (ret == ENOMEM) { + printf("ENOMEM at DB->get after %d txns\n", + completed); + enomem = 1; + break; + } + fail("DB->get", ret); + } + if ((ret = txn->commit(txn, 0)) != 0) { + if (ret == ENOMEM) { + printf("ENOMEM at commit after %d txns\n", + completed); + enomem = 1; + break; + } + fail("DB_TXN->commit", ret); + } + completed++; + + if (completed % SAMPLE_EVERY == 0) { + lk = lockers(); + mtx = mutexes(); + if (lk > high_lk) + high_lk = lk; + if (mtx > high_mtx) + high_mtx = mtx; + /* + * Peak per half of the run. The sweep is best-effort, so + * the steady state is a sawtooth; comparing the two peaks + * is phase-independent, while a per-transaction leak makes + * the second-half peak strictly larger. + */ + if (completed <= ATTEMPTS / 2) { + if (lk > first_lk) + first_lk = lk; + if (mtx > first_mtx) + first_mtx = mtx; + } else { + if (lk > second_lk) + second_lk = lk; + if (mtx > second_mtx) + second_mtx = mtx; + } + printf(" after %5d txns: lockers=%lu mutexes=%lu\n", + completed, (u_long)lk, (u_long)mtx); + } + } + + lk = lockers(); + mtx = mutexes(); + if (lk > high_lk) + high_lk = lk; + if (mtx > high_mtx) + high_mtx = mtx; + printf("final completed=%d enomem=%d lockers=%lu->%lu (peak %lu) " + "mutexes=%lu->%lu (peak %lu) halfpeak lockers=%lu/%lu " + "mutexes=%lu/%lu\n", completed, enomem, + (u_long)base_lk, (u_long)lk, (u_long)high_lk, + (u_long)base_mtx, (u_long)mtx, (u_long)high_mtx, + (u_long)first_lk, (u_long)second_lk, + (u_long)first_mtx, (u_long)second_mtx); + + if ((ret = db->close(db, 0)) != 0) + fail("DB->close", ret); + if ((ret = env->close(env, 0)) != 0) + fail("DB_ENV->close", ret); + + ret = 0; + if (enomem || completed != ATTEMPTS) { + fprintf(stderr, + "FAIL: %s mode did not complete %d transactions " + "(completed %d, enomem %d)\n", + mode, ATTEMPTS, completed, enomem); + ret = 1; + } + if (high_lk > MAX_LOCKERS) { + fprintf(stderr, "FAIL: locker count grew to %lu (limit %lu) " + "-- committed-reader lockers are not being reclaimed\n", + (u_long)high_lk, (u_long)MAX_LOCKERS); + ret = 1; + } + if (high_mtx > base_mtx + MAX_MUTEXES_OVER_BASE) { + fprintf(stderr, "FAIL: mutex slots grew to %lu from base %lu " + "(limit +%lu)\n", (u_long)high_mtx, (u_long)base_mtx, + (u_long)MAX_MUTEXES_OVER_BASE); + ret = 1; + } + /* + * The leak signature: counts that track the transaction count. A + * plateau or sawtooth has equal peaks in both halves of the run; a + * per-transaction leak makes the second half's peak strictly larger. + */ + if (first_lk != 0 && second_lk > first_lk) { + fprintf(stderr, "FAIL: peak lockers rose from %lu (first half) " + "to %lu (second half) -- still leaking one per txn\n", + (u_long)first_lk, (u_long)second_lk); + ret = 1; + } + if (first_mtx != 0 && second_mtx > first_mtx) { + fprintf(stderr, "FAIL: peak mutex slots rose from %lu (first " + "half) to %lu (second half)\n", + (u_long)first_mtx, (u_long)second_mtx); + ret = 1; + } + printf("%s: mode=%s\n", ret == 0 ? "PASS" : "FAIL", mode); + return (ret); +} diff --git a/test/c/leak_si_mvcc_mtx.c b/test/c/leak_si_mvcc_mtx.c new file mode 100644 index 000000000..955b5ae0e --- /dev/null +++ b/test/c/leak_si_mvcc_mtx.c @@ -0,0 +1,352 @@ +/*- + * See the file LICENSE for redistribution information. + * + * leak_si_mvcc_mtx.c -- resource-accounting regression test for the MVCC + * mutex-slot leak in __txn_reap_si_details (GitHub issue #138). + * + * Drives the trigger sequence with public APIs only: each cycle runs a + * DB_TXN_SNAPSHOT transaction that READS one multiversion database (creating + * a SIREAD marker, so the committed detail's si_ref is nonzero) and WRITES + * another (so the detail also has mvcc_ref > 0 and gets parked on the + * mvcc_txn list by __txn_end). Later the last MVCC buffer is evicted while + * the marker is still live, so the detail is finally reclaimed by the SIREAD + * reaper __txn_reap_si_details -- which freed the detail WITHOUT releasing + * td->mvcc_mtx, leaking one mutex slot per reaped detail until the mutex + * region was exhausted (ENOMEM from a later valid operation). + * + * Usage: leak_si_mvcc_mtx [read|no-read] (default: read) + * read -- the trigger: the snapshot txn reads accounts.db + * no-read -- control: same cycle without the read, so no SIREAD marker + * + * Asserts via DB_ENV->mutex_stat()/mutex_stat_print() that st_mutex_inuse + * and the MTX_TXN_MVCC ("txn mvcc") slot count stay bounded, and that no + * operation returns ENOMEM. + */ +#include +#include + +#include +#include +#include +#include + +#include "db.h" + +#define HOME "TESTDIR_leak_si_mvcc_mtx" +#define ACCOUNTS 512 +#define CYCLES 700 +#define VALUE_BYTES 256 + +/* + * Pre-fix, "txn mvcc" grows ~1 per cycle (10 -> 600+) and in_use grows with + * it until ENOMEM. Post-fix both plateau: the sweep is best-effort and fires + * when live SIREAD markers pass half the allocated lock objects, so there is + * a legitimate sawtooth, but its height is a function of the lock region, not + * of CYCLES. The sharp test is therefore the plateau check below (late + * sample must not exceed the early one), with these as loose backstops. + */ +#define MAX_MVCC_MUTEXES 250 +#define MAX_INUSE_OVER_BASE 400 + +static DB_ENV *env; +static DB *accounts, *journal; +static u_long mvcc_mutexes; +static int saw_mvcc_type; + +static void +fail(const char *op, int ret) +{ + fprintf(stderr, "FAIL %s: %s (%d)\n", op, db_strerror(ret), ret); + exit(1); +} + +/* + * DB_ENV->mutex_stat_print(0) emits one "\t" line per mutex + * type in use; keep the MTX_TXN_MVCC line. Makes no libdb call. + */ +static void +message(const DB_ENV *unused, const char *text) +{ + (void)unused; + if (strstr(text, "\ttxn mvcc") != NULL) { + mvcc_mutexes = strtoul(text, NULL, 10); + saw_mvcc_type = 1; + } +} + +static u_int32_t +in_use(void) +{ + DB_MUTEX_STAT *sp; + u_int32_t n; + int ret; + + if ((ret = env->mutex_stat(env, &sp, 0)) != 0) + fail("DB_ENV->mutex_stat", ret); + n = sp->st_mutex_inuse; + free(sp); + + mvcc_mutexes = 0; + saw_mvcc_type = 0; + if ((ret = env->mutex_stat_print(env, 0)) != 0) + fail("DB_ENV->mutex_stat_print", ret); + return (n); +} + +static void +pair(DBT *key, DBT *data, char *keybuf, char *databuf) +{ + memset(key, 0, sizeof(*key)); + memset(data, 0, sizeof(*data)); + key->data = keybuf; + key->size = (u_int32_t)strlen(keybuf); + key->ulen = 64; + key->flags = DB_DBT_USERMEM; + data->data = databuf; + data->size = VALUE_BYTES; + data->ulen = VALUE_BYTES; + data->flags = DB_DBT_USERMEM; +} + +static int +retryable(int ret) +{ + return (ret == DB_LOCK_DEADLOCK || ret == DB_SNAPSHOT_CONFLICT || + ret == DB_SNAPSHOT_UNSAFE); +} + +/* One snapshot transaction: optionally read accounts, then write journal. */ +static int +cycle_txn(int with_read, int cycle, char *databuf) +{ + DB_TXN *txn; + DBT key, data; + char keybuf[64]; + int abort_ret, ret; + + for (;;) { + txn = NULL; + if ((ret = env->txn_begin(env, + NULL, &txn, DB_TXN_SNAPSHOT)) == ENOMEM) + return (ret); + if (retryable(ret)) + continue; + if (ret != 0) + fail("DB_ENV->txn_begin", ret); + + if (with_read) { + snprintf(keybuf, sizeof(keybuf), + "account-%d", cycle % ACCOUNTS); + pair(&key, &data, keybuf, databuf); + if ((ret = accounts->get(accounts, + txn, &key, &data, 0)) != 0) + goto undo; + } + snprintf(keybuf, sizeof(keybuf), "entry-%d", cycle); + pair(&key, &data, keybuf, databuf); + if ((ret = journal->put(journal, txn, &key, &data, 0)) != 0) + goto undo; + + if ((ret = txn->commit(txn, 0)) == 0 || ret == ENOMEM) + return (ret); + if (!retryable(ret)) + fail("DB_TXN->commit", ret); + continue; + +undo: if ((abort_ret = txn->abort(txn)) != 0) + fail("DB_TXN->abort", abort_ret); + if (ret == ENOMEM) + return (ret); + if (!retryable(ret)) + fail("txn body", ret); + } +} + +int +main(int argc, char *argv[]) +{ + DBT key, data; + char keybuf[64], databuf[VALUE_BYTES]; + const char *mode; + u_long base_mvcc, high_mvcc, first_mvcc, second_mvcc; + u_int32_t base, high, last, first_inuse, second_inuse; + int cycle, enomem, j, ret, with_read; + + mode = argc > 1 ? argv[1] : "read"; + if (strcmp(mode, "read") != 0 && strcmp(mode, "no-read") != 0) { + fprintf(stderr, "usage: %s [read|no-read]\n", argv[0]); + return (2); + } + with_read = strcmp(mode, "read") == 0; + + (void)mkdir(HOME, 0755); + + if ((ret = db_env_create(&env, 0)) != 0) + fail("db_env_create", ret); + env->set_errfile(env, stderr); + env->set_errpfx(env, "leak_si_mvcc_mtx"); + env->set_msgcall(env, message); + if ((ret = env->set_lk_detect(env, DB_LOCK_DEFAULT)) != 0) + fail("DB_ENV->set_lk_detect", ret); + if ((ret = env->open(env, HOME, DB_CREATE | DB_INIT_LOCK | + DB_INIT_LOG | DB_INIT_MPOOL | DB_INIT_TXN, 0600)) != 0) + fail("DB_ENV->open", ret); + + if ((ret = db_create(&accounts, env, 0)) != 0) + fail("db_create(accounts)", ret); + if ((ret = accounts->open(accounts, NULL, "accounts.db", NULL, + DB_BTREE, DB_CREATE | DB_AUTO_COMMIT | DB_MULTIVERSION, 0600)) != 0) + fail("DB->open(accounts)", ret); + if ((ret = db_create(&journal, env, 0)) != 0) + fail("db_create(journal)", ret); + if ((ret = journal->open(journal, NULL, "journal.db", NULL, + DB_BTREE, DB_CREATE | DB_AUTO_COMMIT | DB_MULTIVERSION, 0600)) != 0) + fail("DB->open(journal)", ret); + + memset(databuf, 'x', sizeof(databuf)); + for (j = 0; j < ACCOUNTS; ++j) { + snprintf(keybuf, sizeof(keybuf), "account-%d", j); + pair(&key, &data, keybuf, databuf); + for (;;) { + ret = accounts->put(accounts, NULL, &key, &data, 0); + if (ret == 0) + break; + if (!retryable(ret)) + fail("DB->put(preload)", ret); + } + } + high = last = base = in_use(); + if (!saw_mvcc_type) + fail("mutex_stat_print: no \"txn mvcc\" line", EINVAL); + high_mvcc = base_mvcc = mvcc_mutexes; + first_mvcc = second_mvcc = 0; + first_inuse = second_inuse = 0; + printf("baseline mode=%s in_use=%lu txn_mvcc=%lu cycles=%d\n", + mode, (u_long)base, base_mvcc, CYCLES); + + enomem = 0; + for (cycle = 0; cycle < CYCLES; ++cycle) { + if (cycle_txn(with_read, cycle, databuf) == ENOMEM) { + printf("ENOMEM in snapshot txn at cycle %d\n", cycle); + enomem = 1; + break; + } + + /* Flush, then churn the cache so MVCC buffers get evicted. */ + if ((ret = env->memp_sync(env, NULL)) != 0) + fail("DB_ENV->memp_sync", ret); + for (j = 0; j < ACCOUNTS; ++j) { + snprintf(keybuf, sizeof(keybuf), "account-%d", + (j + 97 * cycle) % ACCOUNTS); + pair(&key, &data, keybuf, databuf); + for (;;) { + ret = accounts->get(accounts, + NULL, &key, &data, 0); + if (ret == 0) + break; + if (ret == ENOMEM) { + printf("ENOMEM in churn get at " + "cycle %d\n", cycle); + enomem = 1; + goto done; + } + if (!retryable(ret)) + fail("DB->get(churn)", ret); + } + } + + /* One autocommit write outside any snapshot transaction. */ + snprintf(keybuf, sizeof(keybuf), "tick-%d", cycle); + pair(&key, &data, keybuf, databuf); + for (;;) { + ret = journal->put(journal, NULL, &key, &data, 0); + if (ret == 0) + break; + if (ret == ENOMEM) { + printf("ENOMEM in autocommit put at " + "cycle %d\n", cycle); + enomem = 1; + goto done; + } + if (!retryable(ret)) + fail("DB->put(autocommit)", ret); + } + if ((cycle + 1) % 100 == 0 && + (ret = env->txn_checkpoint(env, 0, 0, 0)) != 0) + fail("DB_ENV->txn_checkpoint", ret); + + last = in_use(); + if (last > high) + high = last; + if (mvcc_mutexes > high_mvcc) + high_mvcc = mvcc_mutexes; + /* Peak per half of the run; see the locker test for why. */ + if (cycle < CYCLES / 2) { + if (mvcc_mutexes > first_mvcc) + first_mvcc = mvcc_mutexes; + if (last > first_inuse) + first_inuse = last; + } else { + if (mvcc_mutexes > second_mvcc) + second_mvcc = mvcc_mutexes; + if (last > second_inuse) + second_inuse = last; + } + if ((cycle + 1) % 100 == 0) + printf(" after %4d cycles: in_use=%lu txn_mvcc=%lu\n", + cycle + 1, (u_long)last, mvcc_mutexes); + } + +done: printf("final mode=%s cycles=%d enomem=%d in_use=%lu->%lu (peak %lu) " + "txn_mvcc=%lu->%lu (peak %lu) halfpeak in_use=%lu/%lu " + "txn_mvcc=%lu/%lu\n", mode, cycle, enomem, + (u_long)base, (u_long)last, (u_long)high, + base_mvcc, mvcc_mutexes, high_mvcc, + (u_long)first_inuse, (u_long)second_inuse, + first_mvcc, second_mvcc); + + if ((ret = accounts->close(accounts, 0)) != 0) + fail("DB->close(accounts)", ret); + if ((ret = journal->close(journal, 0)) != 0) + fail("DB->close(journal)", ret); + if ((ret = env->close(env, 0)) != 0) + fail("DB_ENV->close", ret); + + ret = 0; + if (enomem || cycle != CYCLES) { + fprintf(stderr, "FAIL: %s mode did not complete %d cycles " + "(reached %d, enomem %d)\n", mode, CYCLES, cycle, enomem); + ret = 1; + } + if (high_mvcc > MAX_MVCC_MUTEXES) { + fprintf(stderr, "FAIL: \"txn mvcc\" mutexes grew to %lu " + "(limit %lu) -- reaped details leak their mvcc_mtx\n", + high_mvcc, (u_long)MAX_MVCC_MUTEXES); + ret = 1; + } + if (high > base + MAX_INUSE_OVER_BASE) { + fprintf(stderr, "FAIL: mutex slots in use grew to %lu from " + "base %lu (limit +%lu)\n", (u_long)high, (u_long)base, + (u_long)MAX_INUSE_OVER_BASE); + ret = 1; + } + /* + * The leak signature: the counts track the cycle count. A plateau or + * sawtooth has equal peaks in both halves of the run; a per-transaction + * leak makes the second half's peak strictly larger. + */ + if (first_mvcc != 0 && second_mvcc > first_mvcc) { + fprintf(stderr, "FAIL: peak \"txn mvcc\" mutexes rose from %lu " + "(first half) to %lu (second half) -- reaped details still " + "leak their mvcc_mtx\n", first_mvcc, second_mvcc); + ret = 1; + } + if (first_inuse != 0 && second_inuse > first_inuse) { + fprintf(stderr, "FAIL: peak mutex slots in use rose from %lu " + "(first half) to %lu (second half)\n", + (u_long)first_inuse, (u_long)second_inuse); + ret = 1; + } + printf("%s: mode=%s\n", ret == 0 ? "PASS" : "FAIL", mode); + return (ret); +} diff --git a/test/soak/test_soak_resources.c b/test/soak/test_soak_resources.c index 4e931913a..ee25dc0ba 100644 --- a/test/soak/test_soak_resources.c +++ b/test/soak/test_soak_resources.c @@ -104,6 +104,14 @@ typedef struct soak_workload { const char *issue; /* Which counters this workload asserts on; 0 => all of them. */ unsigned mask; + /* + * Minimum transactions needed for a meaningful verdict. Some SSI + * bookkeeping is a bounded sawtooth whose collect phase only fires + * once the live count passes half the allocated lock objects; a window + * shorter than one full period lands on a rising phase and reads as a + * leak. 0 => the default is fine. + */ + long min_txns; } soak_workload; static DB_ENV *env; @@ -493,19 +501,19 @@ wl_cursor_churn(soak_workload *w, long i) static soak_workload workloads[] = { { "ro_snapshot", "read-only DB_TXN_SNAPSHOT txns, no write (the #137 shape)", - wl_ro_snapshot, 1, "#137", 0 }, + wl_ro_snapshot, 0, NULL, 0, 0 }, { "mvcc_retained", "snapshot txns that read and write, details MVCC-retained (#138)", - wl_mvcc_retained, 1, "#138", 0 }, + wl_mvcc_retained, 0, NULL, 0, 25000 }, { "rw_plain", "ordinary read-write txns, no snapshot (control)", - wl_rw_plain, 0, NULL, 0 }, + wl_rw_plain, 0, NULL, 0, 0 }, { "aborted", "snapshot txns that all abort (control)", - wl_aborted, 0, NULL, 0 }, + wl_aborted, 0, NULL, 0, 0 }, { "cursor_churn", "plain txns that open, walk and close a cursor (control)", - wl_cursor_churn, 0, NULL, 0 }, + wl_cursor_churn, 0, NULL, 0, 0 }, }; #define NWORKLOADS ((int)(sizeof(workloads) / sizeof(workloads[0]))) @@ -515,6 +523,36 @@ static soak_workload workloads[] = { * per 1000 transactions. Least squares rather than (last - first) * because a single noisy endpoint should not decide the verdict. */ +/* + * soak_peak_grew -- + * Does counter `c' peak HIGHER in the second half of [lo, hi) than in the + * first? This separates a genuine leak from a bounded sawtooth: a leak + * climbs, so its later peaks exceed its earlier ones, while bookkeeping + * that accumulates and is then collected returns to the same band no + * matter how long it runs. A small slack keeps ordinary jitter from + * reading as growth. + */ +static int +soak_peak_grew(const soak_sample *s, int lo, int hi, int c) +{ + double first, second, v; + int i, mid; + + if (hi - lo < 4) /* Too few samples to judge. */ + return (1); + mid = lo + (hi - lo) / 2; + first = second = 0.0; + for (i = lo; i < mid; i++) + if ((v = s[i].v[c]) > first) + first = v; + for (i = mid; i < hi; i++) + if ((v = s[i].v[c]) > second) + second = v; + + /* Grew only if the later peak clears the earlier one by >10%. */ + return (second > first * 1.10 + 1.0); +} + static double soak_slope(const soak_sample *s, int lo, int hi, int c) { @@ -550,9 +588,21 @@ run_workload(soak_workload *w) double slope[C_NCOUNTER]; long every, i; int c, leaked, nsample, ok, warm; + long n; + + /* + * Honour the workload's minimum: a window shorter than one sawtooth + * period lands on a rising phase and reads as a leak (see min_txns). + */ + n = soak_n; + if (w->min_txns != 0 && n < w->min_txns) { + printf(" (raising %ld -> %ld transactions: this shape needs " + "a full collect cycle to judge)\n", n, w->min_txns); + n = w->min_txns; + } printf("== %s ==\n shape: %s\n %ld sequential transactions\n", - w->name, w->shape, soak_n); + w->name, w->shape, n); soak_enomem_at = -1; soak_enomem_call = NULL; @@ -562,12 +612,12 @@ run_workload(soak_workload *w) if (soak_put(NULL, i, 0) != 0) soak_die("seed put", EINVAL); - every = soak_n / (SOAK_MAX_SAMPLE - 1); + every = n / (SOAK_MAX_SAMPLE - 1); if (every < 1) every = 1; nsample = 0; soak_sample_now(&s[nsample++], 0); - for (i = 1; i <= soak_n; i++) { + for (i = 1; i <= n; i++) { int rc = w->one(w, i); if (rc != 0) @@ -575,8 +625,8 @@ run_workload(soak_workload *w) if (i % every == 0 && nsample < SOAK_MAX_SAMPLE) soak_sample_now(&s[nsample++], i); } - if (nsample < SOAK_MAX_SAMPLE && s[nsample - 1].txns != soak_n) - soak_sample_now(&s[nsample++], soak_n); + if (nsample < SOAK_MAX_SAMPLE && s[nsample - 1].txns != n) + soak_sample_now(&s[nsample++], n); /* * Warmup: ignore the first quarter of the samples. Lazy region @@ -607,8 +657,26 @@ run_workload(soak_workload *w) printf(" %-14s %+10.2f (tol %6.2f)%s\n", counters[c].name, slope[c], counters[c].tolerance, slope[c] > counters[c].tolerance ? " <== GROWING" : ""); - if (slope[c] > counters[c].tolerance) - leaked = 1; + if (slope[c] <= counters[c].tolerance) + continue; + /* + * A positive slope alone does not prove a leak. Some SSI + * bookkeeping is a bounded SAWTOOTH: it accumulates until a GC + * trigger fires (for markers, when the live count passes half + * the allocated lock objects) and then collapses. A sample + * window that happens to land on a rising phase yields a large + * least-squares slope even though the peak never grows -- and + * "the peak stays bounded" is the honest property a fixed engine + * guarantees here. So only call it a leak if the second half + * also peaks higher than the first: a real leak climbs, a + * sawtooth returns. + */ + if (!soak_peak_grew(s, warm, nsample, c)) { + printf(" %-14s bounded: peak does not grow " + "(sawtooth, not a leak)\n", counters[c].name); + continue; + } + leaked = 1; } if (soak_enomem_at >= 0) { printf(" ENOMEM/RUNRECOVERY from %s at transaction " @@ -629,7 +697,7 @@ run_workload(soak_workload *w) else printf(" FAIL: a region resource grew " "monotonically beyond tolerance over %ld " - "sequential transactions.\n\n", soak_n); + "sequential transactions.\n\n", n); return (1); } if (w->expect_leak)