Skip to content

perf: per-thread transient-cursor cache (RFC 0021, O-D1) - #238

Draft
gburd wants to merge 25 commits into
masterfrom
perf/cursor-cache
Draft

gburd wants to merge 25 commits into
masterfrom
perf/cursor-cache

Conversation

@gburd

@gburd gburd commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Per-thread transient-cursor cache (RFC 0021, review item O-D1)

DB->get/DB->put with no application cursor allocate a transient cursor per
call (__db_cursor_int) and free it per call (__dbc_close); both ends take a
per-handle cursor-queue partition mutex. On a shared DB_THREAD handle this is
the dominant read-path cost at scale — 85% of the t=96 profile in
test/bench/BATCHED-READS-RESULTS.md, and the named cause of RFC 0007's t=96
per-key regression.

This PR adds a small process-local, per-thread cache of idle transient
cursors keyed by DB handle, so the common path reuses one without touching a
queue mutex.

Design (RFC 0021, committed first)

  • A fixed 4-slot per-thread TLS cache in db_cam.c, keyed on (DB *, DBTYPE),
    behind the same TLS portability guard as src/mutex/mut_order.c. With no TLS
    the cache compiles to a no-op and behaviour matches master.
  • An eligible transient cursor (DBC_TRANSIENT, no off-page-dup cursor, own
    default locker, no txn, threaded non-partitioned non-secondary handle) is left
    on the handle's active queue across __dbc_close instead of moving to the
    free queue. While idle its cp->pgno is PGNO_INVALID and it holds no lock,
    so __db_walk_cursors's adjustment callbacks never match it; a reused cursor
    is already on the queue before it positions, preserving the empty-partition
    skip's happens-before invariant in db.c. DB->del never sets
    DB_CURSOR_TRANSIENT, so cached cursors never carry a pending delete.
  • __db_refresh bumps a process-global epoch (so other threads' stale TLS slots
    miss without dereferencing a freed DB) and brackets its active-queue walk so
    re-entrant __dbc_close does not re-cache cursors being destroyed.
  • DB_NO_CURSOR_CACHE disables the cache (A/B + kill switch), matching the
    fork's DB_NO_OPTREAD / DB_NO_FNAME_CACHE pattern.

No on-disk/log/region/ABI format change. sizeof(DB), sizeof(DBC) and
DB_THREAD_INFO are unchanged (proven by run_handle_sizes.sh); env_sig.c
and the region signature are byte-identical to master. dbinc_auto regenerated
via dist/s_include.

Scope note

The cache engages only for non-transactional DB->get/DB->put. A
txn-associated cursor cannot be cached (its locker belongs to a txn that
commits/aborts between ops), so xrel_kv (all-transactional) shows no delta by
construction; the win is on the non-txn path (the motivating profile's
workload).

Tests (fail without the change, pass with it)

  • test/db/run_cursorcache_adjust.sh (+ driver): Part A opens/uses/closes a
    DB_THREAD handle 400 times (stale-handle stress); Part B is a concurrent
    put/get storm on crowded shared leaves with verification. The sabotage arm
    rebuilds db_cam.o with -DDBC_CACHE_SABOTAGE (stale-handle epoch guard
    disabled) and must FAIL — it does, deterministically, with an ASan
    use-after-free at __dbc_cache_put; the correct arm PASSES.
  • --enable-diagnostic test/db tier: 30 PASS / 4 SKIP (env) / 0 FAIL.
  • TCL run_method -btree test001..010 and -hash test001..010: 20/20 PASS.

Performance (c6id.metal, 128 vCPU, XFS NVMe; batch_bench indiv, 6 reps)

t base k/s branch k/s ratio p99 base→branch
1 544,331 587,380 1.08× 2 → 2 µs
32 1,636,261 1,973,034 1.21× 33 → 16 µs
64 1,479,038 2,267,230 1.53× 66 → 33 µs
96 997,152 2,802,036 2.81× 262 → 33 µs

All CV ≤ 5.2%. Base declines past t=32 (the cursor-mutex wall); the branch keeps
climbing. The t=96 perf profile shows the machine idle 67% (base) vs 47%
(branch) — base leaves two-thirds of the cores blocked on the cursor-queue
mutex. Full data and profile in test/bench/CURSOR-CACHE-RESULTS.md.

Draft: opened for the lead to review and merge.

gburd added 8 commits November 2, 2026 16:10
RFC-first for review item O-D1 (perf rank 1): DB->get/put/del allocate and
free a transient cursor per call, and both ends take a cursor-queue partition
mutex that dominates the read path (85% of the t=96 profile,
test/bench/BATCHED-READS-RESULTS.md).

0021 proposes a process-local, per-thread cache of idle transient cursors
keyed by DB handle. Cached cursors stay on the handle's active queue, so
__db_walk_cursors still sees every positioned cursor (the empty-partition-skip
invariant is preserved and argued in Design S5). No on-disk/log/region/ABI
format change; sizeof(DB) and sizeof(DBC) unchanged. DB_THREAD_INFO is a
shared-region struct and cannot hold process-local pointers, so the cache uses
the existing DB_LO_TLS thread-local pattern.
Reuse DB->get/put transient cursors from process-local thread-local storage so
the common path skips the cursor-queue partition mutex at both ends
(__db_cursor_int alloc and __dbc_close free), the 85% of the t=96 read profile
in test/bench/BATCHED-READS-RESULTS.md.

Mechanism:
- A 4-slot per-thread TLS cache in db_cam.c keyed by (DB handle, DBTYPE),
  guarded by the same TLS portability macro as src/mutex/mut_order.c; with no
  TLS the cache is a no-op and behaviour matches master.
- An eligible transient cursor (DBC_TRANSIENT, no opd, own default locker, no
  txn, threaded non-partitioned non-secondary handle) is left ON the handle's
  active queue across __dbc_close instead of moving to the free queue. While
  idle its cp->pgno is PGNO_INVALID and it holds no lock, so __db_walk_cursors
  adjustment callbacks never match it; a reused cursor is already on the queue
  before it positions, preserving the empty-partition-skip invariant in db.c.
  DB->del never sets DB_CURSOR_TRANSIENT, so cached cursors never carry a
  pending delete and am_close does no cursor adjustment.
- __db_cursor_int serves a cache hit without any queue mutex and skips the
  final active-queue insert; a refresh failure returns the cursor to the free
  queue.
- __db_refresh bumps a process-global epoch (so other threads' stale TLS slots
  miss without dereferencing a freed DB) and brackets its active-queue walk
  with refresh-enter/leave so re-entrant __dbc_close does not re-cache cursors
  being destroyed.
