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")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.