-
Notifications
You must be signed in to change notification settings - Fork 420
fix: raise LMDB max_readers to avoid MDB_READERS_FULL (#783) #785
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a14e016
4080a43
4719b3e
ff73757
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| // Repro for #783: fff opens LMDB envs with only map_size set, leaving heed's | ||
| // default max_readers (126) and default TLS mode. Long-lived threads each pin a | ||
| // reader slot for the thread's lifetime, so >126 live reader threads exhaust the | ||
| // table with MDB_READERS_FULL. | ||
|
|
||
| use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; | ||
| use std::sync::{Arc, Barrier, mpsc}; | ||
| use std::time::Duration; | ||
|
|
||
| use heed::EnvOpenOptions; | ||
|
|
||
| // This binary links heed directly without the fff lib, so nothing pulls in | ||
| // advapi32 for lmdb's security-descriptor calls in mdb_env_setup_locks. | ||
| #[cfg(windows)] | ||
| #[link(name = "advapi32")] | ||
| unsafe extern "C" {} | ||
|
|
||
| fn temp_env_dir(name: &str) -> std::path::PathBuf { | ||
| let dir = std::env::temp_dir().join(format!("fff-readers-{name}-{}", std::process::id())); | ||
| let _ = std::fs::remove_dir_all(&dir); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| dir | ||
| } | ||
|
|
||
| // Regression for #783: with only map_size set (pre-fix), heed's default 126 | ||
| // reader slots are exhausted once >126 live threads each hold a read txn. fff now | ||
| // raises max_readers, so this many live readers must all get a slot. | ||
| const FFF_MAX_READERS: u32 = 1024; | ||
|
|
||
| #[test] | ||
| fn raised_max_readers_admits_more_than_126_live_readers() { | ||
| let dir = temp_env_dir("raised"); | ||
| let env = unsafe { | ||
| EnvOpenOptions::new() | ||
| .map_size(10 * 1024 * 1024) | ||
| .max_readers(FFF_MAX_READERS) | ||
| .open(&dir) | ||
| } | ||
| .unwrap(); | ||
|
|
||
| const THREADS: usize = 200; | ||
| let stop = Arc::new(AtomicBool::new(false)); | ||
| let ready = Arc::new(Barrier::new(THREADS + 1)); | ||
| let readers_full = Arc::new(AtomicBool::new(false)); | ||
| let (tx, rx) = mpsc::channel::<bool>(); // true = read txn acquired | ||
|
|
||
| let mut handles = Vec::new(); | ||
| for _ in 0..THREADS { | ||
| let env = env.clone(); | ||
| let stop = stop.clone(); | ||
| let ready = ready.clone(); | ||
| let readers_full = readers_full.clone(); | ||
| let tx = tx.clone(); | ||
| handles.push(std::thread::spawn(move || { | ||
| match env.read_txn() { | ||
| Ok(txn) => { | ||
| tx.send(true).ok(); | ||
| ready.wait(); | ||
| while !stop.load(Ordering::Relaxed) { | ||
| std::thread::park_timeout(Duration::from_millis(5)); | ||
| } | ||
| drop(txn); // hold the slot for the whole test | ||
| } | ||
| Err(e) => { | ||
| if e.to_string().contains("MDB_READERS_FULL") { | ||
| readers_full.store(true, Ordering::Relaxed); | ||
| } | ||
| tx.send(false).ok(); | ||
| ready.wait(); | ||
| } | ||
| } | ||
| })); | ||
| } | ||
| drop(tx); | ||
|
|
||
| // Collect exactly one result per thread; parked threads keep their tx clone | ||
| // alive, so we must not wait for the channel to close. | ||
| let acquired = AtomicUsize::new(0); | ||
| for _ in 0..THREADS { | ||
| if rx.recv().unwrap() { | ||
| acquired.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| } | ||
| ready.wait(); | ||
|
|
||
| let acquired = acquired.load(Ordering::Relaxed); | ||
| stop.store(true, Ordering::Relaxed); | ||
| for h in handles { | ||
| h.join().unwrap(); | ||
| } | ||
| let _ = std::fs::remove_dir_all(&dir); | ||
|
|
||
| // With max_readers raised, all 200 live reader threads must get a slot and | ||
| // none may see MDB_READERS_FULL. On the pre-fix default of 126 this plateaus | ||
| // at 126 and the rest fail. | ||
| assert!( | ||
| !readers_full.load(Ordering::Relaxed), | ||
| "MDB_READERS_FULL hit: only {acquired}/{THREADS} live reader threads got a slot" | ||
| ); | ||
| assert_eq!( | ||
| acquired, THREADS, | ||
| "all {THREADS} live reader threads should get a slot; got {acquired}" | ||
| ); | ||
| } | ||
|
|
||
| // Structural fix for #783: with MDB_NOTLS a reader slot is tied to the txn | ||
| // object and freed on drop, not pinned per thread. 200 long-lived threads each | ||
| // open+drop a txn against the *default* 126-slot table; in TLS mode this | ||
| // plateaus at 126, in NOTLS mode every thread must succeed. | ||
| #[test] | ||
| fn notls_releases_slots_of_live_threads() { | ||
| let dir = temp_env_dir("notls"); | ||
| let env = unsafe { | ||
| EnvOpenOptions::new() | ||
| .read_txn_without_tls() | ||
| .map_size(10 * 1024 * 1024) | ||
| .open(&dir) | ||
| } | ||
| .unwrap(); | ||
|
|
||
| const THREADS: usize = 200; | ||
| let stop = Arc::new(AtomicBool::new(false)); | ||
| let ready = Arc::new(Barrier::new(THREADS + 1)); | ||
| // Serialize txns so the test measures slot *release*, not concurrency. | ||
| let txn_gate = Arc::new(std::sync::Mutex::new(())); | ||
| let acquired = Arc::new(AtomicUsize::new(0)); | ||
|
|
||
| let mut handles = Vec::new(); | ||
| for _ in 0..THREADS { | ||
| let env = env.clone(); | ||
| let stop = stop.clone(); | ||
| let ready = ready.clone(); | ||
| let txn_gate = txn_gate.clone(); | ||
| let acquired = acquired.clone(); | ||
| handles.push(std::thread::spawn(move || { | ||
| { | ||
| let _gate = txn_gate.lock().unwrap(); | ||
| if let Ok(txn) = env.read_txn() { | ||
| acquired.fetch_add(1, Ordering::Relaxed); | ||
| drop(txn); // NOTLS: slot returns to the pool here | ||
| } | ||
| } | ||
| // Stay alive: in TLS mode this thread would keep its slot pinned. | ||
| ready.wait(); | ||
| while !stop.load(Ordering::Relaxed) { | ||
| std::thread::park_timeout(Duration::from_millis(5)); | ||
| } | ||
| })); | ||
| } | ||
|
|
||
| ready.wait(); | ||
| let got = acquired.load(Ordering::Relaxed); | ||
| stop.store(true, Ordering::Relaxed); | ||
| for h in handles { | ||
| h.join().unwrap(); | ||
| } | ||
| let _ = std::fs::remove_dir_all(&dir); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,190p' crates/fff-core/tests/lmdb_readers_full_repro.rsRepository: dmtrKovalenko/fff Length of output: 5899 🏁 Script executed: rg -n -C 3 'env|remove_dir_all|handles|NOTLS|lmdb_readers_full_repro' crates/fff-core/tests/lmdb_readers_full_repro.rs crates/fff-core/Cargo.toml Cargo.tomlRepository: dmtrKovalenko/fff Length of output: 9780 🏁 Script executed: printf '%s\n' '--- source ---'
sed -n '1,190p' crates/fff-core/tests/lmdb_readers_full_repro.rs
printf '%s\n' '--- references ---'
rg -n -C 3 'env|remove_dir_all|handles|NOTLS|lmdb_readers_full_repro' crates/fff-core/tests/lmdb_readers_full_repro.rs crates/fff-core/Cargo.toml Cargo.tomlRepository: dmtrKovalenko/fff Length of output: 15558 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
p = Path("crates/fff-core/tests/lmdb_readers_full_repro.rs")
lines = p.read_text().splitlines()
for start, end in [(1, 30), (80, 125), (135, 165)]:
print(f"--- lines {start}-{end} ---")
for n in range(start, min(end, len(lines)) + 1):
print(f"{n}: {lines[n-1]}")
PYRepository: dmtrKovalenko/fff Length of output: 4390 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
p = Path("crates/fff-core/tests/lmdb_readers_full_repro.rs")
text = p.read_text()
cleanup = text.index("std::fs::remove_dir_all")
prefix = text[:cleanup]
print("cleanup expression:", text[cleanup:text.find("\n", cleanup)])
print("env binding before cleanup:", "let env" in prefix)
print("explicit drop(env) before cleanup:", "drop(env)" in prefix)
print("worker joins before cleanup:", prefix.count(".join().unwrap()"))
print("cleanup result discarded:", "let _ =" in text[cleanup-20:cleanup+80])
PYRepository: dmtrKovalenko/fff Length of output: 343 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
lines = Path("crates/fff-core/tests/lmdb_readers_full_repro.rs").read_text().splitlines()
cleanup_lines = [i + 1 for i, line in enumerate(lines) if "remove_dir_all(&dir)" in line]
target = cleanup_lines[-1]
before = lines[:target - 1]
print("cleanup lines:", cleanup_lines)
print("target cleanup line:", target)
print("env binding before target cleanup:", any("let env =" in line for line in before))
print("explicit drop(env) before target cleanup:", any("drop(env)" in line for line in before))
print("join calls before target cleanup:", sum(".join().unwrap()" in line for line in before))
print("target cleanup discards result:", "let _ =" in lines[target - 1])
PY
printf '%s\n' '--- heed declarations ---'
rg -n -C 4 'heed\s*=|heed-types|heed' Cargo.toml Cargo.lock crates/fff-core/Cargo.toml
printf '%s\n' '--- available local heed source ---'
fd -HI 'env.rs|environment.rs|heed' . /root/.cargo/registry 2>/dev/null | head -80Repository: dmtrKovalenko/fff Length of output: 3130 🌐 Web query:
💡 Result: In Citations:
Drop After joining workers, call 🤖 Prompt for AI Agents |
||
|
|
||
| assert_eq!( | ||
| got, THREADS, | ||
| "NOTLS must free slots on txn drop; only {got}/{THREADS} live threads got one" | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Test the production open path.
This test sets
max_readersdirectly. It bypassesSharedEnv::get_or_openandmax_readers().The test still passes if production line 97 is removed. Open the environment through the production path, then retain the 200-reader contention check.
🤖 Prompt for AI Agents