- DB_NO_CURSOR_CACHE disables the cache (A/B + kill switch), matching the
  fork's DB_NO_OPTREAD / DB_NO_FNAME_CACHE pattern.

No on-disk/log/region/ABI format change; sizeof(DB), sizeof(DBC) and
DB_THREAD_INFO unchanged. dbinc_auto regenerated via dist/s_include.
test/db/cursorcache_adjust.c hammers one shared DB_THREAD btree handle with
concurrent transient DB->put/DB->get on crowded leaves and verifies every
value round-trips, so a cached cursor that missed a concurrent adjustment
(and so addresses the wrong record) is caught.

run_cursorcache_adjust.sh runs two arms: the real library (must PASS) and a
library whose db_cam.o is rebuilt with -DDBC_CACHE_SABOTAGE (must FAIL). The
sabotage positions a reused cached cursor while OFF the active queue, breaking
the Design S5 invariant, so a missed adjustment corrupts a value. MANIFEST
gets the db-tier entry; the runner is executable.
The first sabotage (positioning a reused cached cursor while off the active
queue) was not observable: a transient get/put is page-latch-atomic, so an
invisible positioned cursor produced the same result. Switch the sabotage to
the RESET invariant, which is observable single-threaded: -DDBC_CACHE_SABOTAGE
now skips the reuse reset in db_am.c, so a reused cached cursor keeps its prior
position and returns the wrong record. The test gains a deterministic Part A
(interleaved far-apart gets) that catches exactly that, plus the concurrent
Part B storm. The runner rebuilds db_am.o for the sabotage arm.
Reusing a cached transient cursor is safe by construction: every DB->get/put
re-searches from the root under the page latch, so a stale position or a brief
off-queue window cannot corrupt a single transient op (confirmed: both earlier
sabotages were no-ops). The one cache invariant whose failure IS observable is
the stale-handle epoch guard (RFC 0021 S4a): a closed handle destroys its
cached cursor, and the epoch bump must make a reopened handle miss the stale
slot instead of dereferencing freed memory.

