Skip to contents

Python’s diskcache is a disk-backed cache built on SQLite, and polars-diskcache layers on top of it: the DataFrame goes to a Parquet file, and diskcache tracks where it went. This article sketches the same thing on mdbx and implements enough of it to run.

It is a design note, not a package. The point is to show what changes when the storage engine is an ordered key-value store rather than a relational one.

What diskcache asks of SQLite

One table and six indexes:

CREATE TABLE Cache (
  rowid INTEGER PRIMARY KEY, key BLOB, raw INTEGER,
  store_time REAL, expire_time REAL, access_time REAL,
  access_count INTEGER, tag BLOB, size INTEGER,
  mode INTEGER, filename TEXT, value BLOB)

CREATE UNIQUE INDEX Cache_key_raw     ON Cache(key, raw)
CREATE        INDEX Cache_expire_time ON Cache(expire_time)
CREATE        INDEX Cache_store_time  ON Cache(store_time)
CREATE        INDEX Cache_access_time ON Cache(access_time)
CREATE        INDEX Cache_access_count ON Cache(access_count)
CREATE        INDEX Cache_tag_rowid   ON Cache(tag, rowid)

Every index exists to answer one question in order and stop early — ORDER BY expire_time LIMIT n for expiry, ORDER BY access_time LIMIT n for least-recently-used eviction, and so on. filename is the escape hatch: values past a size threshold are written as separate files and the row keeps only a pointer, which is exactly how polars-diskcache stores Parquet.

So SQLite is doing two jobs here: a keyed store, and a set of ordered indexes that support bounded scans. Neither needs joins, and none of the queries is relational.

What that becomes on mdbx

mdbx gives one thing SQLite gives here — keys in sorted order — and gives it directly. A table plus an index becomes a named database whose key is the thing you want to sort by:

SQLite mdbx
Cache(key) → value database values, key → payload
the metadata columns database meta, key → a serialized record
INDEX (expire_time) database expiry, key <expire><key>
INDEX (access_time) database accessed, key <atime><key>
rowid INTEGER PRIMARY KEY mdbx_dbi_sequence()
ORDER BY x LIMIT n mdbx_items(db = x, limit = n)
WHERE x > ? (resume) mdbx_items(db = x, start = k)
ORDER BY x DESC LIMIT 1 mdbx_items(db = x, limit = 1, reverse = TRUE)

An index entry is <8 bytes of time><the key> rather than just the time, for two reasons: entries must be unique — two records can expire in the same millisecond — and having the key in the index means expiry does not need a second lookup to learn what to delete.

Byte order is the only order

This is the constraint everything else follows from. mdbx sorts keys as bytes, so a number has to be encoded such that its byte order is its numeric order. Big-endian, fixed width, does that:

be8 <- function(x) writeBin(as.double(x), raw(), size = 8, endian = "big")
un8 <- function(r) readBin(r, "double", size = 8, endian = "big")

times <- c(1, 1000, 1.7e9, 1.7e9 + 1, Inf)
encoded <- vapply(times, function(t) paste(format(be8(t)), collapse = ""), "")
identical(order(encoded), order(times))
#> [1] TRUE

Note that Inf encodes as the largest value of all, which conveniently makes “never expires” sort after every real deadline — no special case needed.

The usual R instincts do not survive here. as.character(10) < as.character(9), and little-endian bytes sort by their least significant byte first. Get the encoding wrong and every ordered scan in this design silently returns the wrong rows.

A working draft

cache_open <- function(dir, map_size = 256 * 1024^2) {
  dir.create(dir, showWarnings = FALSE, recursive = TRUE)
  dir.create(file.path(dir, "blobs"), showWarnings = FALSE)

  # A cache may lose the last few writes to a crash: it is a cache. SAFE_NOSYNC
  # cannot corrupt the database, only roll it back to the last steady commit.
  env <- mdbx_env_open(file.path(dir, "cache.mdbx"),
                       max_dbs = 8, map_size = map_size, flags = "SAFE_NOSYNC")

  mdbx_with_write(env, function(txn) {
    for (name in c("values", "meta", "expiry", "accessed")) {
      mdbx_dbi_open(txn, name, create = TRUE)
    }
  })

  structure(list(env = env, dir = dir), class = "rcache")
}

