Skip to content

Add a thread-local cache for hot btree pages - #1346

Closed
cberner wants to merge 1 commit into
masterfrom
claude/redb-transaction-cache-3st6g0
Closed

Add a thread-local cache for hot btree pages#1346
cberner wants to merge 1 commit into
masterfrom
claude/redb-transaction-cache-3st6g0

Conversation

@cberner

@cberner cberner commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Problem

Multithreaded random reads scale well below linearly. Every page lookup through the shared page cache performs three writes to memory shared between threads: acquiring the stripe RwLock, storing the LRU second-chance flag, and incrementing the page's Arc refcount. For the hottest pages — the top of each btree — those cache lines ping-pong between cores, and per-thread throughput degrades as threads are added.

Change

Each thread keeps a small bounded cache (ThreadLocalPageCache) of private copies of the branch pages one level below the roots it reads, and lookups in read transactions serve those pages from it. Hits touch only thread-local memory, so the implementation needs no locks or atomics at all: a PageNumberHashMap<Rc<[u8]>> behind a RefCell, plain single-threaded code.

Why only one level. A level holds roughly a page's worth of children per page of the level above it, so the level below the root is small enough for a snapshot's pages to fit in the budget, while the next one down is already far too large. On the lmdb_benchmark shape (5M rows, 24-byte keys) the tree is height 4 with ~15 branch pages at depth 1 and ~4050 at depth 2. Caching depth 2 against a 128-page budget misses ~97% of the time, and admitting a page copies it, so it paid a 4KiB copy on nearly every lookup — far more than the shared-cache lookup it saved. An earlier revision of this PR cached two levels and regressed reads by 14-25%, which is what @cberner's benchmark caught. For the same reason the cache stops admitting once full rather than replacing entries: freezing is self-limiting under thrash, replacing is not.

Correctness. A thread-local cache outlives transactions and page numbers are recycled, so every entry is tagged with (database instance id, snapshot generation) and the tag is validated on lookup. Generation is the read transaction's id, and bytes of a page number cannot change within a generation: snapshots are copy-on-write, and a freed page is only reused by a later write transaction whose commit advances the generation. In-place reload is the exception — rolling a non-durable commit back rewinds the id, so later commits reuse ids that named different data — so clear_cache_and_reload() bumps the instance id, invalidating every thread's entries at once. Reading a different snapshot empties the cache, so a stale entry is never returned. Write transactions never use the cache (they can free and reallocate pages within one generation); leaf pages are never cached (AccessGuards need real PageImpls). Reentrant lookups (pathological Key::compare implementations) and thread teardown degrade to uncached reads rather than panicking.

Memory. At most 128 order-zero pages per thread with a live read transaction (512KiB at 4KiB pages), further bounded by the configured cache size so a small set_cache_size shrinks it and set_cache_size(0) disables it. Released when the read transaction is dropped, so a thread that reads once does not retain them for its lifetime.

no_std. thread_local! is std-only, so no_std builds compile the cache out and their lookups are unchanged. For the same reason the instance-id counter is a Mutex<u64> rather than an AtomicU64: 64-bit atomics are unavailable on thumbv7em-none-eabihf, and it is taken only when a database is opened or reloaded.

Results

lmdb_benchmark on a 16-core 9950X3D, measured by @cberner against master 5e6ad02:

master this PR
random reads (1 thread) 1.18M 1.25M 1.06x
random range reads 531K 534K
random reads (4 threads) 4.44M 4.64M 1.05x
random reads (8 threads) 7.61M 8.61M 1.13x
random reads (16 threads) 13.2M 15.8M 1.20x
random reads (32 threads) 17.4M 27.4M 1.57x

The gain grows with thread count, which is the shape the design predicts. Write benchmarks are unchanged within noise; write transactions never consult or populate the cache. One caveat: individual writes read 1.13K vs 982 txn/s across the two runs, but those are fsync-bound at ~1ms per transaction and sit on a path this change does not touch, so that looks like disk variance rather than a regression.

Shared-page-cache reads per lookup drop from 3.00 to 2.00.

