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.
mdbx is not on CRAN yet. Install the development version from GitHub with:
# install.packages("pak")
pak::pak("pedrobtz/mdbx")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.