The test's Part A now opens/uses/closes a DB_THREAD handle REOPENS times; the
-DDBC_CACHE_SABOTAGE build (in db_cam.c) disables the epoch guard, so Part A
faults on a dangling cached cursor under --enable-diagnostic. Part B keeps the
concurrent adjustment storm. Runner rebuilds db_cam.o for the sabotage arm.
The disabled epoch guard permits a reopened handle to reuse a freed cached
cursor, but freed memory often still looks valid, so the fault was silent.
Link the sabotage executable with AddressSanitizer (it intercepts malloc/free
process-wide, covering libdb's allocations) and run it under MALLOC_PERTURB_,
so the use-after-free aborts deterministically. The correct arm is unaffected.
The probe compiled /dev/null, which has no main, so the link always failed and
ASan was never used. Pipe a trivial main() instead.
Measured on c6id.metal (128 vCPU, XFS NVMe) against the non-transactional
per-key DB->get path (batch_bench indiv), the workload behind the
85%-cursor-lifecycle profile. 6 interleaved reps, all CV <= 5.2%.

Branch vs base (origin/master): 1.08x @t=1 (no single-thread regression),
1.53x @t=64, 2.81x @t=96; p99 262us -> 33us at t=96. Base scales to t=32 then
declines to 997k/s (the cursor-queue-mutex wall); branch keeps climbing to
2.80M/s. The t=96 perf profile shows the machine idle 67% (base) vs 47%
(branch): base leaves two-thirds of the cores blocked on the per-handle cursor
mutex; the branch recovers that time.

Adds the driver (cc_ab.sh), raw data, profile summaries and the results doc.
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

ABI diff vs v2026.10.8 (libabigail — authoritative)

Functions changes summary: 0 Removed, 1 Changed (1609 filtered out), 5 Added functions
Variables changes summary: 0 Removed, 0 Changed (1 filtered out), 0 Added variable

5 Added functions:

  [A] 'function void __db_cursor_cache_discard(ENV*, DB*)'    {__db_cursor_cache_discard}
  [A] 'function int __db_cursor_cache_get(DB*, DBTYPE, int, DBC**)'    {__db_cursor_cache_get}
  [A] 'function void __db_cursor_cache_refresh_enter(void)'    {__db_cursor_cache_refresh_enter}
  [A] 'function void __db_cursor_cache_refresh_leave(void)'    {__db_cursor_cache_refresh_leave}
  [A] 'function void __db_cursor_cache_register(DB*)'    {__db_cursor_cache_register}

1 function with some indirect sub-type change:

  [C] 'function int __add_dbregid(DB_LOG_VRFY_INFO*, VRFY_FILEREG_INFO*, int32_t, u_int32_t, DB_LSN, DBTYPE, db_pgno_t, int*)' at log_verify_util.c:1308:1 has some indirect sub-type changes:
    parameter 1 of type 'DB_LOG_VRFY_INFO*' has sub-type changes:
      in pointed to type 'typedef DB_LOG_VRFY_INFO' at db_int.h:126:1:
        underlying type 'struct __db_log_verify_info' at log_verify.h:18:1 changed:
          type size hasn't changed
          1 data member changes (11 filtered):
            type of 'DB_ENV* dbenv' changed:
              in pointed to type 'typedef DB_ENV' at db.h:174:1:
                underlying type 'struct __db_env' at db.h:2409:1 changed:
                  type size hasn't changed
                  1 data member changes (210 filtered):
                    type of 'ENV* env' changed:
                      in pointed to type 'typedef ENV' at db.h:220:1:
                        underlying type 'struct __env' at db_int.h:776:1 changed:
                          type size hasn't changed
                          1 data member changes (13 filtered):
                            type of '__dblist dblist' changed:
                              type size hasn't changed
                              1 data member changes (1 filtered):
                                type of '__db* tqh_first' changed:
                                  in pointed to type 'struct __db' at db.h:1528:1:
                                    type size hasn't changed
                                    1 data member insertion:
                                      'u_int32_t cursor_cache_gen', at offset 1056 (in bits) at db.h:1570:1
                                    no data member changes (127 filtered);

---
_Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md._

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (3) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/btree/bt_curadj.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Independent adversarial review — #238 perf/cursor-cache (RFC 0021)

Verdict: MERGE (one documentation nit, no correctness defect found).

Reviewer wrote none of this code and is read-only on the branch. Build + tests on the shared dev box (Debian 12, 32 vCPU, /data/rev/). Every database produced was run through DB->verify.

What I checked that passed

  • Builds: --enable-diagnostic --enable-test --enable-tcl ✅ and release (configure default) ✅.
  • Branch's own test: run_cursorcache_adjust.sh — correct arm PASS, -DDBC_CACHE_SABOTAGE arm FAILs with an ASan use-after-free at db_cam.c:284 (teeth confirmed).
  • Full test/db diagnostic tier: run_all.sh rc=0, 0 FAIL (incl. run_handle_sizes PASS → sizeof(DB)/sizeof(DBC) unchanged; run_curadj_dup_partition, run_bamc_prev_alias PASS).
  • Five adversarial tests I wrote, each compared branch (cache on) vs. master, and the UAF-sensitive ones re-run under ASan + MALLOC_PERTURB_ (reproducers in /scratch/libdb/rev-work/238/):
    • A1 rev238_a1_verify.c — the gap the branch test leaves: a real DB_INIT_LOCK|TXN env, 1 writer doing transient put/del (deletes drive __bam_ca_di/__bam_ca_rsplit cursor adjustment — the R2/btree: equal-key put over a fully-deleted duplicate set (R2) #244 corruption vector) + 10 readers churning the cache, then cursor-walk + DB->verify. Branch 10/10 PASS, master 10/10 PASS, ASan clean. (First draft used concurrent non-txn writers → DB_PAGE_NOTFOUND on both master and branch — an unsupported pattern, i.e. a test bug, not a regression; fixed to single-writer.)
    • A2 rev238_a2_fork.c — prime the per-thread TLS cache, fork(), child does read-only transient gets through the inherited (shared-region) handle while the parent writes; DB->verify. PASS + ASan clean (no cursor-pointer aliasing across the fork boundary).
    • A3 rev238_a3_lock_sec.c — 20 000 non-txn transient DB->get (primary, cacheable) + DB->pget (secondary, not cacheable) under DB_INIT_LOCK; asserts st_nlocks does not grow (2 → 2 across 20 000 ops → no lock retained on a cached cursor), then DB->verify of both primary and secondary. PASS.
    • A4 rev238_a4_threadexit.c — 60 rounds × 6 worker threads that cache cursors then exit without closing the handle, with periodic handle close/reopen (forces __db_refresh to reclaim parked cursors + bump the epoch). PASS + ASan clean.
    • A6 rev238_a6_cdb.c — DB_INIT_CDB env, concurrent CDB readers + writer; confirms the cache is correctly disabled (CDB_LOCKING eligibility gate) and no CDB lock is retained. Branch 5/5 PASS, master 3/3 PASS.
  • Recno reverse-split (A5): attempted a renumbering-recno reverse-split probe; it hits a pre-existing API restriction (BDB0097/BDB0630) identically on master and branch, so no regression is shown there. The theoretical __ram_ca_delete_func concern below is argued closed by code reading.

The one thing to fix (documentation, not code)

RFC 0021 §5 and the db_cam.c comment state "At release, am_close set cp->pgno = PGNO_INVALID". That is not what happens: __bamc_close → DISCARD_CUR (src/btree/bt_cursor.c:116-132) drops the page (__memp_fput) and lock (__TLPUT) but leaves cp->pgno and cp->root at their stale values. An idle cached cursor therefore sits on the active queue with a stale cp->pgno.

This is still safe, but for a different reason than the RFC gives, and the RFC should say so:

  • __bam_ca_di_func/__bam_ca_rsplit_func can match an idle cached cursor on its stale cp->pgno (e.g. after that page number is freed and reused) and mutate cp->indx/cp->pgno — but reuse wipes both: __db_cursor_int sets cp->indx = 0; cp->pgno = PGNO_INVALID unconditionally (src/db/db_am.c:472-474) and __bamc_refresh recomputes cp->root, all before any positioning search. So the transient op always re-searches from a clean state; the idle-window mutation cannot survive into a real position.
  • __ram_ca_delete_func (recno) matches on cp->internal->root and returns EEXIST; cp->root is likewise recomputed on reuse, and renumber-recno already forbids the concurrent pattern that would be needed to exploit it.

Please correct the RFC/comment to say "reuse resets cp->pgno/cp->indx/cp->root before positioning" rather than "am_close sets cp->pgno = PGNO_INVALID", so a future reader does not rely on a reset that does not occur. No code change required.

Risks from the brief and where they landed

cursor-adjustment visibility (A1 ✅), stale handle after close/reopen (sabotage arm + A4 ✅), fork/multi-process (A2 ✅), thread exit (A4 ✅), DB_THREAD gating + non-threaded off (eligibility code + A6 ✅), failchk (process-local cache dies with the process; §4 reasoning sound), secondary indices (A3 ✅), cached cursor holding a locker/lock — CDB / RU/RC / non-txn under DB_INIT_LOCK (A3 lock-count + eligibility gate ✅), stale page pin (DISCARD_CUR releases the pin; A1/A4 verify ✅).

Nothing blocks the merge.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead review notes for the independent reviewer and the author. These come from reading the code; nothing here is measured yet.

  1. The cache can steal a cursor while the handle is closing (a race).

    • __db_refresh bumps __dbc_cache_global_epoch and then destroys every cursor on the handle's active queue. That includes cursors sitting idle in other threads' TLS slots.
    • The epoch is a plain u_int32_t with no barrier: no atomic or acquire/release.
    • Take thread B in __db_cursor_cache_get for the same dbp. If it read the epoch before the bump, or if the bump is not yet visible to it, B passes the gen == epoch check and takes e->dbc.
    • Meanwhile thread A in __db_refresh is walking the active queue and calling __dbc_close on that same cursor, then destroying it. That is a use-after-free, or two owners of one DBC.
    • The DB->close contract says no other thread may use the handle during close, which narrows this. But the cache makes a cursor reachable from another thread without a handle call in progress, so that contract no longer covers it. Please build a test where thread B does DB->get in a tight loop and thread A closes the handle when B's loop ends, plus a variant that closes a different handle (to bump the epoch).
    • The fix is probably an atomic epoch read with acquire in get, a release in discard, and a recheck of the epoch after taking the slot. Or __db_refresh could claim each cached cursor under the partition mutex, with the cache's take also using a CAS on the slot.
  2. A reused handle address with an unchanged epoch. e->dbp == dbp is the identity key. If a handle is closed (epoch bumped) and a new one opened at the same address, slots filled after the bump carry the new epoch and point at the new handle's cursors, so that is fine. Please confirm that the reopen path cannot leave a slot carrying the new epoch and an old cursor.

  3. fork().

    • A child inherits the parent's TLS slots, which point at cursors on the parent's active queues. In a process-private handle these are copies, so they are probably harmless.
    • With DB_THREAD handles used across fork, which Berkeley DB says is unsupported anyway, this would be undefined.
    • Document it at least.
  4. Thread exit. Up to 4 idle cursors per exited thread stay on the active queue until handle close (already noted by the author). Each one is walked by every __db_walk_cursors on every insert or delete for the life of the handle. That partly undoes the R2 empty-partition skip from lock/db: deadlock detector skips the graph when no cycle is possible; cursor walk skips idle partitions #228. A thread-pool workload makes this unbounded over time: N threads × 4. Consider a pthread key destructor that returns the slots.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up (lead-directed): two concerns tested — revising to FIX FIRST

I built the branch under ASan and TSan with high iteration counts and ran the two scenarios the lead raised. Both are real. One is benign-on-x86; the other is a measurable regression. I am revising my earlier MERGE verdict to FIX FIRST. Reproducers in /scratch/libdb/rev-work/238/ (rev238_a7_epochrace.c, rev238_a8_parkcost.c).

Concern 1 — epoch data race: REAL, benign on x86-TSO, not portable

__dbc_cache_global_epoch is a plain u_int32_t written in __db_cursor_cache_discard (src/db/db_cam.c:321, ++) during DB->close→__db_refresh, and read in __dbc_cache_put/__db_cursor_cache_get (db_cam.c:255). TSan on the branch confirms the race directly:

WARNING: ThreadSanitizer: data race
  Write of size 4 ... __db_cursor_cache_discard db_cam.c:321  <- __db_refresh <- __db_close (thread closing a handle)
  Previous read of size 4 ... __dbc_cache_put db_cam.c:255    <- __dbc_close <- __db_get (thread doing DB->get)
  Location is global '__dbc_cache_global_epoch'

Severity: low on x86, but fix it. My ARM-2 probe (readers hammer DB->get on handle H1 with no reader lock, while a second thread open/closes a different handle H2 in a tight loop — every H2 close bumps the global epoch) ran clean under ASan: no use-after-free, no value mismatch. So on x86-64 (aligned 4-byte access is atomic) the racy read only ever yields old-or-new, i.e. a spurious cache miss — exactly what the RFC §4a claims. But the C memory model makes this UB, and on a weakly-ordered target (ARM64/POWER) the epoch read is not ordered against the slot-pointer read, so "stale read ⇒ safe miss" is not guaranteed there. The fix is mechanical and the codebase already has the tools: make the epoch a db_atomic_t and use atomic_read/atomic_inc (the same primitives SSI uses for nsireaders/si_ref). The legal close-under-rwlock case (ARM 1) was ASan-clean.

Concern 2 — parked cursors degrade inserts on a long-lived handle: REAL, scales, merge-blocker

On thread exit, a thread's up-to-DBC_CACHE_SLOTS (4) idle cached cursors stay on the handle's active queue until the handle closes (RFC §4 "Thread exit"). Every index-shifting insert/delete then walks them via __bam_ca_di→__db_walk_cursors. For the realistic connection-pool / request-worker pattern (one long-lived shared handle, a churning thread pool), this accumulates and degrades every subsequent write.

rev238_a8_parkcost.c measures single-thread insert time on one handle, before vs after a pool of worker threads fills its cache slots and exits. Release build (configure default), inserts batched in one txn so the cost is CPU not fsync, 300 k inserts/phase, ratio = phase1/phase0:

arm ratio (phase1/phase0)
branch, cache ON, 64 threads exited 1.25
branch, cache ON, 16 threads 1.14
branch, cache ON, 128 threads 1.40
branch, cache ON, 256 threads 2.30
branch, cache OFF (DB_NO_CURSOR_CACHE=1), 64 1.00
master, 64 0.99–1.00

Inserts on a handle churned by 256 exited threads run 2.3× slower, and it grows with the pool. Same binary with the cache disabled, and master, are flat — so this is the parked cursors, not noise. It persists for the life of the handle (parked cursors are reclaimed only at handle close). The RFC calls the thread-exit leak "bounded, correct, and no worse than an application that opened and leaked that many cursors" — but the measured cost on the write path is real and unbounded in pool-churn, and the RFC's own §4 flags the proactive THREAD_OUT flush as "a possible refinement … not implemented."

Recommended fix: implement the THREAD_OUT/thread-exit flush that returns an exiting thread's cached cursors to the free queue (where master-parity leaves them), so a churning pool cannot pile inert cursors on the active queue. Until then the write-path regression is real for a common deployment shape.

Everything else from the earlier review still stands

Builds, own run_cursorcache_adjust (correct PASS / sabotage FAIL), full diag db-tier 0 FAIL, and adversarial A1 (adjustment+del+DB->verify), A2 (fork), A3 (lock-accounting+secondary), A4 (thread-exit+reopen), A6 (CDB) all PASS branch==master, ASan-clean. The read-path win is genuine. The two items above should land before merge (concern 2 especially); concern 1 can be a one-line atomics change folded in.

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead: BLOCKER, worse than the two FIX FIRST items. A cached cursor is orphaned on every handle close anywhere in the process. No thread exit is needed.

__db_cursor_cache_get / __dbc_cache_put: on an epoch mismatch, they set every slot's dbp/dbc to NULL "without dereferencing them -- the cursors they named were destroyed by __db_refresh". That holds only for the handle that was closed. A slot whose cursor belongs to a handle that is still open is dropped while the cursor stays DBC_ACTIVE on that handle's active queue, and nothing ever closes it. Every later insert or delete walks it (__bam_ca_di → __db_walk_cursors), and the memory is held until the handle closes.

Reproducer /scratch/libdb/rev-work/238/rev238_a9_orphan.c (one thread): DB->get on long-lived handle H, then open+close an unrelated handle T, repeated N times; then time 100k inserts on H. Release build:

N cycles master branch, cache on branch, DB_NO_CURSOR_CACHE
5,000 1.04×, +0 KB 32×, +7.6 MB 1.04×, +0 KB
20,000 1.04×, +0 KB 237×, +32.7 MB 1.03×, +0 KB

This happens to any application that opens and closes a short-lived handle (a sub-database, a temp table, a DB->verify) alongside a long-lived one. The growth is unbounded and linear.

The fix has to handle three things together:

  1. Stale slots: tell a slot whose handle was closed (pointer dangling: drop it) from a slot whose handle is live (cursor valid: return it to its free queue, or keep it), without dereferencing a dangling pointer. A process-wide epoch cannot do this.
  2. Thread exit: flush the exiting thread's slots (the reviewer's item).
  3. Atomic epoch (the reviewer's item), if an epoch survives the redesign.

Until then this PR must not merge.

gburd added 5 commits November 3, 2026 02:21
The #238 transient-cursor cache had three defects found in review:

BLOCKER (orphan): the process-global epoch was used as a validity stamp.
A close of ANY handle bumped it, so a thread's next cache get/put treated
EVERY slot as stale and NULLed them all -- including slots whose handle was
still open.  That dropped a live handle's cached cursor from its slot while
the cursor stayed DBC_ACTIVE on the handle's active queue forever.  Every
later insert/delete walked the orphan via __db_walk_cursors, unbounded: one
thread doing get-on-H + open/close-of-T for 20k cycles made inserts on H
237x slower and added 32.7 MB.

Fix: the epoch is now only a CHANGE SIGNAL.  When a thread sees it moved, it
RECONCILES its own slots -- keeping those whose handle is still live, dropping
only those whose handle closed.  Liveness is decided by walking env->dblist
under mtx_dblist and matching a new per-handle, never-reused serial
(DB->cursor_cache_gen, assigned at open from a process-global atomic counter),
never by dereferencing a possibly-dangling dbp (ABA-safe).  A live handle's
slot is therefore never orphaned; a closed handle's cursor was already
destroyed by __db_refresh's active-queue walk, so the slot is just dropped.
The closing thread also clears its own slots for the handle at close, so no
slot ever outlives its env and reconcile's env->dblist deref is always safe.

Thread-exit flush: a pthread-key destructor (where pthreads back the mutexes)
returns an exiting thread's live cached cursors to their handles' free queues,
so a churning thread pool cannot pile inert cursors on the active queue
(review concern 2: inserts 2.3x slower after 256 pool threads exit).

Atomic epoch: __dbc_cache_global_epoch and the serial counter are now
db_atomic_t with atomic_read/atomic_inc, fixing the data race on the plain
u_int32_t (review concern 1, TSan-confirmed; UB on weakly ordered CPUs).

DB grows by one u_int32_t (cursor_cache_gen); it is a process-local handle,
not a region struct, so this is ABI-only, recorded via handle_sizes under the
#234 soname bump.  No on-disk, log or region format change.

ABI-BREAK-INTENDED (sizeof(DB) +8 with padding; coordinated with #234 soname).
…0021)

The orphan fix added a second stale-slot guard: the closing thread now clears
its own cache slots at handle close, so the single-thread reopen loop in
cursorcache_adjust part A is safe by self-clear even without the reconcile
sweep.  The sabotage arm must therefore disable BOTH guards to recreate the
use-after-free, so -DDBC_CACHE_SABOTAGE now also skips the self-clear in
__db_cursor_cache_discard.  Sabotage arm FAILs (ASan UAF in
__dbc_cache_free_cursor), correct arm PASSes.
…e (RFC 0021)

Two CI-safe test/db runners that reproduce review #238's defects and assert
they stay fixed:

  run_cursorcache_orphan   -- a long-lived handle H, a loop of DB->get on H
    plus open/close of an unrelated handle T.  Pre-fix: inserts >20x slower,
    RSS +tens of MB (the orphaned cursors on H's active queue).  Fixed:
    insert ratio ~1.0, RSS flat.  FAIL if ratio > 2 or RSS grows > 2 MB.

  run_cursorcache_parkcost -- single-thread insert time on a shared handle
    before and after 256 worker threads fill their cache slots and EXIT.
    Pre-fix: ratio ~2.1 at 256 threads (parked cursors walked by every
    insert).  Fixed: ~1.0.  FAIL if ratio > 1.5.

Dev-box teeth: orphan FAIL 23.98 (pre-fix) -> PASS 1.02 (fix); parkcost
FAIL 2.14 (pre-fix) -> PASS 1.03 (fix).  Both are bounded/5k-cycle/256-thread
and finish in well under their timeouts.  MANIFEST rows + +x set.
Update RFC 0021 to match the review-#238 fixes: the epoch is a change signal
(not a validity stamp), a per-handle never-reused serial + env->dblist walk
tells a live slot from a stale one (ABA-safe), the closing thread clears its
own slots so no slot outlives its env, the thread-exit pthread-key destructor
is now implemented, and epoch/serial are db_atomic_t.  Also records that
DB->cursor_cache_gen costs no bytes (fits existing padding, sizeof(DB)
unchanged) and corrects the \u00a75 claim that am_close zeroes cp->pgno -- it does
not; the reuse-time reset is what makes an idle cached cursor safe.
c6id.metal, 6 interleaved reps, fixed branch vs the fork-point base vs the
same binary with the cache disabled.  The read-path win survives the orphan +
thread-exit + atomic-epoch fixes unchanged: 2.81x at t=96, 1.39x at t=64; the
cache-off arm tracks base at every point, so the delta is the cache.  Profile:
base burns ~90% of CPU in __db_tas_mutex_lock_int (the cursor-queue mutex), the
branch's top symbol is __bam_cmp -- the contention is gone and the time is now
the real B-tree get.  sizeof(DB) unchanged (1744), so no ABI size change.
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

P1b: the three #238 defects are fixed (BLOCKER + thread-exit + atomic epoch)

Branch perf/cursor-cache now addresses all three findings. Summary of the design, the gates, and the re-measured win.

Design

The root cause of the BLOCKER was using the process-global epoch as a validity stamp: a close of any handle bumped it, so a thread's next transient get/put NULLed every slot — including slots whose handle was still open — leaving those live cursors DBC_ACTIVE on the active queue forever (the orphan).

The epoch is now only a change signal. When a thread sees it moved, it reconciles its own slots individually:

  • validate each slot by walking env->dblist under mtx_dblist and matching a new per-handle, never-reused serial DB->cursor_cache_gen (assigned at open from a process-global atomic counter); the dangling dbp is never dereferenced — only handles found live in dblist are read, which defeats ABA (reused heap address → serial mismatch → drop);
  • a live handle's slot is kept (cursor stays valid and reusable) → no orphan;
  • a closed handle's slot is dropped (its cursor was already destroyed by that handle's __db_refresh).

Plus:

  • The closing thread clears its own slots for the handle at close, so no slot ever outlives its env and the reconcile env->dblist deref is always on a live env.
  • Thread-exit flush: a pthread_key destructor (where pthreads back the mutexes) runs in the exiting thread and returns its live cached cursors to their handles' free queues — a churning pool can no longer pile inert cursors on the active queue.
  • Atomic epoch: __dbc_cache_global_epoch and the serial counter are db_atomic_t (atomic_read/atomic_inc), fixing the data race on the plain u_int32_t.

sizeof(DB) is unchanged (1744): cursor_cache_gen lands in the existing 4-byte padding hole after adj_fileid, so there is no ABI size change and no escape token is needed. sizeof(DBC), DB_THREAD_INFO, and the region signature are all unchanged. (My first commit message says ABI-BREAK-INTENDED; that turned out to be unnecessary — the field costs no bytes. Noted here and in the RFC.)

Reproducer gates (dev box --enable-diagnostic; metal numbers below)

reproducer pre-fix head (6bed835) fixed
rev238_a9_orphan, 20k cycles ratio 113.9×, +33.5 MB 1.01×, +0 KB
rev238_a8_parkcost, 256 threads ratio 2.05× 0.999× (16/128 also flat)

Each fails on the current head and passes with the fix. Added as bounded CI runners:

  • run_cursorcache_orphan (FAIL if ratio > 2 or RSS > 2 MB): pre-fix FAIL 23.98×/+7.9 MB, fix PASS 1.02×/+0 KB.
  • run_cursorcache_parkcost (POOL=256, FAIL if ratio > 1.5): pre-fix FAIL 2.14×, fix PASS 1.03×.

Both have MANIFEST rows and +x.

Sanitizers

  • A1–A4, A6 (rev238_run.sh + extras) under ASan: clean, branch==master.
  • A7 epoch race under TSan: PASS, zero races on __dbc_cache_global_epoch or cursor_cache_gen (the prototype's race is gone). The remaining TSan warnings are all pre-existing BDB mutex/lock/pid patterns.

Functional gates

  • run_cursorcache_adjust: correct PASS / sabotage FAIL (ASan use-after-free in __dbc_cache_free_cursor). The sabotage now disables both stale-slot guards (self-clear + reconcile), since the orphan fix added a second layer.
  • run_handle_sizes PASS (sizeof(DB)/sizeof(DBC) unchanged).
  • Diagnostic test/db tier run_all rc=0, 0 FAIL.
  • TCL btree+hash test001–010: 20/20 PASS.

The read-path win survives (c6id.metal, 6 interleaved reps, CV ≤ 6.4%)

t base (k/s) branch (k/s) cache-off (k/s) br/base br/off
1 521,420 548,624 510,240 1.05× 1.08×
16 1,280,632 1,470,616 1,319,645 1.15× 1.11×
32 1,663,194 1,896,430 1,654,966 1.14× 1.15×
64 1,603,846 2,224,867 1,607,494 1.39× 1.38×
96 991,014 2,780,778 980,008 2.81× 2.84×

cache-off (same binary, DB_NO_CURSOR_CACHE=1) tracks base at every point, so the delta is entirely the cache. 2.81× at t=96 is identical to the prototype. Profile at t=96: base burns ~90% of CPU in __db_tas_mutex_lock_int (the cursor-queue mutex); the branch's top symbol is __bam_cmp — the contention is gone and the time is the real B-tree get.

(The t=64 ratio reads 1.39× vs the prototype's 1.53× only because this run's base t=64 median was higher/noisier; the same-binary branch/cache-off = 1.38× is the apples-to-apples figure.)

RFC 0021 §4 (thread exit) and the stale-slot design are updated. Master-only; no LTS backport. EC2 p1b torn down.

gburd added 4 commits November 3, 2026 03:55
The prototype's cursor-cache A/B script shipped mode 100644; the exec-bit gate
(dist/s_execbits) fails on it, which blocks every CI job early.  One-line
permission fix, no content change.
… gate)

CQ_LOCK/CQ_UNLOCK expand to MUTEX_LOCK/MUTEX_UNLOCK, which `return
(DB_RUNRECOVERY)` on a lock failure under --enable-debug.  A void function
using them tripped clang's -Wreturn-type ("void function should not return a
value"), which failed the #140 ASan lock-mode CI build (clang).  Give the
helper an int return and ignore it at the two void-context call sites (a
partition-mutex failure there is an env panic that surfaces on the next op;
the slot is dropped either way).
The TLS guard keys on HAVE_MUTEX_TAS, but the x86_64 gcc-assembly mutex
backend defines HAVE_MUTEX_X86_64_GCC_ASSEMBLY instead, so DBC_CACHE_TLS is
undefined there and the cache is compiled out -- yet __dbc_close referenced
__dbc_cache_eligible/__dbc_cache_put unconditionally, giving an implicit
declaration + link error (the mutex tas-x86_64 CI job).  Add #else stubs that
make every cursor ineligible and never cache, so __dbc_close behaves exactly
like master on backends without the cache.  Pre-existing in the prototype;
surfaced by the full CI matrix.
Under AddressSanitizer+UBSan the process RSS is dominated by shadow memory and
redzones that grow with the 5000 temp-handle cycles regardless of any orphan,
so the +2 MB RSS bound tripped on a correct build (ratio 1.01, RSS +41 MB) in
the --enable-typed asan+ubsan CI job.  Skip the RSS assertion when a sanitizer
is active; the insert-time ratio is the primary orphan signal and keeps its
teeth (pre-fix FAILs 10.98x under ASan, fixed PASSes 1.01x).
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up: four CI-hardening fixes after the first push (all build/portability, no behaviour change to the shipped default):

  1. +x on test/bench/cc_ab.sh (the prototype shipped it 0644; the s_execbits gate blocked every job).
  2. __dbc_cache_free_cursor returns int — CQ_LOCK/CQ_UNLOCK expand to MUTEX_LOCK which return (DB_RUNRECOVERY) under --enable-debug, so a void user tripped clang -Wreturn-type in the libdb 5.3.34: incomplete replication commit lock lists can violate client transaction isolation #140 ASan gate.
  3. #else stubs for __dbc_cache_eligible/__dbc_cache_put so __dbc_close builds on no-TLS mutex backends (the TLS guard keys on HAVE_MUTEX_TAS, but --with-mutex=x86_64/gcc-assembly sets HAVE_MUTEX_X86_64_GCC_ASSEMBLY → cache compiled out → implicit-declaration link error). The cache is simply off there, behaving like master.
  4. cursorcache_orphan skips its RSS bound under ASan (sanitizer shadow memory balloons RSS over the 5000 temp-handle cycles regardless of any orphan). The insert-time ratio keeps the teeth: pre-fix FAILs 10.98× under ASan, fixed PASSes 1.01×.

Locally reproduced + green on every CI config: gcc --enable-diagnostic (27P/0F), clang+ASan (#140 gate), x86_64/gcc-assembly, POSIX/pthreads/library --enable-debug full run_all (27P/0F), and --enable-typed asan+ubsan (all three cursorcache runners PASS). handle_sizes PASS in both default and typed builds (sizeof(DB)=1744 unchanged).

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead re-review of the fix (f73b8e0): one blocker left, a NEW use-after-free introduced by the redesign.

The orphan fix, the thread-exit flush, the atomic epoch and the metal numbers all check out. But the redesign rests on one claim (db_cam.c, __dbc_cache_handle_live):

env itself is guaranteed live here: … the closing thread clears its own slots for each handle at close, so no slot outlives its env.

That is false when the thread that closes the handle is not the thread that cached the cursor. That is the normal case: a pool worker caches, and the main thread closes the DB and the env.

  • The worker's slot keeps e->env = the freed ENV.
  • On the worker's next reconcile, __dbc_cache_handle_live does MUTEX_LOCK(env, env->mtx_dblist) on freed memory.
  • That reconcile is either the thread-exit destructor, or check_epoch on its next get against any other env.

Reproducer /scratch/libdb/rev-work/238/rev238_a10_envuaf.c: a worker does 1 get on H in env E and stays alive; main closes H and E; then the worker either exits or opens a new env and does a get. clang + ASan, --enable-diagnostic:

head arm exit arm reuse
6bed835 (pre-fix) clean clean
f73b8e0 (this fix) heap-use-after-free __dbc_cache_handle_live:228 ← __dbc_cache_thread_exit heap-use-after-free :228 ← __dbc_cache_check_epoch

(freed by __env_close env_open.c:735.)

Fix direction:

  • The slot must not dereference env unless it is known to be live. An env can be closed by any thread, so a per-thread self-clear cannot guarantee that. The same trick as for handles works: a process-global, never-reused env serial plus a process-global registry of live envs. Validate (env, env_serial) against that registry under one process mutex before touching env->mtx_dblist.
  • The simpler alternative: a process-global live-handle registry keyed by (dbp, cursor_cache_gen), which never dereferences env or dbp at all.
  • Either way, the destructor and reconcile must be safe after env close. Add a10 as a test/db runner under ASan.

gburd added 4 commits November 3, 2026 05:37
…RFC 0021)

Review of f73b8e0 found a use-after-free the redesign introduced
(rev238_a10): the stale-slot validator __dbc_cache_handle_live walked
slot->env->dblist, dereferencing the slot's ENV.  The claim that "no slot
outlives its env" holds only when the thread that CLOSES the handle is the
one that CACHED the cursor.  In the normal case -- a pool worker caches a
cursor on handle H in env E and stays alive while the main thread closes H
and E -- the worker's slot keeps a pointer to the freed ENV, and its next
reconcile (thread-exit destructor, or check_epoch on a get against any other
env) did MUTEX_LOCK on freed memory.  ASan heap-use-after-free at
db_cam.c:228 on both the exit and reuse arms; pre-fix head 6bed835 clean.

Fix: a process-global live-handle registry, keyed by (dbp, cursor_cache_gen),
under one process mutex (__dbc_reg_mtx) that lives in no env.  DB->open adds
(__db_cursor_cache_register); __db_refresh removes (in __db_cursor_cache_discard)
BEFORE the handle's cursors are destroyed.  Reconcile and the eviction path
validate a slot against the registry ONLY -- they never dereference the slot's
dbp or env.  A "live" result means the handle is open, so holding the registry
mutex across the check and the free_cursor keeps both the handle and its env
valid for the duration.  Lock order registry -> CQ_LOCK; the registry mutex is
a leaf taken in open/close/reconcile with no other lock held first, and nothing
takes CQ_LOCK then the registry mutex, so no inversion.  The registry is only
consulted on an epoch change (a handle closed), never per get, so the hot path
stays mutex-free.

The cache now requires a pthreads or hybrid mutex backend (it needs __thread,
a pthread_key destructor, and a pthread_mutex for the registry together); the
default autoconf build is hybrid, so the mainstream build keeps the cache, and
niche backends (pure TAS, UI-threads, Solaris LWP, WIN32) compile it out and
behave like master.  sizeof(DB) unchanged (cursor_cache_gen already fit the
existing padding).  No region/log/on-disk/ABI-size change.
…0021)

run_cursorcache_envuaf reproduces review #238's cross-thread env
use-after-free: a worker caches a cursor on H in env E and stays alive, the
main thread closes H and E, and the worker then either exits (pthread-key
destructor reconciles) or opens a new env and gets (check_epoch reconciles).
Built with ASan when available (the fault is a read of freed heap).  Pre-fix
head FAILs both arms (heap-use-after-free in __dbc_cache_handle_live); the
live-handle-registry fix PASSes.  MANIFEST row + +x.
… db.h)