Verification

  • cargo fmt --check, cargo clippy --all-targets --all-features with -D warnings, and cargo test --all-features all pass (podman sandbox unavailable in this environment, so the just test steps were run directly; cargo deny not run). cargo check --target thumbv7em-none-eabihf --no-default-features --features experimental-api-5[,logging,experimental_cursor] also passes with -D warnings.
  • Unit tests in thread_local_page_cache.rs: tag validation including cross-database, snapshot change empties the cache, the page bound holds, and clear() releases entries.
  • Integration tests, each asserting tree_height() > 2 so the tree actually has cacheable branch pages below its root: snapshot stability of a long-lived reader while writers rewrite the tree, alternating short reads and writes on one thread (generation invalidation), a small set_cache_size bounding the per-thread cache, and two databases open on one thread not sharing entries. concurrent_reads_and_writes in multithreading_tests.rs gained the same height guard.
  • Fuzzing (cargo fuzz run --sanitizer=none fuzz_redb, debug assertions on): 196,920 runs on this design, zero failures and no crash artifacts, plus ~660k runs across earlier revisions of the branch. The harness interleaves reads with commits, savepoints and reopens on one thread whose cache persists throughout, checked against a reference map.

Tunables to sanity-check in review: MAX_THREAD_LOCAL_CACHE_PAGES = 128 (page_manager.rs) and MAX_CACHED_DEPTH = 1 (btree.rs). Not covered by design: range()/iterators, first()/last(), and single-page tables (the guard clone on a root-leaf remains).

Open question: the 128-page bound is per thread, so N concurrently-reading threads can hold N x 512KiB beyond the configured cache size. That is documented on thread_local_cache_pages() but not enforced; making it an aggregate bound would need either a guessed thread count or an atomic total, both of which cost something. Left as-is pending your call.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NMLXF7RTMhhhp1jhL9XkNi

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.93909% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.40%. Comparing base (599626b) to head (b23404c).

Files with missing lines Patch % Lines
src/tree_store/btree.rs 90.00% 7 Missing ⚠️
...c/tree_store/page_store/thread_local_page_cache.rs 98.96% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1346      +/-   ##
==========================================
- Coverage   91.40%   91.40%   -0.01%     
==========================================
  Files          39       40       +1     
  Lines       19822    20004     +182     
==========================================
+ Hits        18118    18284     +166     
- Misses       1704     1720      +16     
Files with missing lines Coverage Δ
src/db.rs 91.51% <100.00%> (+0.06%) ⬆️
src/tree_store/page_store/cached_file.rs 92.92% <100.00%> (-0.26%) ⬇️
src/tree_store/page_store/page_manager.rs 96.09% <100.00%> (+0.05%) ⬆️
...c/tree_store/page_store/thread_local_page_cache.rs 98.96% <98.96%> (ø)
src/tree_store/btree.rs 89.21% <90.00%> (-0.10%) ⬇️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/tree_store/page_store/transaction_page_cache.rs Outdated
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 443b591 to 2eba3bf Compare August 10, 2026 01:31
@cberner cberner changed the title Add a per-read-transaction cache for hot btree pages Add a per-reader cache for hot btree pages Aug 10, 2026
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 2eba3bf to 7bd5b4f Compare August 10, 2026 03:07
@cberner cberner changed the title Add a per-reader cache for hot btree pages Add a thread-local cache for hot btree pages Aug 10, 2026
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch 2 times, most recently from 648ebf7 to 9fc6f88 Compare August 11, 2026 16:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fc6f88630

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/tree_store/btree.rs
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 9fc6f88 to 1d73ddb Compare August 11, 2026 17:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d73ddb8ea

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/tree_store/btree.rs
Comment thread CHANGELOG.md Outdated
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 1d73ddb to 68032c9 Compare August 11, 2026 17:34

@cberner cberner left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Polish the code structure. It feels a bit spread out all over the place, and with overly verbose comments too.

