Each affected cleanup leaves one mutex slot in use. Repetition reduces the mutex region's available capacity and can make a later valid database operation return ENOMEM.
After each successful snapshot transaction, the program flushes modified pages, reads all account rows, and writes one autocommit entry. It requests a checkpoint every 100 cycles.
/*
* libdb 5.3.34: txn mvcc accounting grows across committed DB_TXN_SNAPSHOT
* transactions that read one database and write another.
*
* usage: repro read each cycle reads accounts.db and writes journal.db
* repro no-read control: the same cycle without the account read
*
* The program uses <db.h> only. It reports DB_ENV->mutex_stat() and the
* "txn mvcc" line of DB_ENV->mutex_stat_print() after each reported cycle,
* so the mutex accounting is visible through the public API.
*/
#include <db.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define ACCOUNTS 512 /* rows touched during the read sweep */
#define CYCLES 1000 /* upper bound on the campaign */
#define VALUE_BYTES 256
static DB_ENV *env;
static DB *accounts, *journal;
static u_long mvcc_mutexes;
static int saw_mvcc_type;
static const char *oom_operation; /* the call that returned ENOMEM */
static void
fail(const char *operation, int ret)
{
fprintf(stderr, "FAIL operation=%s ret=%d name=%s\n",
operation, ret, db_strerror(ret));
exit(2);
}
/* Transient transaction outcomes handled by starting a new transaction. */
static int
retryable(int ret)
{
return (ret == DB_LOCK_DEADLOCK || ret == DB_SNAPSHOT_CONFLICT ||
ret == DB_SNAPSHOT_UNSAFE);
}
/*
* DB_ENV->mutex_stat_print() emits one "<count>\t<type>" line per mutex
* type in use. Keep the MTX_TXN_MVCC line and drop the rest. The callback
* makes no libdb call.
*/
static void
message(const DB_ENV *unused, const char *text)
{
const char *tab;
(void)unused;
if ((tab = strstr(text, "\ttxn mvcc")) != NULL) {
mvcc_mutexes = strtoul(text, NULL, 10);
saw_mvcc_type = 1;
}
}
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 u_int32_t
in_use(void)
{
DB_MUTEX_STAT *stat;
u_int32_t value;
int ret;
if ((ret = env->mutex_stat(env, &stat, 0)) != 0)
fail("DB_ENV->mutex_stat", ret);
value = stat->st_mutex_inuse;
free(stat);
mvcc_mutexes = 0;
saw_mvcc_type = 0;
if ((ret = env->mutex_stat_print(env, 0)) != 0)
fail("DB_ENV->mutex_stat_print", ret);
if (!saw_mvcc_type)
fail("DB_ENV->mutex_stat_print(txn mvcc missing)", EINVAL);
return (value);
}
/* Reopen after the clean close and read one committed journal entry. */
static void
verify_stored(char *databuf)
{
DBT key, data;
char keybuf[64];
int i, ret;
if ((ret = db_env_create(&env, 0)) != 0)
fail("db_env_create(reopen)", ret);
env->set_errfile(env, stdout);
if ((ret = env->open(env, ".", DB_CREATE | DB_INIT_LOCK |
DB_INIT_LOG | DB_INIT_MPOOL | DB_INIT_TXN | DB_RECOVER, 0600)) != 0)
fail("DB_ENV->open(reopen)", ret);
if ((ret = db_create(&journal, env, 0)) != 0)
fail("db_create(journal.db reopen)", ret);
if ((ret = journal->open(journal, NULL, "journal.db", NULL,
DB_BTREE, DB_AUTO_COMMIT | DB_MULTIVERSION, 0600)) != 0)
fail("DB->open(journal.db reopen)", ret);
snprintf(keybuf, sizeof(keybuf), "entry-0");
pair(&key, &data, keybuf, databuf);
if ((ret = journal->get(journal, NULL, &key, &data, 0)) != 0)
fail("DB->get(entry-0 reopen)", ret);
if (data.size != VALUE_BYTES)
fail("DB->get(entry-0 size)", EINVAL);
for (i = 0; i < VALUE_BYTES; ++i)
if (databuf[i] != 'x')
fail("DB->get(entry-0 value)", EINVAL);
if ((ret = journal->close(journal, 0)) != 0)
fail("DB->close(journal.db reopen)", ret);
if ((ret = env->close(env, 0)) != 0)
fail("DB_ENV->close(reopen)", ret);
printf("STORED after_reopen key=entry-0 value=x*256 bytes=%lu ret=0 "
"name=DB_SUCCESS\n", (u_long)data.size);
}
/* An autocommit write outside any snapshot transaction. */
static int
autocommit_put(const char *keytext, char *databuf)
{
DBT key, data;
char keybuf[64];
int ret;
for (;;) {
snprintf(keybuf, sizeof(keybuf), "%s", keytext);
pair(&key, &data, keybuf, databuf);
ret = journal->put(journal, NULL, &key, &data, 0);
if (ret == ENOMEM)
oom_operation = "DB->put(autocommit)";
if (ret == 0 || ret == ENOMEM)
return (ret);
if (!retryable(ret))
fail("DB->put(autocommit)", ret);
}
}
/* 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];
const char *site;
int abort_ret, ret;
for (;;) {
txn = NULL;
ret = env->txn_begin(env, NULL, &txn, DB_TXN_SNAPSHOT);
if (ret == ENOMEM) {
oom_operation = "DB_ENV->txn_begin";
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);
site = "DB->get(account)";
if ((ret = accounts->get(accounts,
txn, &key, &data, 0)) != 0)
goto undo;
}
snprintf(keybuf, sizeof(keybuf), "entry-%d", cycle);
pair(&key, &data, keybuf, databuf);
site = "DB->put(entry)";
if ((ret = journal->put(journal, txn, &key, &data, 0)) != 0)
goto undo;
ret = txn->commit(txn, 0);
if (ret == ENOMEM)
oom_operation = "DB_TXN->commit";
if (ret == 0 || ret == ENOMEM)
return (ret);
if (!retryable(ret))
fail("DB_TXN->commit", ret);
continue;
undo: abort_ret = txn->abort(txn);
if (abort_ret != 0)
fail("DB_TXN->abort", abort_ret);
if (ret == ENOMEM) {
oom_operation = site;
return (ret);
}
if (!retryable(ret))
fail(site, ret);
}
}
int
main(int argc, char *argv[])
{
DBT key, data;
char keybuf[64], databuf[VALUE_BYTES];
const char *text;
u_long base_mvcc;
u_int32_t base, last;
int major, minor, patch, with_read, ret, cycle, j, done, oom, valid;
if (argc != 2 || (strcmp(argv[1], "read") != 0 &&
strcmp(argv[1], "no-read") != 0)) {
fprintf(stderr, "usage: %s read|no-read\n", argv[0]);
return (2);
}
with_read = strcmp(argv[1], "read") == 0;
text = db_version(&major, &minor, &patch);
printf("VERSION %s\n", text);
if (major != 5 || minor != 3 || patch != 34)
return (2);
printf("MODE %s cache=default accounts=%d cycles=%d checkpoint_interval=100\n",
argv[1], ACCOUNTS, CYCLES);
if ((ret = db_env_create(&env, 0)) != 0)
fail("db_env_create", ret);
env->set_errfile(env, stdout);
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, ".", DB_CREATE | DB_INIT_LOCK |
DB_INIT_LOG | DB_INIT_MPOOL | DB_INIT_TXN | DB_RECOVER, 0600)) != 0)
fail("DB_ENV->open", ret);
if ((ret = db_create(&accounts, env, 0)) != 0)
fail("db_create(accounts.db)", 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.db)", ret);
if ((ret = db_create(&journal, env, 0)) != 0)
fail("db_create(journal.db)", 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.db)", 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);
}
}
last = base = in_use();
base_mvcc = mvcc_mutexes;
printf("PRELOADED in_use=%lu txn_mvcc=%lu\n",
(u_long)base, base_mvcc);
done = oom = 0;
for (cycle = 0; cycle < CYCLES; ++cycle) {
if ((ret = cycle_txn(with_read, cycle, databuf)) == ENOMEM) {
printf("ENOMEM operation=%s cycle=%d ret=%d name=%s\n",
oom_operation, cycle, ret, db_strerror(ret));
oom = 1;
break;
}
/* Flush writes, then apply read pressure to the cache. */
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 (!retryable(ret))
fail("DB->get(churn)", ret);
}
}
/* One autocommit write, then the periodic checkpoint. */
snprintf(keybuf, sizeof(keybuf), "tick-%d", cycle);
if (autocommit_put(keybuf, databuf) == ENOMEM) {
printf("ENOMEM operation=%s cycle=%d ret=%d name=%s\n",
oom_operation, cycle, ENOMEM, db_strerror(ENOMEM));
oom = 1;
break;
}
if ((cycle + 1) % 100 == 0 &&
(ret = env->txn_checkpoint(env, 0, 0, 0)) != 0)
fail("DB_ENV->txn_checkpoint", ret);
last = in_use();
done = cycle + 1;
if (cycle < 3 || (cycle + 1) % 100 == 0)
printf("CYCLE %d in_use=%lu txn_mvcc=%lu\n",
cycle, (u_long)last, mvcc_mutexes);
}
printf("RESULT mode=%s cycles_completed=%d enomem=%d in_use=%lu->%lu "
"txn_mvcc=%lu->%lu\n", argv[1], done, oom, (u_long)base,
(u_long)last, base_mvcc, mvcc_mutexes);
valid = with_read ?
oom && oom_operation != NULL &&
mvcc_mutexes > base_mvcc + CYCLES / 10 :
!oom && done == CYCLES && mvcc_mutexes <= base_mvcc;
if ((ret = accounts->close(accounts, 0)) != 0)
fail("DB->close(accounts.db)", ret);
if ((ret = journal->close(journal, 0)) != 0)
fail("DB->close(journal.db)", ret);
if ((ret = env->close(env, 0)) != 0)
fail("DB_ENV->close", ret);
printf("CLOSED ret=0 name=DB_SUCCESS\n");
verify_stored(databuf);
if (!valid) {
fprintf(stderr, "FAIL validation mode=%s enomem=%d "
"cycles_completed=%d txn_mvcc=%lu->%lu\n", argv[1], oom,
done, base_mvcc, mvcc_mutexes);
return (2);
}
printf("EXIT status=0\n");
return (0);
}
After both reference counts reach zero, final cleanup must leave no mutex slot allocated for that detail. Repeating this cleanup must not make the public txn mvcc count grow without bound.
The release trigger produced these selected lines. Eight intermediate cycle reports are omitted.
Summary
__txn_reap_si_detailsleaks an MVCC mutex slot during committed snapshot cleanup. After the detail's SIREAD and MVCC buffer references reach zero, the function frees the detail without releasing its MVCC mutex (src/txn/txn_region.c, lines 447-484).The affected sequence begins when
__txn_endretains the committed detail because an MVCC buffer still refers to it. The last MVCC buffer is then removed while a SIREAD marker remains, and later SIREAD cleanup invokes the reaper.Each affected cleanup leaves one mutex slot in use. Repetition reduces the mutex region's available capacity and can make a later valid database operation return
ENOMEM.Environment
Berkeley DB 5.3.34: (August 3, 2026);c4811dc871e313033993e95baa5b6525057c5911release'CFLAGS=-O2 -g'; the selected Actual result lines came from this treedebug--enable-debug --enable-diagnostic 'CFLAGS=-g -O0 -fno-omit-frame-pointer'asan--enable-debug CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O1' LDFLAGS=-fsanitize=address7.0.0-30-generic;x86_64;cc (Ubuntu 15.2.0-16ubuntu1) 15.2.0cc -g -O0 -fno-omit-frame-pointer; each tree's generateddb.h, staticlibdb.a,LDFLAGS, andLIBSDB_BTREE, opened withDB_AUTO_COMMIT | DB_MULTIVERSIONDB_CREATE | DB_INIT_LOCK | DB_INIT_LOG | DB_INIT_MPOOL | DB_INIT_TXN | DB_RECOVER;DB_TXN_SNAPSHOT;DB_LOCK_DEFAULTDB_ENV->set_cachesize()andDB_ENV->mutex_set_max()are not calledSteps to reproduce
The reproduction program uses only public
db.hAPIs. It does not call internal functions or simulate cleanup.In
readmode, each snapshot transaction readsaccounts.dband writesjournal.db. Theno-readcontrol omits only that transaction read, so it creates no SIREAD marker.After each successful snapshot transaction, the program flushes modified pages, reads all account rows, and writes one autocommit entry. It requests a checkpoint every 100 cycles.
repro.c.no-readcontrol.run_mode release no-read env run_mode debug no-read env run_mode asan no-read "env ASAN_OPTIONS=detect_leaks=0"Expected result
tdis the transaction detail. Itssi_reffield counts SIREAD marker references, and itsmvcc_reffield counts MVCC buffer references.RFC 0003 identifies itself as implemented and normative (
rfc/0003-ssi-serializable-snapshot-isolation.md, lines 1-8). Its M4 lifecycle note gives this final cleanup rule (rfc/0003/M4-commit-lifecycle.md, lines 33-40):On the alternate finalization path,
__txn_remove_bufferreturnstd->mvcc_mtxbefore it frees the detail (src/txn/txn_region.c, lines 541-552).After both reference counts reach zero, final cleanup must leave no mutex slot allocated for that detail. Repeating this cleanup must not make the public
txn mvcccount grow without bound.Actual result
The release trigger produced these selected lines. Eight intermediate cycle reports are omitted.
The release control completed all 1000 cycles without
ENOMEM;txn mvccchanged from 10 to 3.Both auxiliary triggers reached
ENOMEMwhile their controls completed;asanemitted no AddressSanitizer diagnostic.Full release log
Full debug log
Full asan log
Analysis
Root cause
__txn_beginmapsDB_TXN_SNAPSHOTto the internalTXN_SNAPSHOT_SAFEstate (src/txn/txn.c, lines 233-252). A multiversion read then selects a SIREAD marker (src/db/db_meta.c, lines 1178-1194). When it grants the marker,__lock_get_internalincrementstd->si_ref(src/lock/lock.c, lines 1212-1229).The first multiversion update calls
__memp_fget, which allocatestd->mvcc_mtx(src/mp/mp_fget.c, lines 244-265). Buffer ownership calls__txn_add_buffer, which incrementstd->mvcc_ref(src/mp/mp_mvcc.c, lines 24-50;src/txn/txn_region.c, lines 493-502). At transaction end,__txn_endsees a nonzeromvcc_ref, puts the detail on the committed-snapshot list, and leaves its mutex allocated (src/txn/txn.c, lines 1822-1871). This is required for the leak: ifmvcc_refwere already zero,__txn_endwould release the mutex before retaining a SIREAD-only detail.If the last buffer leaves first,
__txn_remove_bufferdecrementstd->mvcc_refto zero whiletd->si_refremains positive. It does not finalize the detail on that path (src/txn/txn_region.c, lines 529-545).A checkpoint or a later snapshot transaction can call
__lock_sicleanup(src/txn/txn_chkpt.c, lines 151-157;src/txn/txn.c, lines 264-279). Cleanup removes eligible markers and decrementstd->si_ref(src/lock/lock.c, lines 99-173). After it releases the object locks, it calls__txn_reap_si_details(src/lock/lock.c, lines 196-228).When both reference counts are zero,
__txn_reap_si_detailsunlinks and frees the detail. It does not call__mutex_freefortd->mvcc_mtx(src/txn/txn_region.c, lines 447-484). By contrast,__txn_remove_buffercalls__mutex_freebefore it frees the detail when it owns finalization (src/txn/txn_region.c, lines 541-552).__mutex_free_intreturns a mutex slot to the free list and decreasesst_mutex_inuse(src/mutex/mut_alloc.c, lines 220-262). Because the reaper omits this call, each affected detail leaves its slot in use. Public statistics labelMTX_TXN_MVCCastxn mvcc(src/mutex/mut_stat.c, lines 493-498). When the free list is empty and the shared region cannot extend, the next mutex allocation reportsBDB2034and returns an allocation error (src/mutex/mut_alloc.c, lines 94-132).Impact and scope
The failing
DB->put(entry)cannot complete. The C documentation says that applications "should always treatENOMEMas a fatal error" (docs_src/guides/upgrading/upgrade_4_3_enomem.md, lines 8-12).The affected order requires a
DB_TXN_SNAPSHOTtransaction to commit while an MVCC buffer and a SIREAD marker still refer to its detail. After commit, its last MVCC buffer must be removed while the marker remains, and marker cleanup must occur later.