COMPQUIET is an internal libdb macro not visible to a test that includes only
db.h, so the ASan link failed with an undefined reference.  Use a plain
(void)a; cast instead.
The orphan/UAF guards are now the live-handle registry, so the sabotage arm
must defeat the registry too: -DDBC_CACHE_SABOTAGE makes __dbc_cache_handle_live
always report "live", which (with the existing gen-ignore in get and the
skipped self-clear in discard) lets a reopened handle reuse a destroyed cached
cursor.  Sabotage arm FAILs (ASan use-after-free), correct arm PASSes.
gburd added 3 commits November 3, 2026 05:57
… flake)

Part B runs 8 writer + 4 reader threads on one shared DB_THREAD handle, but
the env was opened with DB_INIT_MPOOL only -- no DB_INIT_LOCK -- so the
concurrent writers were unsynchronised, an unsupported configuration.  Under a
tight loop that raced ~20% of the time with "writer get: Operation not
permitted" (EPERM from the latch layer), independent of the cursor cache: the
prototype head and the cache-off path fail at the same rate.  Add DB_INIT_LOCK
so the concurrent access is page-locked and supported; the transient cursors
still use their own default locker, so they stay cache-eligible.  0/40 fails
after the change (was ~6/30); sabotage arm still FAILs, correct arm PASSes.
__db_refresh read dbp's active_queue head unlocked (the resync probe) BEFORE
__db_cursor_cache_discard removed the handle from the live-handle registry.
That read raced a concurrent reconcile on another thread, which could still
be moving a cached cursor on the same queue under CQ_LOCK (TSan: db.c:925 read
vs __dbc_cache_free_cursor TAILQ write).  Move discard ahead of the probe: once
discard has removed the handle from the registry (under the registry mutex), no
reconcile touches this handle's queues -- an in-flight one has already finished
(the mutex serialises it) and a new one sees the handle closed and skips it --
so the unlocked probe and the teardown walk are race-free again.  A7 under TSan:
no cache/registry/epoch race remains (644 pre-existing BDB mutex/lock races).
Update RFC 0021 to the registry design: a process-global live-handle registry
keyed (dbp, cursor_cache_gen) under one process mutex replaces the env->dblist
liveness check that dereferenced a slot's possibly-freed ENV (review #238
rework, rev238_a10).  Document the registry, the lock order (registry -> CQ),
that validation runs only on an epoch change (never per get), that the cache
now requires a pthreads/hybrid backend, and the new run_cursorcache_envuaf
gate.  Add the metal re-measure after the registry fix: 2.88x at t=96 / 1.51x
at t=64, unchanged, cache-off tracks base (the registry adds no hot-path lock).
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