# The four databases, resolved against the transaction in hand.
dbs <- function(txn) {
  list(values   = mdbx_dbi_open(txn, "values"),
       meta     = mdbx_dbi_open(txn, "meta"),
       expiry   = mdbx_dbi_open(txn, "expiry"),
       accessed = mdbx_dbi_open(txn, "accessed"))
}

index_key <- function(when, key) c(be8(when), charToRaw(key))
index_of <- function(entry) list(when = un8(entry[1:8]),
                                 key = rawToChar(entry[-(1:8)]))

Writing a record touches all four databases, and the transaction makes that one atomic step — the property SQLite was providing:

cache_set <- function(cache, key, value, expire_in = Inf, threshold = 4096) {
  now <- as.numeric(Sys.time())
  expire <- now + expire_in
  payload <- serialize(value, NULL)

  mdbx_with_write(cache$env, function(txn) {
    db <- dbs(txn)
    forget(txn, db, key, cache$dir)  # replacing? drop the old index entries

    # Large values go to a file and the store keeps a pointer -- the same
    # arrangement polars-diskcache uses for Parquet.
    blob <- NULL
    if (length(payload) > threshold) {
      blob <- paste0(format(as.hexmode(sum(utf8ToInt(key) * seq_along(strsplit(key, "")[[1]])))), "-",
                     length(payload), ".rds")
      writeBin(payload, file.path(cache$dir, "blobs", blob))
    } else {
      mdbx_put(txn, key, payload, db = db$values)
    }

    mdbx_put(txn, key, serialize(
      list(stored = now, expire = expire, accessed = now,
           size = length(payload), blob = blob), NULL), db = db$meta)

    mdbx_put(txn, index_key(expire, key), raw(0), db = db$expiry)
    mdbx_put(txn, index_key(now, key), raw(0), db = db$accessed)
  })

  invisible(cache)
}

# Remove a key everywhere it appears. Needs the metadata to find the index
# entries, which is why the index keys carry their timestamp.
forget <- function(txn, db, key, dir) {
  raw_meta <- mdbx_get(txn, key, db = db$meta, as = "raw")
  if (is.null(raw_meta)) return(FALSE)

  meta <- unserialize(raw_meta)
  mdbx_del(txn, index_key(meta$expire, key), db = db$expiry)
  mdbx_del(txn, index_key(meta$accessed, key), db = db$accessed)
  mdbx_del(txn, key, db = db$meta)
  mdbx_del(txn, key, db = db$values)
  if (!is.null(meta$blob)) unlink(file.path(dir, "blobs", meta$blob))
  TRUE
}

Reading checks the deadline itself, so an expired entry is invisible before anything has got round to deleting it:

cache_get <- function(cache, key, default = NULL) {
  mdbx_with_read(cache$env, function(txn) {
    db <- dbs(txn)
    raw_meta <- mdbx_get(txn, key, db = db$meta, as = "raw")
    if (is.null(raw_meta)) return(default)

    meta <- unserialize(raw_meta)
    if (meta$expire <= as.numeric(Sys.time())) return(default)

    payload <- if (is.null(meta$blob)) {
      mdbx_get(txn, key, db = db$values, as = "raw")
    } else {
      readBin(file.path(cache$dir, "blobs", meta$blob), raw(), n = meta$size)
    }
    unserialize(payload)
  })
}

cache_delete <- function(cache, key) {
  mdbx_with_write(cache$env, function(txn) forget(txn, dbs(txn), key, cache$dir))
}

Expiry and eviction are the same shape

Both walk an index from its cheap end and stop early. This is the whole reason the indexes exist, and it is limit:

# Delete everything already past its deadline, in bounded chunks.
cache_expire <- function(cache, limit = 100) {
  now <- as.numeric(Sys.time())

  mdbx_with_write(cache$env, function(txn) {
    db <- dbs(txn)
    due <- mdbx_items(txn, limit = limit, db = db$expiry, as = "raw")$keys

    removed <- 0
    for (entry in due) {
      at <- index_of(entry)
      if (at$when > now) break        # the rest are in the future
      forget(txn, db, at$key, cache$dir)
      removed <- removed + 1
    }
    removed
  })
}

