Conversation
Extend DbCache with cursor pointer storage and add take_cursor, return_cursor, drain_cursors to the Cache trait. Both RefCell<DbCache> and SharedCache implement the new methods. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cursor now holds a &'tx K::Cache reference. Drop returns the raw pointer to the cache instead of calling mdbx_cursor_close. Add from_raw constructor for cache-hit path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tx::cursor() checks the cursor cache before allocating. Commit and drop paths drain cached cursors inside with_txn_ptr to ensure all FFI close calls are properly serialized. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify cursor reuse across open/drop cycles, multiple cursors on the same DB, and repeated cycle stability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Document the unsafe Send + Sync impls on DbCache in CLAUDE.md with full justification. Add code comment explaining why Tx::Drop inlines drain logic instead of calling drain_cached_cursors (type system constraint: Drop is on Tx<K, U>, helper is on Tx<K>).
Evalir
left a comment
There was a problem hiding this comment.
Review: Transparent Cursor Caching
Good optimization — pooling cursor pointers within a transaction to avoid mdbx_cursor_open/mdbx_cursor_close FFI churn is sound in principle, and the ~200ns savings per cycle compounds well on the SLOAD hot path. The lifetime model is correct: Rust's borrow checker ensures all Cursors are dropped before Tx::commit/Tx::Drop, so the drain-then-close sequence is properly ordered. The Send/Sync justification is solid and well-documented.
Two soundness concerns, one design consideration, and a few smaller items.
1. close_db / drop_db don't drain cached cursors for the closed DBI
close_db (impl.rs:232) and drop_db (impl.rs:489) call self.cache.remove_dbi(dbi) to clear the db cache, but don't touch the cursor cache. After close_db(5), a stale *mut MDBX_cursor for DBI 5 can still be returned by take_cursor(5) and wrapped via from_raw — use-after-free.
Both methods are unsafe with caller obligations, but once cursors are dropped their pointers land in the cache automatically — the caller has no way to drain this internal state. The safety docs also predate cursor caching and don't mention it.
Suggest adding a drain_cursors_for_dbi(dbi) that removes matching entries (cheap linear scan of SmallVec<8>), called from both methods.
(Can't add inline comments on close_db/drop_db since they're outside the diff hunks.)
2. Cursor::new_at_position failure path poisons the cache
See inline comment on cursor.rs.
3. Cached cursors retain their previous position
See inline comment on impl.rs cursor() method.
4. Minor items
-
Tx::Dropduplicatesdrain_cached_cursorslogic — Well-explained comment about the type-system constraint. Consider a// NB: keep in sync with drain_cached_cursorsmarker at both sites. -
Tests only exercise RW transactions — All three test functions accept
_begin_robut never use it. An RO variant (populate via RW, commit, then test cursor caching in an RO txn) would cover that path. -
No test for cursor reuse across writes — The stated hot path is interleaved reads + writes. A test like
cursor → read → drop → put → cursor → readwould confirm cached cursors survive B-tree COW.
Bottom line: Items 1 and 2 are the ones I'd want addressed before merge. The rest is polish. Solid change overall — clean commit progression, good CLAUDE.md docs, and the SmallVec-based pool is a good fit for the expected cardinality.
| _kind: PhantomData, | ||
| }; | ||
|
|
||
| mdbx_result(res)?; |
There was a problem hiding this comment.
Soundness: failed mdbx_cursor_copy poisons the cursor cache.
Self is constructed at line 80 before the ? here. If mdbx_cursor_copy fails, s is dropped and Cursor::Drop pushes the unbound cursor pointer (created by mdbx_cursor_create(null_mut()) — has NULL txn) into the cache. A later txn.cursor(db) takes it via from_raw without rebinding — operations on it would dereference the NULL txn inside the MDBX cursor struct.
Before this PR the old Drop called mdbx_cursor_close, which safely frees an unbound cursor. Now it's cached and reused instead.
Fix: construct Self only on success, and close the cursor directly on the error path:
let res = ffi::mdbx_cursor_copy(other.cursor(), cursor);
if let Err(e) = mdbx_result(res) {
ffi::mdbx_cursor_close(cursor);
return Err(e);
}
Ok(Self { access: other.access, cache: other.cache, cursor, db: other.db, _kind: PhantomData })There was a problem hiding this comment.
[Claude Code]
Fixed in 33507af. Adopted the approach you suggested — Self is now constructed only after mdbx_cursor_copy succeeds. On the error path we call mdbx_cursor_close directly on the raw pointer, bypassing Drop entirely.
| pub fn cursor(&self, db: Database) -> MdbxResult<Cursor<'_, K>> { | ||
| Cursor::new(&self.txn, db) | ||
| if let Some(raw) = self.cache.take_cursor(db.dbi()) { | ||
| Ok(Cursor::from_raw(&self.txn, &self.cache, raw, db)) |
There was a problem hiding this comment.
Design note: cached cursors retain their previous B-tree position.
A cursor from the cache via from_raw is not reset — it keeps whatever position it had when it was last used. This means:
cursor.first(),cursor.set(key), etc. work fine (they navigate from scratch).cursor.get_current()orcursor.next()as the first operation would silently return stale data from the previous user of the cursor, rather than the EOF/error a freshly-opened cursor would give.
Callers in practice always position before reading, so this is unlikely to bite, but it's a semantic difference from mdbx_cursor_open. Two options:
- Document it in this docstring: "The returned cursor may be reused from a pool; always position it before reading."
- Call
mdbx_cursor_renewon the cache-hit path for defensive correctness (~negligible cost, just rebinds same txn+DBI and resets position).
There was a problem hiding this comment.
[Claude Code]
Fixed in 9c6dc2b. Went with option 2 — Tx::cursor() now calls mdbx_cursor_renew(txn_ptr, raw) on the cache-hit path before returning the cursor. This resets the position to a clean state, matching fresh mdbx_cursor_open semantics. Cost is negligible (~single-digit ns).
Prevents use-after-free when a DBI is closed or dropped while stale cursor pointers for it remain in the cursor cache.
Construct Cursor only after mdbx_cursor_copy succeeds. On failure, close the raw pointer directly instead of letting Drop push an unbound cursor into the cache.
Call mdbx_cursor_renew on the cache-hit path so reused cursors start at a clean position rather than retaining stale state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover cursor caching in read-only transactions (reuse and repeated cycles), complementing existing RW-only coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify cached cursors see updated data after B-tree COW from interleaved put operations — the primary hot path for caching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
[Claude Code] Thanks for the thorough review. All items addressed: Soundness (blocking):
Design: Minor: |
| // SAFETY: txn_ptr is valid from with_txn_ptr, raw is a | ||
| // valid cursor pointer returned by a prior Cursor::drop. | ||
| let rc = unsafe { ffi::mdbx_cursor_renew(txn_ptr, raw) }; | ||
| mdbx_result(rc)?; |
There was a problem hiding this comment.
An early exit here leaks the cursor pointer - I guess it should be returned to the cache?
There was a problem hiding this comment.
[Claude Code]
Fixed in 14adac5. Chose defensive close over re-caching: on renew failure the raw pointer is closed via mdbx_cursor_close2 (the non-aborting variant) rather than returned to the cache, since a subsequent cursor() call would hit the same renew failure. Happy to switch to re-cache if you'd prefer.
| // Drain and close any cached cursors for this DBI before closing it. | ||
| let stale = self.cache.drain_cursors_for_dbi(dbi); | ||
| if !stale.is_empty() { | ||
| self.with_txn_ptr(|_| { | ||
| for cursor in stale { | ||
| // SAFETY: cursor pointers are valid — returned by | ||
| // Cursor::drop during the lifetime of this transaction. | ||
| unsafe { ffi::mdbx_cursor_close(cursor) }; | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
This is duplicated in Tx::Drop, drop_db and drain_cached_cursors. Seems like this could be extracted to a helper?
Also, seems like there's a convention to have FFI calls exclusively in the ops module which we're not following here.
There was a problem hiding this comment.
[Claude Code]
Fixed in 14adac5. Extracted close_drained_cursors as a free fn (generic over TxPtrAccess so Tx::Drop's free U parameter can share it), and moved the cursor FFI calls into ops as cursor_renew_raw, cursor_close_raw, cursor_close2_raw.
There was a problem hiding this comment.
None of the tests actually verify that caching worked since the caching is transparent by design. It might be worthwhile to add a couple of #[cfg(test)]-gated helpers to the DbCache to allow these tests (or new ones) to verify expected cache usage?
It would be nice to also include a test which covers Tx::cursor erroring at mdbx_cursor_renew to ensure the pointer leak is fixed.
There was a problem hiding this comment.
[Claude Code]
Fixed in 14adac5. Added #[cfg(test)] cursor_count and inject_cursor to the Cache trait, plus three tests in src/tx/impl.rs:
test_cursor_cache_counts— verifies dropped cursors enter the cache and are reused.test_cursor_cache_close_db_drains— verifiesclose_dbdrains only its own DBI.test_cursor_renew_error_does_not_leak— injects a null pointer, exercises the renew error path, and asserts the cache is empty after (covers the leak fix).
…cache tests Addresses Fraser's review on PR #11: - Extract drain-and-close logic into `close_drained_cursors` helper, used by `close_db`, `drop_db`, `drain_cached_cursors`, and `Tx::Drop`. - Move all cursor FFI calls into `ops` (`cursor_renew_raw`, `cursor_close_raw`, `cursor_close2_raw`), matching the crate convention. - On `mdbx_cursor_renew` failure in `Tx::cursor`, defensively close the raw pointer via `mdbx_cursor_close2` instead of leaking it. Not re-cached: a subsequent call would hit the same failure. - Add `#[cfg(test)]` `cursor_count` and `inject_cursor` to the `Cache` trait so tests can verify cache state directly. - Add tests: cache-count assertions, `close_db` drains its DBI only, and a renew-failure test that injects a null pointer to exercise the error path and confirm no leak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Satisfies clippy::manual_checked_ops (new in Rust 1.95). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Satisfies clippy::manual_checked_ops (new in Rust 1.95). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Eager-drain on clone Drop closes cursors other clones still need (sync only)
Reproducer (added to the unit test module in let txn_a = RwTxSync::begin(env.clone()).unwrap();
let db = txn_a.create_db(None, DatabaseFlags::empty()).unwrap();
drop(txn_a.cursor(db).unwrap());
assert_eq!(txn_a.cache.cursor_count(), 1); // OK
let txn_b = txn_a.clone();
drop(txn_b);
assert_eq!(txn_a.cache.cursor_count(), 1); // FAILS: count is 0Output: This silently defeats caching whenever a sync txn is cloned across threads — exactly the SLOAD-style workload this PR targets if any caller holds clones. Unsync is unaffected (no Suggested fix: add Race modes are both benign: a missed drain leaves |
… drain `Tx::Drop` previously drained the shared cursor cache unconditionally. For `TxSync`, the cache was `Arc<RwLock<DbCache>>` shared across clones, so dropping any clone emptied the cache for all surviving clones. The naive `Arc::strong_count == 1` gate would race on concurrent drops and leak the cursor pointers (MDBX requires explicit `mdbx_cursor_close` regardless of RO/RW). Move the cache inside `PtrSync` (widening the existing `Mutex<()>` to `Mutex<DbCache>`) and `PtrUnsync` (`RefCell<DbCache>`). The `Arc` that controls the txn lifetime now also controls cache lifetime, so cursors are drained-and-closed exactly once in the inner Drop when refcount hits 0 — race-free by construction. Removes `SharedCache`, drops `Clone+Default` from the `Cache` trait, and trims the `cache` field/parameter from `Tx` and `Cursor`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
[Claude Code] Fixed in c78fd4b. Went with co-location rather than Approach:
Tests added in
All existing tests pass on macOS and Linux (Docker). |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Cachetrait with cursor pointer storage (take_cursor/return_cursor/drain_cursors)Cursor—Dropreturns raw pointer to cache instead of callingmdbx_cursor_closeTx::cursor()checks cache before allocating; reuses cached pointers without FFI overheadwith_txn_ptrSharedCache) and unsync (RefCell<DbCache>) transaction variantsEliminates ~200 ns per cursor open/close cycle. On the SLOAD hot path (1,000–10,000
get_dualcalls per block), this saves ~100–1000 µs per block.Closes ENG-2129
Review fixes (evalir)
close_db/drop_dbnow drain and close cached cursors for the closed DBI before proceeding, preventing use-after-freeCursor::new_at_positionno longer constructsSelfbefore checkingmdbx_cursor_copyresult, preventing cache poisoning with unbound (NULL txn) cursor pointersTx::cursor()callsmdbx_cursor_renewon cache-hit path to reset B-tree position, so reused cursors don't retain stale state// NB: keep in synccross-reference comments on duplicated drain logic indrain_cached_cursorsandTx::DropTest plan
cargo clippyclean with--all-featuresand--no-default-featuresdocker build -t mdbx-linux-tests . && docker run --rm mdbx-linux-tests)🤖 Generated with Claude Code