P1b rework: the cross-thread ENV use-after-free (rev238_a10) is fixed

You were right — the "no slot outlives its env" claim held only when the closing thread was also the caching thread. The fix replaces the env->dblist liveness check with a process-global live-handle registry that dereferences nothing in the slot.

Design

  • A registry of (dbp, cursor_cache_gen) for every open handle, under one process mutex (__dbc_reg_mtx, a pthread_mutex_t in no env). DB->open adds (__db_cursor_cache_register); __db_refresh removes (__db_cursor_cache_discard) before the handle's cursors are destroyed.
  • Reconcile and the put-path eviction validate a slot by registry lookup only — never touching slot->dbp or any env. A "live" result means the handle is open, so holding the registry mutex across the check and the __dbc_cache_free_cursor keeps both the handle and its env valid for the duration.
  • The per-handle never-reused serial defeats ABA (reused heap address → serial mismatch → miss → slot dropped).
  • Lock order registry → CQ_LOCK; the registry mutex is a leaf taken in open/close/reconcile with no other lock held first, and nothing takes CQ_LOCK then the registry mutex. No inversion.
  • The registry is consulted only on an epoch change (a handle closed), never per DB->get; the cache-hit path for the handle the caller is operating on needs no validation (the caller holds it open). So the hot path stays mutex-free.
  • sizeof(DB) still unchanged (1744). Cache now requires a pthreads/hybrid backend (needs __thread + pthread_key + pthread_mutex together); default autoconf is hybrid, so the mainstream build keeps it, niche backends compile it out = master.

