R bindings to libmdbx, an embedded transactional key-value store. The library is vendored and compiled into the package, so there is no server to run and nothing to install beside it.
A database is a single file. Keys and values are bytes, held in sorted key order and optionally split across named databases within that one file. Every read and write happens inside a transaction: commits are ACID and fully durable by default, and a reader sees a consistent snapshot without blocking the writer. Several processes can share a database — many readers, one writer.
Three limits shape how it is used. An environment runs one transaction at a time, because libmdbx
binds a transaction to the thread that began it and R is single-threaded. A handle does not survive
fork(), so each process opens its own. And storing bytes is where the package stops: serializing
R objects is left to you, as are cursors, duplicate keys and batched calls, which are not
implemented yet.
Install the released version from CRAN with:
install.packages("mdbx")
Or the development version from GitHub with:
# install.packages("pak")
# pak::pak("pedrobtz/mdbx")
install.packages("mdbx", repos = c('https://pedrobtz.r-universe.dev', 'https://cloud.r-project.org'))
Data lives in an environment (a file on disk, .mdbx by convention), and every read or write
happens inside a transaction: mdbx_with_read() and mdbx_with_write() open one, run your code,
and commit if it returns or abort if it throws. Keys and values are bytes: a string goes in as its
UTF-8 bytes, so "k" and charToRaw("k") are the same key. Reads decode back to text by default,
which MDBX cannot vouch for: pass as = "raw" for the rest; a wrong guess errors, never corrupts.
library(mdbx)
path <- tempfile(fileext = ".mdbx")
env <- mdbx_env_open(path)
mdbx_with_write(env, function(txn) {
mdbx_put(txn, "answer", "42")
mdbx_put(txn, "config", serialize(list(retries = 3L), NULL))
})
mdbx_with_read(env, function(txn) {
mdbx_get(txn, "answer")
})
#> [1] "42"
# Not text, so read the bytes and decode them yourself.
mdbx_with_read(env, function(txn) {
unserialize(mdbx_get(txn, "config", as = "raw"))
})
#> $retries
#> [1] 3
# A key that is not there reads as NULL.
mdbx_with_read(env, function(txn) mdbx_get(txn, "missing"))
#> NULL
mdbx_env_close(env)
mdbx_keys() and mdbx_items() walk the database in key order, in a single crossing into C
rather than one per record.
env <- mdbx_env_open(tempfile(fileext = ".mdbx"))
mdbx_with_write(env, function(txn) {
mdbx_put(txn, "banana", "2")
mdbx_put(txn, "apple", "1")
})
mdbx_with_read(env, function(txn) mdbx_keys(txn))
#> [1] "apple" "banana"
items <- mdbx_with_read(env, function(txn) mdbx_items(txn))
stats::setNames(items$values, items$keys)
#> apple banana
#> "1" "2"
mdbx_env_close(env)
Keys come back in key order, not insertion order. Both accept as = "raw", needed for keys or
values that are not text, and both take limit to bound the read. A scan with no limit refuses
to run past mdbx_scan_max (a million records) rather than quietly materializing the lot — pass
limit = n, or limit = Inf to mean it.
mdbx_txn_begin(), mdbx_txn_commit() and mdbx_txn_abort() drive a transaction directly, for
when the decision to keep the writes is only made at the end. The worked
example shows the pattern; the with_*
helpers are those calls plus on.exit().
testthat covers the API, the fork() and cross-process contracts, and panic recovery. Generated
operation sequences are replayed against a reference state model: valid calls must agree with it,
forged handles must be refused changing nothing, and injected faults must stay recoverable. CI adds
R CMD check on five OS/version legs, ASan/UBSan, valgrind, LTO, gctorture, rchk and shuffled test
order; tools/interop-check.sh round-trips against an independently built libmdbx.