# Least-recently-used eviction: the oldest access times are the first keys.
cache_evict <- function(cache, n = 10) {
  mdbx_with_write(cache$env, function(txn) {
    db <- dbs(txn)
    oldest <- mdbx_items(txn, limit = n, db = db$accessed, as = "raw")$keys
    for (entry in oldest) forget(txn, db, index_of(entry)$key, cache$dir)
    length(oldest)
  })
}

Iteration is resumable, so listing a large cache does not materialise it:

cache_keys <- function(cache, chunk = 1000) {
  seen <- character(0)
  from <- NULL

  mdbx_with_read(cache$env, function(txn) {
    db <- dbs(txn)
    repeat {
      keys <- mdbx_keys(txn, limit = chunk, start = from, db = db$meta)
      if (length(keys) == 0) break
      if (!is.null(from)) keys <- keys[-1]   # `start` is inclusive
      seen <<- c(seen, keys)
      if (length(keys) == 0) break
      from <<- keys[length(keys)]
    }
  })
  seen
}

cache_close <- function(cache) {
  mdbx_env_sync(cache$env)
  mdbx_env_close(cache$env)
}

It runs

dir <- file.path(tempdir(), "rcache-demo")
cache <- cache_open(dir)

cache_set(cache, "alpha", list(n = 1))
cache_set(cache, "beta", data.frame(x = 1:3))
cache_set(cache, "brief", "gone shortly", expire_in = -1)  # already expired
cache_set(cache, "big", runif(5000))                       # over the threshold

cache_get(cache, "alpha")
#> $n
#> [1] 1

cache_get(cache, "beta")
#>   x
#> 1 1
#> 2 2
#> 3 3

# Expired on read, before anything has deleted it.
cache_get(cache, "brief", default = "(expired)")
#> [1] "(expired)"

# The large one went to a file; the store kept a pointer.
length(cache_get(cache, "big"))
#> [1] 5000
list.files(file.path(dir, "blobs"))
#> [1] "269-40031.rds"
sort(cache_keys(cache))
#> [1] "alpha" "beta"  "big"   "brief"

# Expiry finds only what is actually due.
cache_expire(cache)
#> [1] 1

sort(cache_keys(cache))
#> [1] "alpha" "beta"  "big"
# Eviction takes the least recently used first, which is insertion order here.
cache_evict(cache, n = 1)
#> [1] 1
sort(cache_keys(cache))
#> [1] "beta" "big"

cache_close(cache)

What this draft leaves out

It is a sketch, and the gaps are as interesting as the code.

Access time is never updated on read. Doing it properly makes every get() a write transaction, which serialises readers against each other — diskcache has the same problem and solves it with a statistics switch and batched updates. A real implementation would buffer access times and flush them periodically.

No size accounting or cull-to-limit. mdbx_env_info()$file_size gives the database size, but the blob directory has to be tracked separately, and enforcing a byte ceiling means evicting until under it.

No tags. A tag index is a fifth database keyed <tag>\0<key>, scanned with start for the prefix — the composite-key equivalent of INDEX (tag, rowid).

Blob names are a toy. They should be a content hash, and orphaned files need collecting when a transaction that wrote one rolls back — the store is transactional, the filesystem is not.

Blob deletion is not transactional, and this draft gets it wrong on purpose. forget() calls unlink() while the transaction is still open. If a later step fails, mdbx rolls the metadata back to an entry whose blob file has already gone, and cache_get() on that key then fails to read a file the store still believes in. That is data loss, not garbage: the reverse of the orphan case above, and not fixable by collecting unreferenced files. A real implementation collects the paths to delete during the transaction and unlinks them only after mdbx_txn_commit() returns, accepting orphans on a crash in between — orphans are recoverable, dangling references are not.

No concurrent-writer story. One process writes at a time; a second gets MDBX_BUSY or waits. For a cache that is usually right, but it wants flags = "TRY" and a retry rather than a blocked worker. See ?"mdbx-concurrency".

What carried over, and what did not

The parts of SQLite this design does not need turn out to be most of it: no schema, no query planner, no SQL, no joins. What it does need — sorted keys, bounded ordered scans, atomic multi-step writes, and a counter — mdbx provides directly, and a named database per index is a closer fit to the intent than a table plus CREATE INDEX.

What genuinely gets harder is that every ordering decision moves into the key encoding, where it is your problem rather than the engine’s. SQLite knows that expire_time is a number. mdbx knows only bytes, and be8() above is the whole difference between an index that works and one that quietly returns nonsense.