Volume III. Internal Mechanisms
Level: for those who understand architecture; a bridge between "how to use" and "how to configure and debug". Goal of the volume: you explain libmdbx's behavior through its internals: B+tree and mmap, MVCC, commit pipeline, GC, geometry, nested transactions, lock file, and recovery.
Chapter 13. Storage Architecture: B+tree and mmap
13.1. B+tree: Branches, Leaves, Overflow
libmdbx stores data in a B+tree — a balanced tree where all values live in leaves, while internal nodes (branch) contain only separator keys and pointers to child pages.
[branch: M]
/ \
[branch: B] [branch: V]
/ \ / \
[a..b] [c..m] [n..r] [s..z] <- leaves: key→value pairs
Properties:
- search — descent from root to leaf, O(log N);
- all leaves at the same level are linked — range traversal goes through neighbors;
- on leaf overflow — split (halving), recursively upward, including a new root;
- on emptying below the threshold — rebalance/merge with a neighbor (threshold
merge_threshold).
Values that do not fit in a leaf (larger than roughly half a page) go to overflow pages: a
contiguous run of P_LARGE pages; the leaf keeps a pointer {pgno, npages}.
13.2. Why B+tree Rather than B-tree or LSM
- B-tree — data in both leaves and branches: a search may stop at any level, but traversal requires moving up/down. A B+tree with data only in leaves gives a predictable descent and linear traversal.
- LSM (LevelDB/RocksDB) is optimized for writes (append-only + background compaction), but reads suffer from multi-level lookups. A B+tree gives honest O(log N) for both reads and writes without background processes.
13.3. Page — the Unit of Storage and I/O
The page size is 256…65536 bytes (4096 by default), chosen when the database is created and then fixed. Types:
| Type | Purpose |
|---|---|
| branch | Internal node: separators and pointers |
| leaf | Leaf: key→value pairs |
| large / overflow | Contiguous run of large values |
| meta | One of the three meta pages (database state snapshot) |
| dupfix / subpage | Dense packing of multivalues |
Every page carries the transaction label (txnid) of the transaction that created its current
version — this is the basis of CoW and MVCC.
A user-supplied canary (MDBX_canary, 32 bytes — four uint64_t: x, y, z, v; the v
field is always set to the transaction number) can be associated with the database:
mdbx_canary_put(txn, &canary) writes it into the meta, mdbx_canary_get(txn, &canary) reads it.
Updated values become visible to other processes only after the commit. This is not table data but an
arbitrary application marker (e.g., a schema version) that survives database opens and is available
even when mounted read-only.
13.4. mmap: Virtual Memory as a Window into the File
The file is mapped into the address space in its entirety. Reading is a pointer dereference; the kernel (page cache) pulls in the page. There is no separate buffer cache in the library.
Consequences:
- copy-free reads (you get an address straight into mmap);
- the OS page cache manages residency;
- database size is limited by the address space (on 32-bit — practically ~1–3 GB);
- when the database ≫ RAM, PTE overhead in the kernel grows.
madvise(MADV_NOHUGEPAGE) (opting out of THP) is applied to the mmap region — huge pages interfere
with accurate residency estimation.
Fragment from examples/c++/18-tree-height.c++ — tree
estimation via mdbx_env_stat_ex(): height, leaf/branch/overflow pages and the number of entries;
a value larger than the page size creates overflow pages:
{
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (int i = 0; i < 5000; ++i)
txn.insert(table, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
// A value larger than the page size (4096) goes to overflow pages.
const std::string big(64 * 1024, 'x');
txn.insert(table, mdbx::slice("big"), mdbx::slice(big));
txn.commit();
}
const auto stat = env.get_stat(); // mdbx_env_stat_ex()
std::cout << "stat: depth=" << stat.ms_depth << " leaf=" << stat.ms_leaf_pages
<< " branch=" << stat.ms_branch_pages << " overflow=" << stat.ms_overflow_pages
<< " entries=" << stat.ms_entries << "\n";
Full code: 18-tree-height.c++ · C version.
13.5. The Reachability Invariant
Every non-meta page up to the first_unallocated pointer is reachable exactly once: either it
belongs to some tree (an ordinary, the main, or the GC table), or it is listed in a GC record.
Integrity auditing and the database verification tool (mdbx_chk) are built on this invariant.
Examples for this chapter:
examples/c++/18-tree-height.c++· C version.
13.6. Summary of Chapter 13
- B+tree: data in leaves, branches — separators; split/merge maintains the balance.
- Large values — overflow runs of pages.
- mmap instead of a buffer cache: copy-free reads, the cost — address space/PTE.
- The reachability invariant — the foundation of integrity.
13.7. Exercises
- Explain why
mdbx_geton 64-bit is practically independent of the database size. - What changes for a reader if the tree grows from height 3 to height 5?
13.8. Chapter 13 checklist
- [ ] I can explain why all values live in the leaves of the B+tree while branches hold only separators, and what this gives for search and range traversal.
- [ ] I know where a value that does not fit in a leaf goes (an overflow run) and what the leaf keeps in its place.
- [ ] I understand why libmdbx does not maintain its own buffer cache, and its consequences (copy-free reads, residency managed by the kernel, PTE growth when the DB ≫ RAM).
- [ ] I can name the address-space limit on database size and its consequence on 32-bit platforms.
- [ ] I know the purpose of the canary (
MDBX_canary) and that thevfield always holds the transaction number. - [ ] I can state the reachability invariant and explain what is built on it (integrity auditing,
mdbx_chk).
Chapter 14. MVCC and Snapshots
14.1. Page Versioning, Reader Snapshot
Each page is marked with the number of the transaction that created its current version. On start, a reader fixes a snapshot — the state of the database as of the moment the transaction began. Everything it sees afterwards is a consistent picture of that moment, regardless of subsequent commits.
14.2. Trio of Meta Pages and Two-Phase Commit
The database always has three meta pages (NUM_METAS = 3). This allows keeping two valid
snapshots ("fresh" and "steady" — with data guaranteed to be flushed to disk) and one trailing
slot for overwriting.
The meta update is two-phase: first one half is invalidated (writing the transaction number while zeroing the second), then the fields are filled in and the second half is validated. A reader sees either the old intact meta or the new one — never a "half-updated" one.
14.3. Finite State Machine of the Trio
The state of each meta is encoded independently; the full transition table covers 216 (6³) combinations and is exhaustively checked by internal validation. This protects against "impossible" states after failures.
14.4. Reader Lock Table (RLT)
A reader registers its snapshot number in the reader lock table (in the lock file). The slot contains: the snapshot number, the owner's pid/tid, and fields describing how many pages the reader "pins".
The oldest active snapshot — the detent — is computed by scanning the RLT (lock-free, with caching). The detent is the watershed for page reuse (chapter 16).
14.5. Wait-free Reads and safe64
On read operations the reader takes no locks — it simply walks the mmap. The only "writing" action is registering a slot when the transaction starts.
64-bit fields (transaction numbers in the RLT and in the meta) are read through a special atomic protocol, safe64: values are written "low word first, then high word", and reads are retried when a "torn" value is detected. This is protection against torn 64-bit reads on weak memory models.
14.6. The Writer: Global Mutex, Front Txnid, Dirty List
At any moment there is one write transaction (the global writer mutex in the lock file). On
start, the writer gets a preliminary number (front txnid = txnid + 1): new and CoW-copied
pages are marked with it and are invisible to readers until commit. Modified pages accumulate in
the dirty list (DPL).
Fragment from examples/c++/19-readers-lag.c++ — a reader
holds a snapshot; the writer's commits increase its "lag", and mdbx_reader_list shows reader
slots with txnid and lag:
// Commits after the snapshot was created increase the reader's lag.
for (int i = 3; i < 8; ++i) {
auto txn = env.start_write();
auto wtable = txn.open_map(nullptr);
txn.insert(wtable, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
txn.commit();
}
// Enumerate the readers: a reader slot has lag > 0.
struct visitor {
int operator()(const mdbx::env::reader_info &ri, int) {
std::cout << "reader slot: pid=" << ri.pid << " tid=" << ri.thread << " txnid=" << ri.transaction_id
<< " lag=" << ri.transaction_lag << "\n";
return mdbx::continue_loop;
}
} v;
env.enumerate_readers(v);
Full code: 19-readers-lag.c++.
Examples for this chapter:
examples/c++/19-readers-lag.c++.
14.7. Summary of Chapter 14
- MVCC: a page is marked with a txnid; a reader sees its own snapshot.
- The meta trio with a two-phase update and a finite state machine of 216 states.
- RLT and detent — the basis of safe reuse.
- Wait-free reads; safe64 against torn reads.
- One writer; front txnid hides uncommitted changes.
14.8. Exercises
- Why exactly three meta pages, and not two?
- What happens if the writer crashes in the middle of a two-phase meta update?
14.9. Chapter 14 checklist
- [ ] I can explain why a reader sees a consistent picture regardless of subsequent commits (the snapshot + the
txnidlabel on pages). - [ ] I know why exactly three meta pages are needed and which two valid snapshots they keep.
- [ ] I can describe the two-phase meta update and explain why a reader never sees a "half-updated" meta.
- [ ] I understand how the detent is computed by scanning the RLT and why it is the watershed for page reuse.
- [ ] I know that reads are wait-free and take no locks, and why the
safe64protocol is needed. - [ ] I can explain why the writer's uncommitted pages are invisible to readers (
front txnid, the dirty list).
Chapter 15. Copy-on-Write and the Commit Pipeline
15.1. Page Lifecycle on Write
The page state is determined by comparing its label mp->txnid with the transaction number:
| State | Condition | Meaning |
|---|---|---|
| frozen | txnid < txn->txnid |
Old version, visible to readers; cannot be changed — only copied |
| spilled | txnid == txn->txnid |
Already written to disk by the current writer |
| shadowed | txnid > txn->txnid |
A newer version exists; this one is obsolete |
| modifiable | txnid == front txnid |
New version of the current writer; can be modified in place |
The touch mechanism (CoW): if a page is "frozen" — we allocate a new one (from GC or the file
tail), copy the contents, mark it with front txnid, put it in the dirty list; the old one is
released. The parent that pointed to the old page also becomes "modifiable" — CoW propagates
upward to the root. Cursors that referred to the old version are moved to the new one.
15.2. Dirty Page List (DPL)
Within one transaction a page enters the dirty list once, no matter how many times it is modified — this is the key factor holding back WAF (write amplification factor, the ratio of bytes written to bytes of useful data; detailed in Volume IV, chapter 21).
15.3. Spill
If the volume of dirty pages exceeds the limit (dp_limit, default ≈ 1/42 of RAM), spill kicks
in: some pages are written to disk ahead of time. Pages referenced by cursors are not subject to
spill; the selection order is governed by divisor options (minimum/maximum share).
Nuance: if a spilled page is then modified again — it will be written once more (additional amplification). This is a "memory vs WAF" trade-off.
15.4. Loose Pages and Refund
- Loose pages — a small cache of dirty pages freed in the current transaction (limit
loose_limit, default 64) for fast reuse without consulting the GC. - Refund — returning freed tail pages to unallocated space (rather than to the GC); reduces WAF and enables online shrinkage.
15.5. The Commit Pipeline
- Close/verify cursors.
- GC processing: freed pages are placed into GC-tree records.
- Refund: tail frees are returned to unallocated space.
- Spill: on dirty-list overflow — early write-out.
- Audit (in enhanced checking modes): reconciliation of page sums.
- Two-phase meta update + synchronization according to the durability mode.
- Release the writer lock, update the detent cache, clean out dead readers.
Each stage is measurable via mdbx_txn_commit_ex() → MDBX_commit_latency
(preparation/gc_wallclock/audit/write/sync/ending/whole).
Fragment from examples/c++/20-commit-latency.c++ —
collecting MDBX_commit_latency by stage via commit_get_latency() for a series of commits and
averaging:
for (int i = 0; i < commits; ++i) {
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
txn.insert(table, mdbx::slice("k" + std::to_string(i)), mdbx::slice("v"));
const auto lat = txn.commit_get_latency(); // returns MDBX_commit_latency
prep += lat.preparation;
gc += lat.gc_wallclock;
audit += lat.audit;
write += lat.write;
sync += lat.sync;
ending += lat.ending;
whole += lat.whole;
}
std::cout << "avg latency (us): preparation=" << (prep / commits) << " gc=" << (gc / commits)
<< " audit=" << (audit / commits) << " write=" << (write / commits) << " sync=" << (sync / commits)
<< " ending=" << (ending / commits) << " whole=" << (whole / commits) << "\n";
Full code: 20-commit-latency.c++.
Examples for this chapter:
examples/c++/20-commit-latency.c++.
15.6. Summary of Chapter 15
- CoW: frozen → copy → modifiable; the path to the root is rewritten entirely.
- DPL deduplicates pages within a transaction — the main lever of WAF.
- Spill — a memory/WAF trade-off; loose and refund reduce writes.
- Commit pipeline: GC → refund → spill → audit → meta → sync → unlock.
15.7. Exercises
- Why does changing a single byte in a leaf rewrite the whole path to the root?
- How can
mdbx_txn_commit_exhelp see what "got stuck" — spill or sync?
15.8. Chapter 15 checklist
- [ ] Given a page's
mp->txnidlabel, I can determine its state (frozen/spilled/shadowed/modifiable) and what may be done with it. - [ ] I can explain why CoW rewrites the path to the root and how cursors are moved to new page versions.
- [ ] I know that a page enters the dirty list once per transaction, and why this holds back WAF.
- [ ] I can describe when spill kicks in (
dp_limit≈ 1/42 of RAM), which pages it never touches, and why a re-modified spilled page is bad. - [ ] I distinguish loose pages (fast reuse,
loose_limit= 64) from refund (returning tail pages to unallocated space). - [ ] I can reproduce the order of the commit pipeline and name the stages visible in
MDBX_commit_latency.
Chapter 16. GC — the Garbage Collector Inside the Database
16.1. Why There Is No Free-List
libmdbx has no classic free-page list. Freed pages are accounted for persistently, inside the data file, in a special GC tree (a separate table whose root is in the meta).
16.2. Format of a GC Record
- Record key — the number of the transaction that freed the pages.
- Value — a list of page numbers (compressed, as ranges).
- One record is physically limited (~1000 numbers at a 4 KB page).
The GC format is frozen and has not changed since v11.3 — an important compatibility guarantee.
16.3. Detent: Safety of Reuse
Pages from a GC record with key T can be reused only when T ≤ detent — the oldest active
snapshot. Above the detent, records stay "frozen" until the corresponding reader finishes.
The detent is computed by scanning the RLT (lock-free, with caching of the result).
16.4. FIFO vs LIFO
| Policy | Mechanism | Effect |
|---|---|---|
| FIFO (default) | Pages are taken from the oldest suitable record | The list lives longer, cools down on disk |
LIFO (MDBX_LIFORECLAIM) |
The newest frees come first | Minimally short circulation cycle; pages are still "warm"; on systems with a write-back cache — several-fold write performance growth |
Nuance:
MDBX_LIFORECLAIMgives almost no effect withSAFE_NOSYNC/UTTERLY_NOSYNC— there the cycle length is determined by the frequency ofenv_sync(). Vector (SIMD) kernels are used to find dense sequences: SSE2/AVX2/AVX512/NEON.
16.5. BigFoot
One GC record holds ~1000 page numbers (~4 MB at 4 KB). If a transaction frees more (for example, when replacing a huge value), the records form a chain over consecutive txnids — the BigFoot mode. Readers must be newer than the whole chain. The price — more records, a taller GC tree.
BigFoot is enabled by default in 64-bit builds (MDBX_ENABLE_BIGFOOT); it is fully compatible with
the database format.
16.6. Recursiveness of GC Updates
The GC is also a CoW tree: modifying the GC requires allocating pages, which affects the list of pages that goes into the GC. That is why an operational reserve of free pages is formed before an update: shortage → irrational database growth, surplus → overhead.
16.7. rp_augment_limit and gc_time_limit
Searching for dense sequences (for large values) can be expensive under fragmentation. Two cost limiters:
rp_augment_limit— the limit of list accumulation during search; exceeding it → it is cheaper to append new pages to the file tail;gc_time_limit— a time limit (in 1/65536 seconds) on the search during a writing transaction.
Rule: decrease
rp_augment_limitonly together withgc_time_limit; too small anrp_augmentdoes not cure GC growth but aggravates it for the sake of long-record insert speed.
Fragment from examples/c++/22-gc-limits.c++ — setting and
reading GC limits: rp_augment_limit (page reserve for reclamation) and gc_time_limit (1/65536
seconds per sequence search):
env.set_extra_option(opt::rp_augment_limit, 128 * 1024);
// gc_time_limit is specified in 1/65536 fractions of a second (16.16 fixed point):
// 2500 ≈ 38 ms for searching page sequences in the GC within a transaction.
env.set_extra_option(opt::gc_time_limit, 2500);
// A load with long values: makes the GC work harder.
{
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (int i = 0; i < 2000; ++i) {
const auto key = "k" + std::to_string(i);
const std::string value(4096 + (i % 7) * 512, 'v');
txn.insert(table, mdbx::slice(key), mdbx::slice(value));
}
txn.commit();
auto del = env.start_write();
auto dtable = del.open_map(nullptr);
for (int i = 0; i < 2000; i += 2)
del.erase(dtable, mdbx::slice("k" + std::to_string(i)));
del.commit();
}
std::cout << "rp_augment_limit = " << env.extra_option(opt::rp_augment_limit) << "\n";
std::cout << "gc_time_limit = " << env.extra_option(opt::gc_time_limit) << "\n";
Full code: 22-gc-limits.c++.
16.8. Early GC Cleanup (2025) and Non-Deferred Cleanup (devel)
- Early cleanup (2025): recycled GC records start to be processed earlier.
- Non-deferred cleanup (devel, 0.14.x): recycled GC records are removed immediately after reading rather than at commit; overhead becomes proportional to the volume of operations. This also opened the path to explicit copy-free defragmentation (0.14.2+).
Examples for this chapter:
examples/c++/21-gc-observe.c++;examples/c++/22-gc-limits.c++.
16.9. Summary of Chapter 16
- GC — a tree of "txnid → page list" records inside the file (not a free-list).
- Reuse only newer than the detent.
- FIFO by default;
MDBX_LIFORECLAIM— LIFO. - BigFoot — chains for large frees.
rp_augment_limit/gc_time_limit— control over search cost.
16.10. Exercises
- Explain why a long-lived reader "freezes" reclamation, using the concept of the detent.
- When does LIFO give a win, and when not?
16.11. Chapter 16 checklist
- [ ] I can explain why libmdbx has no free-list and where freed pages are accounted for persistently.
- [ ] I describe the format of a GC record: key — the freeing txnid, value — a list of pages as ranges; the format is frozen since v11.3.
- [ ] I can explain the reuse condition via the detent and what happens to GC records above the detent.
- [ ] I know the difference between FIFO and LIFO and when
MDBX_LIFORECLAIMgives a win and when it does not. - [ ] I understand the BigFoot mechanism: chains of records over consecutive txnids and the requirement that readers be newer than the whole chain.
- [ ] I can explain why GC updates are recursive (the GC is itself a CoW tree) and why an operational reserve of pages is needed.
- [ ] I know the purpose of
rp_augment_limitandgc_time_limitand the rule of decreasing them together.
Chapter 17. Growth, Shrinkage, and Defragmentation of the Database
17.1. Why the Database Grows
- Long-lived readers freeze the detent → GC does not reuse pages → the writer takes new ones from the file tail. Even a put→del cycle "swells".
MDBX_SAFE_NOSYNC— a permanent quasi-long-lived reader (the last steady commit).- Fragmentation and a shortage of contiguous sequences for large values.
- Forgotten readers (thread crash, wrong TLS).
17.2. Geometry: growth_step, upper, shrink_threshold
The file grows automatically, in multiples of growth_step, up to upper. Truncation is possible
only down to the last used page; a single occupied page near the end can block shrinkage
indefinitely. Shrinkage requires hysteresis (shrink step > growth step), otherwise the file
"breathes".
17.3. MDBX_MAP_FULL and Resolution Mechanisms
When the GC is empty/frozen and the file has hit upper, the following fire, when possible: a new
steady point (detent shift) → the HSR callback (HSR — Handle-Slow-Readers, the mechanism for
evicting stuck readers; detailed in Volume V, ch. 29) → eviction of parked readers → and only then
MDBX_MAP_FULL.
Fragment from examples/c++/23-map-full.c++ — reproduction of
MDBX_MAP_FULL (in the example, a rigid geometry limits the file to 512 KB) and resolution by
increasing size_upper:
size_t inserted = 0;
try {
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
for (;;) {
const auto key = "k" + std::to_string(inserted);
const std::string value(1024, 'x');
txn.insert(table, mdbx::slice(key), mdbx::slice(value));
++inserted;
}
} catch (const mdbx::db_full &ex) {
std::cout << "MDBX_MAP_FULL after " << inserted << " inserts\n";
}
// Increase the upper bound — and continue from the same place.
mdbx::env::geometry bigger;
bigger.size_upper = 8 * 1024 * 1024;
try {
env.set_geometry(bigger);
} catch (const mdbx::db_unable_extend &) {
// On 32-bit Windows the address range right after the mapping may be
// occupied: reopen the same database with the enlarged geometry.
mdbx::env::geometry reopened = geo;
reopened.size_upper = bigger.size_upper;
env.close();
env = mdbx::env_managed(path,
mdbx::env_managed::create_parameters().set_geometry(reopened),
mdbx::env::operate_parameters());
}
Raising upper on a live database requires remapping: on most platforms this happens in place, but on
32-bit Windows there may be no free address range next to the current mapping — then
MDBX_UNABLE_EXTEND_MAPSIZE is returned, and the portable path is to close and reopen the database
with the enlarged geometry (the new mapping is created with the required upper right away).
Full code: 23-map-full.c++.
17.4. Auto-Compaction (Implicit Shrink)
On every commit, freed tail pages are returned to unallocated space (refund). When the free tail
exceeds shrink_threshold (+ a small reserve), the commit additionally drops the "excess" range
via madvise(MADV_DONTNEED/REMOVE) and truncates the file.
Limitations: it does not work while long-lived readers hold the tail or while several processes use
the file; with SAFE_NOSYNC it is limited by the frequency of steady points.
17.5. Explicit Defragmentation
mdbx_env_defrag() / the mdbx_defrag utility (0.14.2+) — careful rearrangement of pages toward
the beginning of the file in cycles; each cycle is a separate committed transaction. Parameters:
defrag_atleast/defrag_enough (minimum/sufficient reduction), time_atleast/time_limit
(1/65536 s), acceptable_backlash, preferred_batch. The result code is
MDBX_defrag_enough_threshold.
Guaranteed file reduction — also mdbx_copy -c (compacted copy) or dump+load (-a).
17.6. Estimating Capacity
Dividing the file size by the item size overestimates the real capacity: page granularity, the path to the root, GC, and hysteresis add overhead. Estimate for the worst case of reuse freezing.
Examples for this chapter:
examples/c++/23-map-full.c++;examples/c++/24-defrag.c++.
17.7. Summary of Chapter 17
- Growth = pages not being reused (readers/SAFE_NOSYNC/fragmentation).
upper— the hard limit; MAP_FULL is resolved by steady/HSR/eviction.- Implicit shrink — free compaction as part of commits.
- Explicit defragmentation in cycles; guaranteed shrinkage — copy -c / dump+load.
17.8. Exercises
- Why is "10 GB in the database, so 10 GB of data" an estimation error?
- Describe a scenario where auto-compaction will not work.
17.9. Chapter 17 checklist
- [ ] I name the causes of database growth: long-lived readers,
MDBX_SAFE_NOSYNC, fragmentation, forgotten readers. - [ ] I understand why truncation is possible only down to the last used page and why shrink-step hysteresis is needed.
- [ ] I can reproduce the chain that resolves
MDBX_MAP_FULL: steady point → HSR callback → eviction of parked readers → the error. - [ ] I know the limitations of auto-compaction (implicit shrink) and when it will not work.
- [ ] I can describe explicit cycle-based defragmentation and the guaranteed file-reduction paths (
mdbx_copy -c, dump+load). - [ ] I can explain why dividing the file size by the item size overestimates the real capacity.
Chapter 18. Nested Transactions
18.1. Why They Are Needed
A nested transaction is a child write-transaction inside a parent one, allowing changes to be grouped with the ability to partially roll back.
18.2. Implementation
A child transaction works on the same transaction number and front txnid. It inherits from the parent: the page space and the dirty list (with a shadow of the parent's), retired lists (lists of pages already freed but still held back from reuse — see 19.4), GC buffers, table descriptors, and cursors (via the mechanism of "paired" cursors).
18.3. Commit (join) vs Abort (undo)
- Commit: the child's dirty pages are merged into the parent's list; GC counters are transferred. If the child is "clean" — a fast path without merging.
- Abort: all changes are discarded; retired pages are "un-retired"; handles and cursors are restored.
Fragment from examples/c++/25-nested-txn.c++ — a nested
transaction: start_nested() + abort() rolls back its changes (undo); the symmetric commit()
merges them into the parent (join):
// A nested transaction with a rollback (undo).
{
auto nested = txn.start_nested();
auto ntable = nested.open_map(nullptr);
nested.insert(ntable, mdbx::slice("k2"), mdbx::slice("v2"));
nested.abort(); // roll back the nested one
}
std::cout << "parent sees k1 after nested abort: " << txn.get(table, mdbx::slice("k1")).as_string() << "\n";
try {
(void)txn.get(table, mdbx::slice("k2"));
std::cerr << "FAIL: k2 survived nested abort\n";
return EXIT_FAILURE;
} catch (const mdbx::not_found &) {
std::cout << "k2 rolled back by nested abort: absent\n";
}
Full code: 25-nested-txn.c++.
18.4. Fate of Tables
- A table created in a nested transaction: stays on commit, disappears on abort.
- A table dropped in a nested transaction: is deleted on commit, "comes back to life" on abort.
18.5. Anti-Pattern: a Nested Transaction in Every Function
Calling mdbx_txn_begin(parent, ...) in every small function is an anti-pattern: the meaning of
grouping is lost and overhead grows. Nest deliberately, along the boundaries of logical operations.
18.6. Workaround for Lack of Space
Nested transactions are a known workaround for lack-of-space scenarios (you can partially roll back without losing previous changes).
Nuance: nested transactions do not combine with
MDBX_WRITEMAP.Examples for this chapter:
examples/c++/25-nested-txn.c++.
18.7. Summary of Chapter 18
- A nested transaction = a child write-transaction with partial rollback.
- Commit — merging into the parent; Abort — full rollback.
- The fate of created/dropped tables follows the CoW rules.
- Do not nest "for every function"; do not combine with WRITEMAP.
18.8. Exercises
- Write an example of "a group of updates with a rollback of the last step" via a nested transaction.
- Check the fate of a table created and dropped in a nested transaction upon abort.
18.9. Chapter 18 checklist
- [ ] I can explain that a child transaction works on the same transaction number, and what it inherits from the parent.
- [ ] I distinguish commit (join): merging the child's dirty pages into the parent, from abort (undo): full rollback and "un-retiring" pages.
- [ ] I know the fate of tables created and dropped in a nested transaction, on commit and on abort.
- [ ] I can demonstrate a partial rollback via
start_nested()+abort(). - [ ] I can justify why a nested transaction "in every function" is an anti-pattern.
- [ ] I know that nested transactions are a workaround for lack of space, and that they do not combine with
MDBX_WRITEMAP.
Chapter 19. The Lock File and Interprocess Synchronization
19.1. Why a Separate File
Next to the data file lives the lock file (.lck): the global writer mutex, the reader table
(RLT), statistics, caches of the oldest snapshot. It is needed for coordination between
processes.
Fragment from examples/c++/26-two-processes.c++ —
interprocess access: a writer process commits data, a reader process (a child via fork()) opens
the same database and reads it:
// Writer: the parent process commits data and closes the environment.
{
auto env = example::env_open(path);
auto txn = env.start_write();
auto table = txn.open_map(nullptr);
txn.insert(table, mdbx::slice("k"), mdbx::slice("v1"));
txn.commit();
std::cout << "parent wrote: k=v1" << std::endl;
}
const pid_t pid = fork();
if (pid < 0) {
std::cerr << "fork failed\n";
return EXIT_FAILURE;
}
if (pid == 0) {
// Reader: the child opens the same database and reads the data.
auto env = example::env_open(path);
auto txn = env.start_read();
auto table = txn.open_map(nullptr);
std::cout << "child reads: " << txn.get(table, mdbx::slice("k")).as_string() << std::endl;
txn.abort();
_exit(EXIT_SUCCESS);
}
Full code: 26-two-processes.c++.
19.2. Lock Implementations
Build option MDBX_LOCKING:
| Flavour | Primitives | LCK signature |
|---|---|---|
POSIX2008 |
Robust mutexes (Linux default) | 0x8017 |
POSIX2001 |
Shared mutexes | 0x8017 |
SYSV |
Semaphores (macOS default) | 0xF18D |
WIN32FILES |
Windows (LockFileEx) |
0xF10C |
POSIX1988 |
(legacy) | 0xFC29 |
Nuance:
POSIX2001andPOSIX2008share one signature — this is not a typo. Both flavors store the same kind of interprocess primitive in the LCK — apthread_mutex_t, so their on-disk formats are byte-identical and the signature is common. The difference lies only in the API features used: robust mutexes appeared in POSIX.1-2008, while the 2001 mode works with plain process-shared mutexes. Consequence: a database opened in one of these two modes can be continued in the other — but not withSYSV,WIN32FILES, orPOSIX1988(their signatures differ, and an LCK of such a format is rejected).
The actual compatibility check at open time uses not the raw signature but the derived
MDBX_LOCK_FORMAT hash — it mixes the flavor's signature with sizeof(reader_slot_t) and the
offsets of the key lck_t fields, so the constants above are just the per-primitive-format
"base", while the full identity also accounts for the layout of the remaining fields.
The LCK format is versioned separately from the data format: a structure change breaks only shared access to an already-open database, not the data file itself.
19.3. Why LockFileEx on Windows Instead of Named Mutexes
File locks (LockFileEx) were chosen deliberately: they work on network drives and protect against
incompetent actions. The price — in naive benchmarks with many small transactions libmdbx may lag
behind LMDB (acquiring/releasing a file lock — hundreds of microseconds).
19.4. Reader Slot (RLT)
txnid (atomic) snapshot number (INVALID_TXNID = free)
tid owner; PARKED = UINT64_MAX, OUSTED = UINT64_MAX-1
pid owner process
snapshot_pages_used first_unallocated at snapshot time
snapshot_pages_retired how many pages it holds back from reuse
Slot liveness is checked using OS facilities (protection against pid/tid reuse). Stale slots are not scanned one by one — the whole table is reinitialized when the LCK is opened by a single process.
19.5. Recovering Locks After a Process Failure
After a process crash the OS releases its locks; dead reader slots are cleaned up by the liveness
mechanisms and mdbx_reader_check().
19.6. Mode Without a Lock File
A "single process, exclusive" mode without .lck is possible (a build option) — when interprocess
access is not needed.
Examples for this chapter:
examples/c++/26-two-processes.c++.
19.7. Summary of Chapter 19
- LCK: writer mutex + RLT + statistics; versioned separately.
- Implementations: POSIX/SysV/WIN32FILES; flavour signatures.
- LockFileEx — the choice for network drives; slower for small transactions.
- RLT slot: txnid/tid/pid + pinning fields; liveness via the OS.
- A mode without LCK is possible (single process).
19.8. Exercises
- Why is deleting
.lckwhile the database is open a mistake? - How is
mdbx_reader_checkdifferent from "automatic" cleanup?
19.9. Chapter 19 checklist
- [ ] I can list what lives in the lock file and why it is needed for interprocess access.
- [ ] I know that the LCK format is versioned separately from the data format, and what that means for compatibility.
- [ ] I can explain why
LockFileExwas chosen on Windows, and what we pay for it in benchmarks with small transactions. - [ ] I describe an RLT reader slot and the fields recording how many pages the reader holds back from reuse.
- [ ] I understand how slot liveness is checked with OS facilities and what cleans dead slots after a process crash (
mdbx_reader_check()). - [ ] I know when the mode without a lock file is possible (single process, exclusive).
Chapter 20. Durability and Recovery
20.1. Two-Phase Meta Update: weak vs steady
- weak — data is in the file but not guaranteed to be flushed to persistent media;
- steady — data is flushed to disk; the snapshot survives a system failure.
20.2. Recovery Without a WAL
There is deliberately no WAL. After a crash, on open the last intact and valid meta is selected (a consistent trio, matching txnid halves, a checksum). "Half-written" transactions that did not manage to publish the meta simply do not exist.
mdbx_txn_checkpoint(txn, weakening_durability, &latency) — a commit variant: it commits the
transaction and returns per-stage latencies (MDBX_commit_latency), and allows weakening durability
via weakening_durability. The header marks it as "may be changed in future releases" — use it for
diagnostics, not as a stable API. Forcing the steady point is done with
mdbx_env_sync_ex(env, force=true, ...) (Volume II, chapter 10), not with a checkpoint.
20.3. Open for Recovery
mdbx_env_open_for_recovery() opens the database with a choice of the target meta page
(target_meta) under an exclusive lock, to resolve a "stuck" trio. Since 0.12.7+ recovery checks
do not modify the database. The MDBX_WANNA_RECOVERY code is returned on a read-only open of a
database requiring recovery.
Warning: the header marks this function as internal API of the
mdbx_chkutility, "subject to change at any time" — it is not recommended in application code; the regular path is a read-write open ormdbx_chk.
Fragment from examples/c++/27-recovery.c++ — a writer crash
(_exit without commit/abort) is rolled back on reopen (steady), and open_for_recovery() opens a
specific meta page:
// Reopen: uncommitted changes were rolled back.
{
auto env = example::env_open(path);
auto txn = env.start_read();
auto table = txn.open_map(nullptr);
const std::string value(txn.get(table, mdbx::slice("key")).as_string());
std::cout << "crashed writer changes rolled back: key=" << value << ", newkey ";
try {
(void)txn.get(table, mdbx::slice("newkey"));
std::cout << "present\n";
return EXIT_FAILURE;
} catch (const mdbx::not_found &) {
std::cout << "absent\n";
}
txn.abort();
}
// Recovery mode: open a specific meta page (0..2).
{
auto recovery = mdbx::env_managed::open_for_recovery(path.c_str(), 0 /* target_meta */, true);
std::cout << "open_for_recovery: ok\n";
}
Full code: 27-recovery.c++.
20.4. boot_id: Semantics and Behavior in LXC
boot_id — the OS boot identifier, taken into account when rolling back weak metas. In LXC, boot_id
may be absent/shared across containers — the rollback control for weak metas accounts for this. This
matters for failures inside containers.
20.5. Unified Page Cache Incoherence
A known issue, issue #269: incoherence of the unified page cache between processes. The protection —
MDBX_FORCE_CHECK_MMAP_COHERENCY (a build option, default 0); introduced in the 0.11.5–0.11.6
series; the trigger counter is visible in diagnostics.
20.6. mdbx_chk: Integrity Checking
mdbx_chk — deep validation: metas/trio, trees, ordering, the reachability invariant, GC. Modes:
-w (read-write, rollback to steady), -d (page-by-page traversal — without it you cannot find
lost/double-used pages), -i (ignore false ordering errors with custom comparators), -0/-1/-2 (a
specific meta), -vvvvv (histograms). Exit code: 0 = clean.
20.7. What Survives a Power Failure — by Mode
| Mode | After a failure |
|---|---|
MDBX_SYNC_DURABLE |
All committed data is in place |
MDBX_NOMETASYNC |
Loss of the last commits (meta deferred) |
MDBX_SAFE_NOSYNC |
Rollback to the last steady |
MDBX_UTTERLY_NOSYNC |
No guarantees; corruption possible |
Examples for this chapter:
examples/c++/27-recovery.c++.
20.8. Summary of Chapter 20
- Two-phase meta: weak/steady; recovery — selecting the last intact meta.
- Open-for-recovery with target_meta; since 0.12.7 checks do not modify the database.
- boot_id matters in LXC; page-cache incoherence — #269 + FORCE_CHECK.
mdbx_chk— the main verification tool; see the modes above.
20.9. Exercises
- Describe what will happen to the database after kill -9 in the middle of a commit, in
DURABLEmode. - Why is
mdbx_chk -dneeded to find lost-unused pages?
20.10. Chapter 20 checklist
- [ ] I distinguish weak and steady snapshots and know which one survives a system failure.
- [ ] I can explain how recovery works without a WAL: selecting the last intact and valid meta; "half-written" transactions simply do not exist.
- [ ] I know the purpose of
mdbx_env_open_for_recovery()withtarget_metaand why it is not recommended in application code. - [ ] I understand the role of
boot_idwhen rolling back weak metas, and its specifics in LXC. - [ ] I know about the unified page cache incoherence (#269) and the
MDBX_FORCE_CHECK_MMAP_COHERENCYprotection. - [ ] I can pick the right
mdbx_chkmodes (-w,-d,-i,-0/-1/-2) for the task and understand why-dis needed. - [ ] For each durability mode, I can say what will survive a power failure.
Volume Conclusion
You understand the internals of libmdbx: from the page and B+tree to the meta, GC, and recovery. Now you can explain any observed behavior through the architecture.
What's next: Volume IV — performance and optimization: WAF, choosing a configuration for the scenario, micro-optimizations, get-cached, bulk operations, benchmarks.