From 1dca3dd616b210f8bb8370f51e7a6f6c3857b5b6 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 3 Nov 2026 00:46:13 -0500 Subject: [PATCH] lock: steal a free locker from another stripe before failing (recd015) P12 (2d8397dfe) gave each of the 64 locker stripes its own free list. An empty stripe refills with a batch of st_lockers >> 2 lockers capped by st_maxlockers - st_lockers and spread round-robin across all stripes. Once st_lockers reaches st_maxlockers (= lk_max_lockers) the cap is zero, so a create landing on a stripe whose free list is empty fails with "Lock table is out of available locker entries" even though other stripes still hold idle free lockers and the number of lockers actually in use is well below the configured limit. Recovery's backward pass creates one locker per prepared transaction; the id hash concentrates those creates on a few stripes, so a long prepare run hits the dry-stripe case and panics recovery. The TCL recd015 btree test ("prepare 10000 txns", lk_max_lockers 10005) fails on the backward pass with BDB2055 then DB_RUNRECOVERY. It passed on Berkeley DB 5.3.28 and on the commit just before P12 (e2d279040); it has failed since 2d8397dfe, shipped in v2026.10.5. Fix: in __lock_getlocker_int, when the stripe's free list is empty and the region cannot grow st_lockers any further, steal a free locker from another stripe before failing. This path already holds LOCK_LOCKERS (every stripe, exclusive), so every stripe's free list is safe to touch. The stolen locker's stripe is recomputed from its id when it is inserted below, which is the stealing stripe, so it joins this stripe's bucket chain and ulinks list and, when freed, returns to this stripe's free list -- a one-way migration, never a cross-stripe leak. With the P12 sharding switch off (locker_shard == 0) every locker lives on stripe 0 and the steal loop finds nothing, so behaviour is unchanged. No region-format change. Adds test/db/lock_stripe_exhaustion.c + run_lock_stripe_exhaustion.sh, a deterministic black-box regression test (public lock_id / lock_id_free only): fill the pool, free every locker off a target stripe so that stripe is dry while the region is full, then create on the target stripe. FAILs before the fix (ENOMEM), PASSes with it. (cherry picked from commit 6ff1561b1cde8a39a16a5bf0747fd46d05b2884b) --- src/lock/lock_id.c | 46 ++++++- test/MANIFEST | 1 + test/db/lock_stripe_exhaustion.c | 180 ++++++++++++++++++++++++++ test/db/run_lock_stripe_exhaustion.sh | 79 +++++++++++ 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 test/db/lock_stripe_exhaustion.c create mode 100755 test/db/run_lock_stripe_exhaustion.sh diff --git a/src/lock/lock_id.c b/src/lock/lock_id.c index 37db14e98f..8a821ea19f 100644 --- a/src/lock/lock_id.c +++ b/src/lock/lock_id.c @@ -550,8 +550,50 @@ __lock_getlocker_int(lt, locker, create, retp) F_CLR(<->reginfo, REGION_TRACKED); LOCK_REGION_UNLOCK(lt->env); LOCK_LOCKERS(env, region); - if (nlockers == 0) - return (__lock_nomem(env, "locker entries")); + if (nlockers == 0) { + /* + * The region cannot grow st_lockers any further + * (it has reached st_maxlockers = lk_max_lockers), + * yet this stripe's free list is empty. Under P12's + * per-stripe free lists the global cap can be + * unreached while THIS stripe is dry and OTHER + * stripes still hold idle free lockers -- the id hash + * can pile creates onto a few stripes (recovery's + * backward pass makes one locker per prepared txn, + * recd015). Before failing, steal a free locker from + * another stripe onto this one. + * + * Safe because this path holds LOCK_LOCKERS (all + * stripes, exclusive), so every stripe's free_lockers + * is ours to touch. The stolen locker's bucket (and + * therefore its stripe) is recomputed from its id + * below, which is THIS stripe, so it is inserted on + * this stripe's bucket chain and ulinks list and, when + * freed, returns to this stripe's free list + * (__lock_freelocker_int derives the stripe the same + * way) -- a one-way migration, never a cross-stripe + * leak. The retained mtx_locker travels with it; a + * logical-lock mutex carries no stripe identity. + */ + for (s = 0; s < LOCK_LOCKER_STRIPES; s++) { + if (s == LOCK_LOCKER_ALLOC_STRIPE(region, + LOCK_LOCKER_STRIPE(indx))) + continue; + if ((sh_locker = SH_TAILQ_FIRST(®ion-> + locker_stripe[s].free_lockers, + __db_locker)) == NULL) + continue; + SH_TAILQ_REMOVE(®ion-> + locker_stripe[s].free_lockers, + sh_locker, links, __db_locker); + SH_TAILQ_INSERT_HEAD(&ls->free_lockers, + sh_locker, links, __db_locker); + break; + } + if (s == LOCK_LOCKER_STRIPES) + return (__lock_nomem(env, + "locker entries")); + } /* * Spread the new lockers across ALL stripes' free lists, * round-robin starting at this one. diff --git a/test/MANIFEST b/test/MANIFEST index caad5399bf..1ae4f3ed8b 100644 --- a/test/MANIFEST +++ b/test/MANIFEST @@ -466,6 +466,7 @@ db run_recd_compact db run_recd_handlers db run_upgrade db run_lock_priority_nullderef +db run_lock_stripe_exhaustion db run_txn_remevent_leak db run_compact_subdb_remove db run_log_emfile_nopanic diff --git a/test/db/lock_stripe_exhaustion.c b/test/db/lock_stripe_exhaustion.c new file mode 100644 index 0000000000..7b881c63e7 --- /dev/null +++ b/test/db/lock_stripe_exhaustion.c @@ -0,0 +1,180 @@ +/*- + * Copyright (c) 2026 libdb contributors. All rights reserved. + * + * SPDX-License-Identifier: Sleepycat + * + * See the file LICENSE for redistribution information. + */ + +/*- + * Regression test for the P12 per-stripe locker-allocation exhaustion + * (recd015 "out of available locker entries" on a backward recovery pass). + * + * P12 (2d8397dfe) gave each of the 64 locker stripes its own free list and + * refilled an empty stripe with a batch spread round-robin across ALL stripes, + * capped by st_maxlockers - st_lockers (= lk_max_lockers - in use). Once + * st_lockers reaches st_maxlockers the cap is zero, so a create that lands on + * a stripe whose free list happens to be empty fails with + * "Lock table is out of available locker entries" -- even though OTHER stripes + * still hold idle free lockers and the number of lockers actually in use is + * well below lk_max_lockers. Recovery's backward pass creates one locker per + * prepared txn; the id hash concentrates those creates on a few stripes, so it + * hit the dry-stripe case and panicked the recovery. + * + * This drives the bug through the public API only (lock_id / lock_id_free): + * + * 1. lk_max_lockers = MAXL. The locker hash table size is a prime >= MAXL + * (__db_tablesize), so for every id we allocate (all < MAXL < table size) + * the bucket is id itself and the stripe is id & 63. We therefore know + * each id's stripe exactly. + * 2. Allocate ids with lock_id() until the pool is at its limit, recording + * each id's stripe. Pick a TARGET stripe that received several ids. + * 3. Free every allocated id that is NOT on the target stripe. Freed lockers + * return to their own stripe's free list, so now every stripe except the + * target has free lockers and the target stripe's free list is empty, + * while st_lockers is still at st_maxlockers (nothing was returned to the + * region). + * 4. Allocate ids until one lands on the target stripe. That create finds an + * empty free list and a full region: before the fix it fails with ENOMEM + * (the recd015 panic); with the steal-from-another-stripe fix it succeeds. + * + * Before the fix the test fails at step 4; with the fix it passes. + */ +#include +#include +#include +#include +#include +#include + +#define MAXL 320 /* lk_max_lockers */ +#define STRIPES 64 /* LOCK_LOCKER_STRIPES (dbinc/lock.h) */ + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" FAIL: "); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ +} while (0) + +int +main(int argc, char *argv[]) +{ + DB_ENV *dbenv; + u_int32_t id, ids[MAXL + 1], target, newid; + const char *home = "LOCK_STRIPE_TESTDIR"; + int i, n, ret, per_stripe[STRIPES], freed, got_target; + + (void)argc; (void)argv; + + if ((ret = db_env_create(&dbenv, 0)) != 0) { + fprintf(stderr, "db_env_create: %s\n", db_strerror(ret)); + return (EXIT_FAILURE); + } + dbenv->set_errpfx(dbenv, "lock_stripe_exhaustion"); + (void)dbenv->set_lk_max_lockers(dbenv, MAXL); + if ((ret = dbenv->open(dbenv, home, DB_CREATE | DB_INIT_LOCK | + DB_INIT_MPOOL, 0600)) != 0) { + fprintf(stderr, "env open %s: %s\n", home, db_strerror(ret)); + return (EXIT_FAILURE); + } + + /* + * 2: allocate until the pool is exhausted, recording each id's stripe. + * The library seeds its own initial free list and may hold a handful of + * internal lockers, so we simply allocate until lock_id returns ENOMEM + * rather than assuming an exact count. + */ + memset(per_stripe, 0, sizeof(per_stripe)); + for (n = 0; n < MAXL; n++) { + if ((ret = dbenv->lock_id(dbenv, &id)) != 0) { + if (ret == ENOMEM) + break; + fprintf(stderr, "lock_id #%d: %s\n", n, + db_strerror(ret)); + return (EXIT_FAILURE); + } + ids[n] = id; + per_stripe[id & (STRIPES - 1)]++; + } + printf(" allocated %d lockers before the pool filled\n", n); + CHECK(n > STRIPES, + "allocated only %d lockers; expected more than %d so several share " + "a stripe", n, STRIPES); + + /* Pick a target stripe that several ids landed on. */ + target = 0; + for (i = 0; i < STRIPES; i++) + if (per_stripe[i] > per_stripe[target]) + target = i; + printf(" target stripe %u holds %d allocated lockers\n", + target, per_stripe[target]); + CHECK(per_stripe[target] >= 1, + "no allocated locker landed on the target stripe"); + + /* + * 3: free every allocated id NOT on the target stripe. Those lockers + * go back to their own stripes' free lists; the target stripe keeps its + * lockers in use and its free list empty. st_lockers stays at the max. + */ + freed = 0; + for (i = 0; i < n; i++) + if ((ids[i] & (STRIPES - 1)) != target) { + if ((ret = dbenv->lock_id_free(dbenv, ids[i])) != 0) { + fprintf(stderr, "lock_id_free %u: %s\n", + ids[i], db_strerror(ret)); + return (EXIT_FAILURE); + } + freed++; + } + printf(" freed %d lockers on other stripes; target stripe stays dry\n", + freed); + CHECK(freed > 0, "freed nothing; cannot create the dry-stripe case"); + + /* + * 4: allocate until one id lands on the dry target stripe. That is the + * create that panicked recd015. With the fix it steals a free locker + * from one of the stripes we emptied in step 3 and succeeds. + */ + got_target = 0; + newid = 0; + for (i = 0; i < 4 * STRIPES; i++) { + ret = dbenv->lock_id(dbenv, &id); + if (ret == ENOMEM) { + printf(" lock_id returned ENOMEM " + "(out of locker entries) at try %d\n", i); + break; + } + if (ret != 0) { + fprintf(stderr, "lock_id (step 4) #%d: %s\n", i, + db_strerror(ret)); + return (EXIT_FAILURE); + } + if ((id & (STRIPES - 1)) == target) { + got_target = 1; + newid = id; + break; + } + /* Free non-target ids again so the pool stays full. */ + (void)dbenv->lock_id_free(dbenv, id); + } + CHECK(got_target == 1, + "could not allocate a locker on the dry target stripe %u: " + "the create failed (recd015 regression)", target); + if (got_target) + printf(" allocated locker id %u on the dry target stripe %u\n", + newid, target); + + (void)dbenv->close(dbenv, 0); + + printf("lock_stripe_exhaustion: %d checks, %d failures\n", + checks, failures); + printf("lock_stripe_exhaustion: %s\n", failures == 0 ? "PASS" : "FAIL"); + return (failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE); +} diff --git a/test/db/run_lock_stripe_exhaustion.sh b/test/db/run_lock_stripe_exhaustion.sh new file mode 100755 index 0000000000..b7318399a3 --- /dev/null +++ b/test/db/run_lock_stripe_exhaustion.sh @@ -0,0 +1,79 @@ +#!/bin/sh - +# +# $Id$ +# +# run_lock_stripe_exhaustion.sh -- +# Build and run lock_stripe_exhaustion.c, the regression test for the P12 +# per-stripe locker-allocation exhaustion that made a TCL recd015 backward +# recovery pass fail with "BDB2055 Lock table is out of available locker +# entries" (first broken by 2d8397dfe, shipped in v2026.10.5). +# +# P12 gave each of the 64 locker stripes its own free list and refills an +# empty stripe with a batch capped by st_maxlockers - st_lockers. Once +# st_lockers reaches st_maxlockers (= lk_max_lockers) the cap is zero, so a +# create landing on a stripe whose free list is empty failed even though +# other stripes still held idle free lockers. __lock_getlocker_int now +# steals a free locker from another stripe before failing. +# +# Exits non-zero on failure or hang. + +set -e + +BUILD=${BUILD:-.} +_HERE=$(cd "$(dirname "$0")" && pwd) +SRC=${SRC:-$_HERE/lock_stripe_exhaustion.c} +HOME_DIR=${HOME_DIR:-LOCK_STRIPE_TESTDIR} +TIMEOUT=${TIMEOUT:-180} + +# Prefer the STATIC library (see run_lock_priority_nullderef.sh for why). +LIB="" +LIBRPATH="" +for cand in "$BUILD"/libdb.a "$BUILD"/.libs/libdb-5.3.a "$BUILD"/.libs/libdb-*.a; do + if [ -f "$cand" ]; then LIB="$cand"; break; fi +done +if [ -z "$LIB" ]; then + for cand in "$BUILD"/.libs/libdb-5.3.so "$BUILD"/.libs/libdb-5.3.dylib \ + "$BUILD"/.libs/libdb-*.so "$BUILD"/.libs/libdb-*.dylib; do + if [ -f "$cand" ]; then LIB="$cand"; break; fi + done + [ -n "$LIB" ] && LIBRPATH="-Wl,-rpath,$(cd "$BUILD/.libs" && pwd)" +fi +[ -n "$LIB" ] || { echo "FAIL: no libdb library (static or shared) found under $BUILD"; exit 1; } + +# A static libdb needs its transitive deps named explicitly. +EXTRALIBS="" +for l in $(pkg-config --libs liburing 2>/dev/null); do EXTRALIBS="$EXTRALIBS $l"; done + +echo "Compiling lock_stripe_exhaustion against $LIB" +"${CC:-cc}" -g -O1 ${CFLAGS:-} -I"$BUILD" "$SRC" "$LIB" \ + -lpthread $LIBRPATH $EXTRALIBS \ + -o "$BUILD/lock_stripe_exhaustion" + +# Start from an EMPTY home, regions included (see the priority runner note). +[ -d "$HOME_DIR" ] && find "$HOME_DIR" -mindepth 1 -delete 2>/dev/null +mkdir -p "$HOME_DIR" + +if command -v timeout >/dev/null 2>&1; then + TIMEOUT_CMD="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_CMD="gtimeout" +else + TIMEOUT_CMD="" +fi +run_with_timeout() { + if [ -n "$TIMEOUT_CMD" ]; then + "$TIMEOUT_CMD" "$@" + else + shift # drop the seconds argument + "$@" + fi +} +echo "Running lock_stripe_exhaustion (timeout ${TIMEOUT}s)" +if run_with_timeout "$TIMEOUT" "$BUILD/lock_stripe_exhaustion"; then + echo "run_lock_stripe_exhaustion.sh: PASS" + exit 0 +else + rc=$? + echo "run_lock_stripe_exhaustion.sh: FAIL (rc=$rc)" + exit $rc +fi