Comment on lines +56 to +58
pub(crate) struct ThreadLocalPageCache {
slots: Box<[Option<CacheEntry>]>,
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this custom cache? It's thread local. Can't we just use a HashMap or something?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — done in 154a00c, and it turned out both simpler and faster. (I posted this on the wrong thread first, sorry; it's the one above on btree.rs.)

The custom cache was left over from when entries survived across transactions and had to be validated individually. Once the cache is emptied at the end of a read transaction, every live entry belongs to the same snapshot — so one tag for the whole cache replaces per-entry tags, and reading under a different snapshot just empties it. That deletes the slot array, the probe sequence, the collision handling and the eviction policy:

pub(crate) struct ThreadLocalPageCache {
    tag: Option<CacheTag>,
    pages: PageNumberHashMap<Rc<[u8]>>,
}

get is a tag compare plus a map lookup; insert empties first if the tag differs, then inserts while under the size cap. The module is ~150 lines now, over half of it tests.

It also measures better, because the probe sequence was silently dropping hot pages on collisions — shared-cache reads per lookup, 5M-row height-5 tree:

reads per get
no cache 4.00
slot array 2.21
HashMap 2.00

2.00 is the floor for caching two levels below the root, so the collision waste is gone. Throughput at 4 threads on my (noisy) VM: 1.60 → 2.03-2.14 vs master, so no worse than the slot array and probably slightly better.

One behavior change worth knowing: two read transactions interleaved on one thread now thrash, since each insert under the other's tag empties the cache. Still correct, just uncached — per-entry tags used to let both live. Seemed the right trade for this much less machinery, but happy to revisit if you think that pattern matters.

On the structure/verbosity feedback in your other comment: I cut the module's doc block down to the invariant argument and trimmed the commentary in btree.rs. The change now touches btree.rs (traversal + tag), the cache module, and small hooks in db.rs (release on transaction end) and page_manager.rs (instance id). Happy to pull more of it together if it still reads as scattered.


Generated by Claude Code

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 68032c9 to 154a00c Compare August 11, 2026 18:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 154a00cc25

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread tests/basic_tests.rs Outdated
Comment on lines +3888 to +3890
let file = std::fs::OpenOptions::new()
.write(true)
.open(tmpfile.path())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid mutating the locked database file in the test

On the windows-latest CI job this test keeps db alive while opening the same path for writing to force an external size change. The normal file backend has already taken an exclusive File::try_lock() for that live Database, so on Windows the extra handle/open or the following set_len() can fail with a sharing/lock violation before check_integrity() is exercised. Use a custom backend that can report a mismatched length, or otherwise avoid relying on a second writable handle to the locked database file.

Useful? React with 👍 / 👎.

Comment thread src/tree_store/btree.rs Outdated
self.get_helper(&child_page, query)
let page = self.mem.get_page(page_number, self.hint)?;
if page.memory()[0] == BRANCH {
cache.insert(*tag, page_number, page.memory());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the configured cache-size limit

When callers use Builder::set_cache_size(0) or a small value to cap cache memory, this path still unconditionally copies hot branch pages into the thread-local cache, which is separate from PagedCachedFile's max_cache_size budget and CacheStats. A multithreaded read workload can therefore retain up to 128 pages per worker thread even when the cache was disabled or tightly bounded; gate this cache on the configured budget, especially the zero-cache case, or account it in the same limit.

Useful? React with 👍 / 👎.

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 154a00c to 23cdd98 Compare August 11, 2026 18:45

cberner commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Audited the tests in 23cdd98, and the audit turned up something worse than redundancy: four of the six integration tests were not exercising the cache at all.

Only branch pages below the root are cached, so a tree of height <= 2 (root plus leaves) leaves the cache empty. Measuring what the tests actually built:

test value tree height exercised the cache?
read_snapshot_stable_while_writes_proceed 50B 3 yes
concurrent_reads_and_writes 50B 3 yes
alternating_reads_and_writes_invalidate_thread_cache u64 2 no
sequential_multi_table_reads u64 2 no
reads_after_reload_rolls_back_non_durable_commit u64 2 no
multithreaded_reads u64 2 no

u32 -> u64 tables stay at height 2 even at 20k entries, so those four were passing as generic correctness tests while testing nothing about this change.

What I did:

  • Removed sequential_multi_table_reads (the per-table cliff it was written for no longer exists — one cache is shared now), multithreaded_reads (concurrent_reads_and_writes covers concurrent readers and adds a writer), and reads_after_reload_rolls_back_non_durable_commit (vacuous, and by my own admission above it did not fail without its fix; Codex separately flagged it for opening a second writable handle to the locked file, which is a fair portability complaint).
  • Fixed alternating_reads_and_writes_invalidate_thread_cache to build a height-3 tree, since generation invalidation is the property most worth an integration test.
  • Guarded each remaining cache test with assert!(tree_height() > 2), so shrinking the data can never silently make them vacuous again.
  • Trimmed read_snapshot_stable_while_writes_proceed from three rewrite rounds to two: 9.9s -> 1.3s, and it was by far the most expensive test in the change.

Net: 6 integration tests -> 3, each verified to exercise the cache, and the added suite time drops from ~16s to ~7s. The four unit tests stay — they cover tag validation, the empty-on-new-snapshot path, the size bound and clear(), each distinct and effectively free.

Also in this push, from the Codex P2 that was worth acting on: set_cache_size() now bounds the thread-local cache too. It previously sat entirely outside that budget, so a database configured with a small cache — or set_cache_size(0) — still got 128 pages per reading thread. The per-thread cap is now min(128, configured_bytes / page_size), so a zero budget disables it, with a test covering that case. Hit rate is unchanged at 2.00 shared-cache reads per lookup.

The other Codex P1 ("Rewrite the AI-authored commit") is wrong again: it cites 4e18858faaa880806da5cd597d6bb740c3923c32, which does not exist in this repository (git cat-file -t -> no such commit). commit-authors has passed on every pushed revision.


Generated by Claude Code

Comment thread src/tree_store/page_store/thread_local_page_cache.rs
Comment on lines +14 to +33
/// A thread's cache of the hottest btree pages, holding private copies of them so that
/// lookups in read transactions can skip the shared page cache, whose lock stripe, LRU flag
/// and `Arc` refcount are all writes to memory shared between threads. Being thread-local
/// makes it plain single-threaded code: no locks, no atomics, and hits touch nothing another
/// core can see.
///
/// All the entries belong to one snapshot, named by `tag`, and reading under a different one
/// empties the cache. That is what keeps entries from going stale, and it holds because:
///
/// * A transaction is given generation `G` only while `G` is the last committed transaction:
/// `register_read_transaction()` reads that id and pins it under one lock acquisition, and
/// ids only increase. Note that the pinned id can lag the roots the transaction goes on to
/// read, which is safe because the older pin is the conservative one.
/// * Reusing a page number for different bytes takes more than one commit past `G`, because
/// the page must be freed and then released by `process_freed_pages()`, which stops at the
/// oldest live read. Every transaction holding `G` pins `G`.
///
/// So a page cached under `G` cannot have been reused while any transaction can still read it
/// back. In-place reload is the exception, since rolling a non-durable commit back rewinds the
/// id; `clear_cache_and_reload()` bumps the instance id to cover it.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too verbose

Comment on lines +37 to +38
// Page budget of the database `tag` names, so that a database configured with a small
// cache does not gain memory back through its readers' threads

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty obvious from the field name. Do a pass to clean up unnecessary comments

&mut self,
tag: CacheTag,
max_pages: usize,
page_number: PageNumber,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have an assertion that only order-0 pages get cached. We don't want it getting bloated in edge cases

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 23cdd98 to 90982b8 Compare August 11, 2026 19:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90982b8efe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated

@cberner cberner left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do a careful pass over all the comments you wrote in the code. I had a fair bit of feedback on them. Match my style in the rest of the code base

Comment on lines +31 to +32
// Source of `TransactionalMemory::cache_instance_id`. Starts at 1 so that 0 can mean
// "no database" in thread-local page cache entries.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems errorprone that we rely on this being process wide. I think we should move it into the TransactionMemory or something, and then use the same address check that savepoints use to avoid restoring across databases

Comment on lines +1588 to +1591
// Pages each thread may copy into its page cache. Bounded by the cache size the caller
// configured, so that `set_cache_size()` still caps memory: a small budget shrinks it and
// a zero one disables the cache entirely, rather than every reading thread adding to the
// configured total.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too verbose

Comment thread src/db.rs Outdated
Comment on lines +335 to +336
// Write transactions may free and reallocate pages within the transaction, so readers
// they create must not cache pages

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments should document the function not how its callers work. Either move this comment to the caller or just remove it.

Comment thread src/db.rs Outdated
Comment on lines +349 to +351
// So that a thread which reads once does not hold pages for its lifetime.
// Dropping on another thread, or while this thread has a second read
// transaction open, only costs the optimization.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a good comment. Say it clears the thread local cache so that it doesn't "leak" cache space if the thread doesn't do another tranasction

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 90982b8 to b01c6c9 Compare August 12, 2026 01:29

cberner commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Comment pass done in b01c6c9, and the counter stays with real docs on it.

On the instance id — I looked at what savepoints do (std::ptr::from_ref(self.transaction_tracker.as_ref()) != savepoint.db_address()) and I don't think the bare address works here, because savepoints get their safety from something this cache deliberately lacks: a Savepoint holds the Arc<TransactionTracker>, so the address it compares can't be reused while the savepoint exists. The cache holds no reference to the database, by design — it must not keep one alive.

Without that, the address can be recycled:

  • ReadTransaction is Send, so a transaction read on thread A can be dropped on thread B. The release in TransactionGuard::drop only clears the dropping thread's cache, so A keeps its entries.
  • The database is then dropped, and a new one allocates its tracker at the same address.
  • New databases start at TransactionId::new(0) (page_manager.rs), so the new database's generations walk straight through the ones A cached under.

Tag matches, and A gets the old database's bytes for the new database's page numbers. Narrow, but silent corruption, so it didn't seem like the right thing to ship on a maintainer's suggestion without flagging it. Pinning the address with a Weak would close it, at the cost of a Weak field plus a separate reload epoch — more machinery than the counter, which is why I asked rather than picking.

The static now documents what it has to guarantee and how, per your comment:

// Assigns every TransactionalMemory, and every in-place reload of one, an id that no other
// instance in this process has held, which is what lets a thread local page cache tell whose
// pages it is holding. The id must be unique process wide, not just within one database:
// thread local caches outlive the databases that filled them, and every database a thread
// reads shares one cache, so an id unique only within a database would let one database's
// pages match another's tag. Ids are never reused -- incrementing a u64 once per instance and
// per reload cannot wrap in practice -- so a tag from a dropped database matches nothing.

Comments — went through every one I added:

  • TransactionGuard::is_read(): removed, it described the callers.
  • The release call site now says it clears the thread local cache so it doesn't leak cache space if the thread runs no further transaction, as you wrote it.
  • thread_local_cache_pages(): cut to two lines.
  • Trimmed the rest to the same shape — cache_instance_id, the reload invalidation, MAX_CACHED_DEPTH, get_page_for_read, NodePage, cache_tag, and the cache module's own docs. The module doc keeps only the two facts that make the tag sufficient, since that is the part a future change could silently break.

Verified: full suite, clippy with -D warnings, fmt.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b01c6c9437

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
* Add `ExtractIf::close()` to explicitly finalize an extract iterator without removing unread
entries.
* Optimize `Table::pop_first()` and `Table::pop_last()` to be about 2x faster.
* Improve multithreaded read scaling. Each thread now caches the top of the btrees it reads,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the AI-authored commit

Fresh evidence for this review: git log -1 --format='%an <%ae> / %cn <%ce>' a16c4f6ee5945b28b8278b10a9f635392e13c7b8 reports Codex <codex@openai.com> / Codex <codex@openai.com>, and the PR's commit-authors job rejects commits whose author or committer contains claude or codex; rewrite the commit with the human identity and add the required Assisted-by: trailer before merging.

AGENTS.md reference: AGENTS.md:L36-L40

Useful? React with 👍 / 👎.

// Pages each thread may cache, bounded by the configured cache size so that
// set_cache_size() still caps memory
fn thread_local_cache_pages(&self) -> usize {
let budget = self.storage.max_cache_size() / (self.page_size as usize);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account thread-local pages in the global cache budget

When set_cache_size() is set to a small non-zero value and multiple reader threads are active, this calculation grants the full page-count budget independently to every thread, while PagedCachedFile can still use the configured budget and CacheStats::used_bytes() omits these copies. For example, a one-page cache allows one extra cached page per reader thread, so the configured cache size no longer caps total cache memory; either account these pages in the shared budget or disable/partition the thread-local cache when the global budget is tight.

Useful? React with 👍 / 👎.

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from b01c6c9 to c457f5f Compare August 12, 2026 01:41

cberner commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

One correction to my own comment, in c457f5f, plus an open question for you.

Codex is right that the set_cache_size() bound I added is per thread, not aggregate: each reading thread may hold up to min(128, configured / page_size) pages, so N concurrent readers can hold N times that beyond the configured size. My comment claimed it meant "set_cache_size() still caps memory", which overstated it. Now:

// Pages each thread may cache. Bounded by the configured cache size, so that a small one
// shrinks it and set_cache_size(0) disables it, but this is a per thread bound: threads
// reading concurrently can hold this many pages each, beyond the configured size

What the bound does give: set_cache_size(0) disables the cache outright, and a small budget shrinks the per-thread cap proportionally. What it doesn't: a total ceiling. At the default 1GiB the per-thread cap is the full 128 pages (512KiB/thread), which is the design as it stands; at, say, 1MiB with 16 readers it is ~8MiB against a 1MiB setting.

Whether that needs fixing is your call, and the options each cost something:

  • Leave it, documented as above. Per-thread caches outside the global budget are a normal pattern, and the absolute numbers are small.
  • Divide the budget by an assumed thread count — arbitrary, and wrong in both directions depending on the workload.
  • Track the aggregate in an atomic on TransactionalMemory and stop caching past a share of the budget. Exact, but it puts an atomic back on the fill path (misses only, not hits) and re-adds machinery you asked me to strip out.

I didn't want to pick a policy for you here, so I only corrected the comment. Say which you'd like and I'll do it.

Unrelated: the commit-authors P1 on this thread has now cited four SHAs that do not exist in this repository (4e18858, d082f32, a16c4f6, and one earlier). The branch has one commit, authored and committed by you, and that job has passed on every push. I've stopped replying to it individually.


Generated by Claude Code

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from c457f5f to 61d0b7d Compare August 20, 2026 04:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61d0b7d5ed

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +98 to +100
if self.pages.len() < self.max_pages {
self.pages.insert(page_number, Rc::from(bytes));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict entries after the thread-local cache fills

When a long-lived read transaction touches max_pages distinct branch pages before reaching its hot working set, this condition permanently rejects every later page because the tag does not change until another snapshot is used. The cache therefore freezes the first 128 pages rather than the hottest pages; for example, scanning several tables before repeatedly querying another table can leave that hot table with zero thread-local hits for the rest of the transaction. Replace or evict an existing entry when admitting a new page after the cache reaches its bound.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0df6351. This was real, and worse than "suboptimal" — I measured it and the second table got zero benefit.

The reproduction: read table A until the cache fills, then measure only lookups in table B within the same read transaction. Shared-page-cache reads per lookup (cache_metrics), 5M-row height-4 table, against master 5e6ad02:

one table A then B
master (no cache) 3.00 3.00
stop inserting when full 2.07 3.00
clear when full 2.28 2.28
two halves (now) 2.12 2.12

"A then B" at 3.00 is exactly the uncached baseline, so the cache was doing nothing for B.

I did try the literal suggestion — evict one existing entry per admission — using pages.keys().next() as the victim. It measured worse on both shapes (2.70 / 3.00): with no use ordering the sweep evicts the few hot top-level pages as readily as cold ones, and the newly inserted entry frequently becomes the next victim.

What shipped instead: entries live in two halves. Insertions fill pages; when it reaches half the bound the two halves swap and the older one is cleared. A page read again before its half is discarded is promoted back into pages, so what stays cached is what is still being read, with no per-entry bookkeeping and no use-order tracking. |pages| + |evicting| <= max_pages is asserted on every insertion in the bounded unit test, and a_page_read_again_outlives_the_rotation covers promotion.

This keeps essentially all of the single-table win (2.07 → 2.12) while making the multi-table case work at all.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and I had the invariant wrong. Fixed in ed3271c.

Promotion moved an entry from evicting to pages, which keeps the total constant, and I stopped reasoning there. What I missed is that it can push |pages| past half — and then the rotation swaps that oversized map into evicting and keeps it, so the total ratchets up by one per round. Exactly as described.

I wrote your scenario as a test before fixing anything: insert a page, read back everything still held, repeat. With MAX_PAGES = 128 it peaked at 384 held pages, and it fails on the parent commit:

PEAK held = 384, bound = 128

The fix routes both callers through one admission point, so the rotation check cannot be bypassed:

/// Adds an entry to the live half, rotating the halves first when it is full. Both callers
/// go through here, so that promoting cannot grow the cache past its bound either
fn admit(&mut self, page_number: PageNumber, bytes: Rc<[u8]>) {
    let half = self.max_pages / 2;
    if half == 0 {
        return;
    }
    if self.pages.len() >= half {
        swap(&mut self.pages, &mut self.evicting);
        self.pages.clear();
    }
    self.pages.insert(page_number, bytes);
}

|pages| <= half now holds after every operation, and evicting is only ever assigned from pages, so |pages| + |evicting| <= max_pages follows. promoting_respects_the_bound asserts it after each round of that access pattern and is the regression test for this.

No performance cost: still 2.12 reads/lookup on both the single-table and A-then-B workloads.


Generated by Claude Code

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 61d0b7d to 43a553c Compare August 20, 2026 17:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43a553cac4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
touch fewer pages. The default implementation returns a whole key, leaving existing `Key`
implementations unchanged; `&[u8]`, `&str`, and `String` keys now store minimal prefixes.
`Option` keys shorten their payload, when the wrapped type is variable width.
* Improve multithreaded read scaling. Each thread now caches the top of the btrees it reads,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the current AI-authored commit

Fresh evidence for the reviewed commit: git show -s --format='%an <%ae> / %cn <%ce>' e1498a01b50ff8fd4f481f6844a7003ceb0efcfb reports Codex <codex@openai.com> / Codex <codex@openai.com>. The commit-authors job in .github/workflows/ci.yml:25-30 rejects PR commits whose author or committer contains codex, so this commit will block CI until it uses the human author's identity and includes the required Assisted-by: trailer.

AGENTS.md reference: AGENTS.md:L37-L41

Useful? React with 👍 / 👎.

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch 2 times, most recently from 0df6351 to 4f55e18 Compare August 21, 2026 04:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f55e18460

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
`Option` keys shorten their payload, when the wrapped type is variable width.
* Add `Key::min_encoded_key()`, the encoding of a key type's smallest value. Implementing it is
optional, and lets container types holding that key store shorter separators.
* Improve multithreaded read scaling. Each thread now caches the top of the btrees it reads,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the AI-authored commit

Fresh evidence for this review: git show -s --format='%an <%ae> / %cn <%ce>' d1a441599916a535e4f22e3c9182d02b3187e3f1 reports Codex <codex@openai.com> / Codex <codex@openai.com>. The commit-authors job in .github/workflows/ci.yml:25-30 rejects PR commits whose author or committer contains codex, so this commit will block CI until it uses the human author's identity and includes the required Assisted-by: trailer.

AGENTS.md reference: AGENTS.md:L37-L41

Useful? React with 👍 / 👎.

return Some(bytes.clone());
}
let bytes = self.evicting.remove(&page_number)?;
self.pages.insert(page_number, bytes.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the page cap when promoting cached entries

When one snapshot repeatedly accesses every page in evicting before introducing a new page, this promotion can make pages larger than its half-cache target. The next insertion swaps that entire oversized map into evicting without discarding it, and repeating the pattern retains one additional page per round, so a long-lived reader cycling through an expanding set of tables can grow this supposedly bounded per-thread cache without limit. Apply the capacity/rotation policy during promotion as well as insertion.

Useful? React with 👍 / 👎.

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 4f55e18 to ed3271c Compare August 21, 2026 04:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed3271ccdc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
`Option` keys shorten their payload, when the wrapped type is variable width.
* Add `Key::min_encoded_key()`, the encoding of a key type's smallest value. Implementing it is
optional, and lets container types holding that key store shorter separators.
* Improve multithreaded read scaling. Each thread now caches the top of the btrees it reads,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the AI-authored commit

Fresh evidence for the reviewed commit: git show -s --format='%an <%ae> / %cn <%ce>' 8d4729c6df2c18bdfcd587fc440ba869033f7e18 reports Codex <codex@openai.com> / Codex <codex@openai.com>. The commit-authors job in .github/workflows/ci.yml:18-30 rejects PR commits whose author or committer contains codex, so this commit will block CI until it uses the human author's identity and includes the required Assisted-by: trailer.

AGENTS.md reference: AGENTS.md:L37-L41

Useful? React with 👍 / 👎.

@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch 2 times, most recently from 32ba5e9 to 488176b Compare August 22, 2026 14:21
Concurrent readers contend in the shared page cache: every page lookup
acquires a lock stripe, sets the LRU second-chance flag, and updates the
page's Arc refcount. For the hottest pages, the top of each btree, those
writes make cache lines ping-pong between cores and cap multithreaded
read scaling well below linear.

Each thread now keeps a small fixed-size cache of private copies of the
branch pages one level below the roots it reads, and lookups in read
transactions serve those pages from it. Hits touch only thread-local
memory, so the cache needs no locks or atomics (Rc and RefCell suffice)
and cannot cause any cross-thread contention. Builds without std have no
thread local storage, so they get no cache and lookups are unchanged.

Only that one level is cached. A level holds roughly a page's worth of
children per page of the level above it, so the level below the root is
small enough for a snapshot's pages to fit, while the next one down is
already far too large. Caching a level that does not fit is worse than
not caching it at all, because admitting a page copies it, and a level
that thrashes pays that copy on nearly every lookup -- far more than the
shared cache lookup it saves. For the same reason the cache stops
admitting once full rather than replacing entries.

Entries are tagged with the database instance and snapshot generation
(the read transaction's id) and validated on lookup. Bytes of a page
number cannot change within a generation: snapshots are copy-on-write
and freed pages are only reused by later write transactions, which
advance the generation. Reloading a database in place is the exception,
since rolling back a non-durable commit rewinds the id and later commits
reuse it for different data, so clear_cache_and_reload() bumps the
instance id. Stale entries never match and are simply overwritten. Write
transactions do not use the cache, since they can free and reallocate
pages within one generation. Entries hold copies of the page bytes
rather than references into the shared page cache, so the shared cache
stays free to evict, and the open-page accounting is unaffected.

The cache holds at most 128 single pages per thread with a live read
transaction (512KiB with 4KiB pages), and releases them when the read
transaction is dropped, so threads that read once do not retain them and
neither many tables nor many transactions grow it.

Random reads measured with lmdb_benchmark on a 16 core 9950X3D are about
1.06x faster single threaded, 1.13x with 8 threads, 1.20x with 16 and
1.57x with 32. Range reads and writes are unchanged.

Assisted-by: Claude Code
@cberner
cberner force-pushed the claude/redb-transaction-cache-3st6g0 branch from 488176b to b23404c Compare August 22, 2026 15:36
@cberner cberner closed this Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant