#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <db.h>
#define DATABASE "ledger.db"
#define NRECORDS 2000
#define PAGE_SIZE 512
#define MAX_RETRIES 20
static int broadcast_calls;
static int targeted_calls;
static const char *
code_name(int ret)
{
if (ret == 0)
return ("DB_SUCCESS");
if (ret == DB_LOCK_DEADLOCK)
return ("DB_LOCK_DEADLOCK");
if (ret == DB_LOCK_NOTGRANTED)
return ("DB_LOCK_NOTGRANTED");
if (ret == DB_SNAPSHOT_CONFLICT)
return ("DB_SNAPSHOT_CONFLICT");
if (ret == DB_SNAPSHOT_UNSAFE)
return ("DB_SNAPSHOT_UNSAFE");
if (ret == DB_NOTFOUND)
return ("DB_NOTFOUND");
return (db_strerror(ret));
}
static int
fail(const char *call, int ret)
{
fprintf(stderr, "%s: %s (%d)\n", call, code_name(ret), ret);
return (ret == 0 ? EINVAL : ret);
}
static int
retryable(int ret)
{
return (ret == DB_LOCK_DEADLOCK || ret == DB_LOCK_NOTGRANTED ||
ret == DB_SNAPSHOT_CONFLICT || ret == DB_SNAPSHOT_UNSAFE);
}
static int
send_message(DB_ENV *env, const DBT *control, const DBT *record,
const DB_LSN *lsn, int eid, u_int32_t flags)
{
(void)env;
(void)control;
(void)record;
(void)lsn;
(void)eid;
(void)flags;
if (eid == DB_EID_BROADCAST) {
broadcast_calls++;
return (0);
}
targeted_calls++;
return (EIO);
}
static int
open_environment(DB_ENV **envp)
{
int ret;
*envp = NULL;
if ((ret = db_env_create(envp, 0)) != 0)
return (fail("db_env_create", ret));
if ((ret = (*envp)->rep_set_transport(
*envp, 1, send_message)) != 0)
return (fail("DB_ENV->rep_set_transport", ret));
if ((ret = (*envp)->open(*envp, ".",
DB_CREATE | DB_INIT_LOCK | DB_INIT_LOG | DB_INIT_MPOOL |
DB_INIT_TXN | DB_INIT_REP, 0600)) != 0)
return (fail("DB_ENV->open", ret));
if ((ret = (*envp)->set_lk_detect(
*envp, DB_LOCK_DEFAULT)) != 0)
return (fail("DB_ENV->set_lk_detect", ret));
if ((ret = (*envp)->rep_start(
*envp, NULL, DB_REP_MASTER)) != 0)
return (fail("DB_ENV->rep_start", ret));
return (0);
}
static int
open_database(DB_ENV *env, DB **dbp, int create)
{
u_int32_t flags;
int ret;
*dbp = NULL;
if ((ret = db_create(dbp, env, 0)) != 0)
return (fail("db_create", ret));
if ((ret = (*dbp)->set_pagesize(*dbp, PAGE_SIZE)) != 0)
return (fail("DB->set_pagesize", ret));
flags = DB_AUTO_COMMIT | DB_MULTIVERSION;
if (create)
flags |= DB_CREATE;
if ((ret = (*dbp)->open(*dbp, NULL, DATABASE, NULL,
DB_BTREE, flags, 0600)) != 0)
return (fail("DB->open", ret));
return (0);
}
static void
make_dbt(DBT *dbt, void *data, size_t size)
{
memset(dbt, 0, sizeof(*dbt));
dbt->data = data;
dbt->size = (u_int32_t)size;
}
static int
put_value(DB *db, DB_TXN *txn, const char *key_text,
const char *value_text)
{
DBT key, value;
make_dbt(&key, (void *)key_text, strlen(key_text));
make_dbt(&value, (void *)value_text, strlen(value_text));
return (db->put(db, txn, &key, &value, 0));
}
static int
get_value(DB *db, DB_TXN *txn, const char *key_text, DBT *value)
{
DBT key;
make_dbt(&key, (void *)key_text, strlen(key_text));
memset(value, 0, sizeof(*value));
return (db->get(db, txn, &key, value, 0));
}
static int
fill_database(DB *db)
{
char key[32], value[32];
int attempt, i, ret;
for (i = 0; i < NRECORDS; i++) {
(void)snprintf(key, sizeof(key), "account%06d", i);
(void)snprintf(value, sizeof(value), "balance%06d", i);
for (attempt = 0; attempt < MAX_RETRIES; attempt++) {
ret = put_value(db, NULL, key, value);
if (ret == 0)
break;
if (!retryable(ret))
return (fail("DB->put (fill)", ret));
}
if (attempt == MAX_RETRIES)
return (fail("DB->put (fill retries)", EBUSY));
}
return (0);
}
static int
run_transaction(DB_ENV *env, DB *db, int with_read)
{
DB_TXN *txn;
DBT value;
int attempt, ret, t_ret;
for (attempt = 0; attempt < MAX_RETRIES; attempt++) {
txn = NULL;
if ((ret = env->txn_begin(
env, NULL, &txn, DB_TXN_SNAPSHOT)) != 0)
return (fail("DB_ENV->txn_begin", ret));
ret = put_value(db, txn, "journal000001", "transfer000001");
if (ret == 0 && with_read)
ret = get_value(db, txn, "account001554", &value);
if (ret != 0) {
t_ret = txn->abort(txn);
if (t_ret != 0)
return (fail("DB_TXN->abort", t_ret));
if (retryable(ret))
continue;
return (fail("transaction operation", ret));
}
ret = txn->commit(txn, 0);
if (ret == 0) {
printf("DB_TXN->commit: %s (%d)\n",
code_name(ret), ret);
return (0);
}
if (!retryable(ret))
return (fail("DB_TXN->commit", ret));
}
return (fail("transaction retries", EBUSY));
}
int
main(int argc, char **argv)
{
DB_ENV *env;
DB *db;
DBT value;
int create, ret, t_ret, with_read;
if (argc != 2 ||
(strcmp(argv[1], "--trigger") != 0 &&
strcmp(argv[1], "--control") != 0)) {
fprintf(stderr, "usage: %s --trigger|--control\n", argv[0]);
return (EXIT_FAILURE);
}
with_read = strcmp(argv[1], "--trigger") == 0;
printf("libdb version: %s\n", db_version(NULL, NULL, NULL));
printf("environment: DB_CREATE|DB_INIT_LOCK|DB_INIT_LOG|DB_INIT_MPOOL|"
"DB_INIT_TXN|DB_INIT_REP\n");
printf("database: DB_BTREE|DB_MULTIVERSION; transaction: "
"DB_TXN_SNAPSHOT; role: DB_REP_MASTER\n");
printf("workload: one write followed by %d read%s\n",
with_read, with_read == 1 ? "" : "s");
env = NULL;
db = NULL;
create = 1;
if ((ret = open_environment(&env)) != 0)
goto out;
if ((ret = open_database(env, &db, create)) != 0)
goto out;
if ((ret = fill_database(db)) != 0)
goto out;
if ((ret = run_transaction(env, db, with_read)) != 0)
goto out;
if ((ret = db->close(db, 0)) != 0) {
db = NULL;
(void)fail("DB->close", ret);
goto out;
}
db = NULL;
printf("DB->close: DB_SUCCESS (0)\n");
if ((ret = env->close(env, 0)) != 0) {
env = NULL;
(void)fail("DB_ENV->close", ret);
goto out;
}
env = NULL;
printf("DB_ENV->close: DB_SUCCESS (0)\n");
if ((ret = open_environment(&env)) != 0)
goto out;
if ((ret = open_database(env, &db, 0)) != 0)
goto out;
if ((ret = get_value(db, NULL, "journal000001", &value)) != 0) {
(void)fail("DB->get after clean close", ret);
goto out;
}
printf("post-close DB->get: %s (%d), key=journal000001 value=%.*s\n",
code_name(ret), ret, (int)value.size, (char *)value.data);
if (value.size != strlen("transfer000001") ||
memcmp(value.data, "transfer000001", value.size) != 0) {
ret = fail("post-close value mismatch", EINVAL);
goto out;
}
if (targeted_calls != 0) {
ret = fail("unexpected transport callback", EIO);
goto out;
}
printf("transport callbacks: %d broadcast, %d targeted\n",
broadcast_calls, targeted_calls);
out:
if (db != NULL) {
t_ret = db->close(db, 0);
if (ret == 0 && t_ret != 0)
ret = t_ret;
}
if (env != NULL) {
t_ret = env->close(env, 0);
if (ret == 0 && t_ret != 0)
ret = t_ret;
}
return (ret == 0 ? EXIT_SUCCESS : EXIT_FAILURE);
}
Summary
libdb 5.3.34 can violate transaction isolation during replication apply on a client. The
master-side trigger is a logged top-level
DB_TXN_SNAPSHOTupdate transaction on a database openedwith
DB_MULTIVERSION. The transaction retains both write locks andDB_LOCK_SIREADlocks atcommit.
__lock_vecallocates the temporary descriptor array used to build the replication commit locklist from
nwrites. This count excludes SIREAD locks, butDB_LOCK_PUT_READpopulates a descriptorfor each retained lock of either type. The extra descriptor is written past the allocation. If
execution continues after this write,
__lock_fix_listserializes only the firstnwritesdescriptors. When a later-acquired SIREAD lock for another object is visited first, its object can
displace the write-lock object for a modified page.
The replication apply path on the client uses this list to acquire write locks. The built-in redo
path for the modified page does not reacquire the omitted page lock. Apply can therefore change the
page despite a read lock retained by a separate client transaction at the default isolation level.
This can make a repeated lookup observe a record that was absent earlier. The same count mismatch
also causes an out-of-bounds write on the master. The client isolation consequence follows from
source analysis and has not been directly observed.
Environment
Berkeley DB 5.3.34: (August 3, 2026), commitc4811dc871e313033993e95baa5b6525057c5911dist/configure 'CFLAGS=-O2 -g'dist/configure --enable-debug --enable-diagnostic 'CFLAGS=-g -O0 -fno-omit-frame-pointer'dist/configure --enable-debug 'CFLAGS=-fsanitize=address -fno-omit-frame-pointer -g -O1' LDFLAGS=-fsanitize=addressDB_MULTIVERSIONDB_CREATE|DB_INIT_LOCK|DB_INIT_LOG|DB_INIT_MPOOL|DB_INIT_TXN|DB_INIT_REPDB_TXN_SNAPSHOT, one write followed by one readSteps to reproduce
Start in an empty directory. The reproducer uses public
db.hcalls to start a single-site master,write one record, read another in the same transaction, and commit. The
--controloption removesonly the read. In the tested tree, the fill phase and small page size place the two keys on
different B-tree pages. This separation is required to create a distinct SIREAD lock and makes the
displaced write-lock object visible in the printed page numbers. The reproducer measures the
incomplete commit lock list and the heap write on the master; it does not run a replication client.
Check out the tested source, then configure and build release, diagnostic, and AddressSanitizer
trees.
Save the reproducer below as
repro.cin the source directory.Complete
repro.cCompile against the release tree, then run the trigger and control in separate directories.
Extract the write record and the matching commit record by transaction ID. This avoids selecting
one of the unrelated commit records produced while the database is filled.
Log extraction command
AddressSanitizer tree omits
--enable-diagnostic, so theDB_ASSERTbounds check does not stopexecution before the out-of-bounds write.
Instrumented build and run commands
Expected result
Replication apply must not modify a page while a client transaction retains a conflicting read
lock. Replication clients permit concurrent reads, and transactional page locks remain held until
the transaction commits
(docs_src/guides/programmer_reference/rep.md#L116-L120,
docs_src/guides/programmer_reference/lock_am_conv.md#L16-L20).
__rep_process_txntherefore acquires the objects in the commit lock list as write locks before itapplies any log record
(src/rep/rep_record.c#L1572-L1658).
The list must contain every write-lock object retained by the master transaction. An object from a
SIREAD lock must not displace a write-lock object. Building the list must remain within its
allocation.
Actual result
The release trigger exits 0 after commit:
The extraction command matches the write and commit records by transaction ID:
The commit lock list therefore omits the page modified by the transaction. Removing only the read
restores that page as the list's only page:
AddressSanitizer trigger (addresses and source-path prefix normalized; unrelated frames omitted):
Diagnostic trigger (source-path prefix normalized):
Both instrumented triggers exit with status 134. Their no-read controls exit 0 without an
assertion or an AddressSanitizer report.
The reproducer starts no replication client. These results directly prove the incomplete commit
lock list and the out-of-bounds heap write. They do not directly observe a client isolation
violation.
Analysis
Root cause
nwritescounts write locks, whileDB_LOCK_PUT_READpopulates descriptors for retained write andSIREAD locks. This mismatch both under-sizes the temporary descriptor array and truncates the
serialized commit lock list.
DB_TXN_SNAPSHOTsets the internalTXN_SNAPSHOTandTXN_SNAPSHOT_SAFEflags. On aDB_MULTIVERSIONhandle,__db_lgetconverts the page-read request toDB_LOCK_SIREAD. Newlygranted locks enter the head of the locker's
heldbylist. A SIREAD lock acquired after a writelock is therefore visited first
(src/txn/txn.c#L233-L252,
src/db/db_meta.c#L1178-L1194,
src/lock/lock.c#L1166-L1184).
__txn_commitasks__lock_vectobuild the commit lock list while both lock types remain on
heldby.nwritescounts onlyIS_WRITELOCKmodes, so the array has one descriptor slot per write lock. It has no additionalslots for retained SIREAD locks.
DB_LOCK_PUT_READreleases ordinary read locks but retains both SIREAD and write locks. Thetraversal can therefore populate more descriptors than the array can hold.
__lock_sicommitdoes not detach the SIREAD locks until the later
__txn_endpath, immediately beforeDB_LOCK_PUT_ALL(src/txn/txn.c#L847-L882,
src/dbinc/lock.h#L52-L58,
src/lock/lock.c#L1448-L1455,
src/lock/lock.c#L386-L460,
src/txn/txn.c#L1769-L1787).
nwritesdescriptors starts outside the allocation.Each retained SIREAD lock increases the number of populated descriptors without increasing the
nwrites-sized allocation. The number of out-of-bounds descriptors therefore grows with theretained SIREAD set.
DB_ASSERTis the only bounds check, and it is inactive withoutDIAGNOSTIC. AddressSanitizerdirectly observes the 8-byte
np->datawrite. The source then assignsnp->sizethrough thesame out-of-range descriptor
(src/lock/lock.c#L453-L460,
src/dbinc/debug.h#L35-L40).
__lock_fix_list. It receivesnwritesinstead of the number of descriptors actually populated. It therefore serializes only that
prefix. Because a later-acquired SIREAD lock is visited first, its object can occupy the prefix
and displace a write-lock object. In the reproduced transaction,
__txn_regop_logthereforerecords the read page and omits the written page
(src/lock/lock.c#L465-L468,
src/lock/lock_list.c#L119-L140,
src/txn/txn.c#L875-L882).
__rep_process_txntakes write locks only for the serialized objects. It thencollects the transaction's log records independently and applies them with
DB_TXN_APPLY. Theaffected
__db_addrem_recoverpath uses a recovery cursor and does not acquire the omittedlogical page lock. Redo can therefore modify that page without encountering a read lock held by
a separate client transaction
(src/rep/rep_record.c#L1572-L1658,
src/dbinc/db_am.h#L87-L107,
src/db/db_meta.c#L1166-L1176,
src/db/db_rec.c#L54-L124).
Impact and scope
The primary impact is that replication apply can violate transaction isolation on a client if the
master sends a commit log record containing the incomplete commit lock list. A separate read-only
client transaction at the default isolation level can search the omitted page and retain its read
lock. Because apply does not request the conflicting page lock, it can change the page without
resolving that conflict. A later lookup for the same key in the same client transaction can find a
record that the earlier lookup did not find. This violates B-tree's documented degree 3 isolation
guarantee
(docs_src/guides/programmer_reference/am_misc_stability.md#L8-L14,
docs_src/guides/programmer_reference/lock_am_conv.md#L16-L20).
The secondary impact is memory corruption on the master. AddressSanitizer directly observes an
out-of-bounds heap write during list construction. The tested release run continues far enough to
record the incomplete commit lock list, but the unchecked write can instead crash or corrupt
process memory.
The commit lock list does not select the log records or pages used for redo. The client still
follows the transaction's log chain, so the current evidence does not establish a dropped write,
persistent database corruption, data loss, or replica divergence.
__txn_commitbuilds the commitlock list only for a logged top-level transaction on a replication master. Non-replication
environments do not enter this branch
(src/txn/txn.c#L847-L872).
Observing the isolation violation also requires a concurrent client transaction to retain a read
lock on an omitted object.