Gates (dev box + c6id.metal)

  • rev238_a10 added as run_cursorcache_envuaf (MANIFEST + +x, ASan): both arms (exit, reuse) FAIL on f73b8e0a (heap-use-after-free in __dbc_cache_handle_live:228) and PASS on the fix. Verified under clang+ASan.
  • A7 epoch race under TSan: PASS, zero races on the epoch/serial/registry state (644 pre-existing BDB mutex/lock races). Fixed a TSan-found race the rework exposed: __db_refresh's unlocked active-queue resync probe raced a concurrent reconcile; moved __db_cursor_cache_discard (the registry remove) ahead of the probe so no reconcile can touch a closing handle's queues.
  • rev238_a9 orphan (20k): ratio 1.04, +0 KB. rev238_a8 parkcost (256 threads): ratio 0.998. Both ≤ 1.10.
  • A1–A4, A6 ASan-clean. run_cursorcache_adjust correct PASS / sabotage FAIL (sabotage now also defeats the registry). Full --enable-diagnostic test/db tier: 28 PASS / 2 SKIP / 0 FAIL.
  • Fixed a pre-existing test flake: cursorcache_adjust part B ran 8 concurrent writers in a DB_INIT_MPOOL-only env (no DB_INIT_LOCK) — an unsupported racy config that returned EPERM ~20% of the time in a tight loop, independent of the cache (prototype 6bed835 and the cache-off path fail identically). Added DB_INIT_LOCK: 0/40.

Read win survives the rework (c6id.metal, 6 interleaved reps)

t base (k/s) branch (k/s) cache-off (k/s) br/base br/off
1 517,026 546,892 514,076 1.06× 1.06×
32 1,649,375 1,924,697 1,610,175 1.17× 1.20×
64 1,510,169 2,281,464 1,599,098 1.51× 1.43×
96 976,010 2,814,246 1,005,420 2.88× 2.80×

2.88× at t=96 / 1.51× at t=64 — unchanged from before the registry. cache-off (same binary) tracks base, so the registry added no per-get cost.

RFC 0021 §4/§4a updated to the registry design. Master-only; no LTS backport. EC2 torn down.

Two portability fixes so the macOS CI jobs stop failing on measurement noise,
without losing the teeth where they matter (Linux CI, metal, dev box):

- cursorcache_orphan: skip the RSS bound on macOS/BSD too, not just under ASan.
  getrusage(2) reports ru_maxrss in BYTES on those platforms (kilobytes on
  Linux), so a +32 KB growth read as "32768 KB" tripped the 2 MB bound on a
  correct build (ratio 0.90, no orphan).  The insert-time ratio still asserts
  everywhere and is the real orphan signal (the orphan was >20x).

- cursorcache_parkcost: the ratio is a single-thread timing comparison that
  needs a quiet machine; GitHub's shared macOS runners perturb a 256-thread
  churn into the same ~2x range as the pre-fix regression (observed 2.21).
  On __APPLE__ run both phases (exercising the thread-exit flush) but only
  report the ratio; assert it everywhere else, where it is stable (Linux CI
  passed at 1.03, metal 0.998).
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green on the reworked branch (dacd6a4e0): the full CI workflow passed — all four macOS default jobs, every mutex backend (pthreads / pthreads-library / tas-x86_64 / posix-forced), --enable-typed asan+ubsan, 32-bit, region signature, handle sizes — plus Cocci + ABI (incl. the #140 ASan lock-mode gate), PBT, Fuzz, Bench, Android. 60 checks pass, 0 fail (Coverage still computing, advisory).

Two small test-portability fixes after the first push surfaced the macOS failures (engine untouched): cursorcache_orphan skips its RSS bound on macOS/BSD (ru_maxrss is bytes there, not KB — the insert ratio keeps the teeth), and cursorcache_parkcost reports rather than asserts its timing ratio on the shared macOS runners (asserted on Linux CI / metal, where it is stable).

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead re-verification of dacd6a4: merge-ready from the engine side.

  • rev238_a10_envuaf, both arms, clang + ASan, fresh build: clean (was a heap-use-after-free on f73b8e0).
  • rev238_a9_orphan at 20k cycles, release build: ratio 1.05, +0 KB, cache on. Cache off gives 1.06, +0 KB.
  • The registry design is sound:
    • liveness is a pure lookup of (dbp, gen) under one leaf mutex, with no dereference of dbp or env;
    • removal happens before cursor teardown in __db_refresh;
    • holding the mutex across the check and the free keeps a live handle valid;
    • the lock order is registry → CQ_LOCK only;
    • the hot path never takes the mutex.
  • Fork safety: the registry is a process-global pthread_mutex_t with no pthread_atfork handler, so a child forked while another thread holds it would inherit it locked. I probed it (/scratch/libdb/cc_fork.c): 4 threads churn DB open/close while the main thread forks 2000 times, and each child opens and closes a handle in a new env. 0/2000 children hung. The hold window is a few list operations, so it is hard to hit, but it is not impossible. Add an atfork handler (prepare: lock; parent and child: unlock) for the same reason os_aio_pool.c has one. It is a follow-up, not a blocker.

#238 can merge after #234 (soname); it is master-only, with no LTS backport.

This branch has not been deployed

No deployments
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