Skip to content

fix(storage): cache open HDF5 read handles for multi-bag bag files - #786

Draft
pmrv wants to merge 2 commits into
mainfrom
claude/bagofholding-perf-regression-8vshbo
Draft

fix(storage): cache open HDF5 read handles for multi-bag bag files#786
pmrv wants to merge 2 commits into
mainfrom
claude/bagofholding-perf-regression-8vshbo

Conversation

@pmrv

@pmrv pmrv commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Fixes the BagOfHoldingH5File multi-bag regression flagged in #625: since PR #746, every contains() (and each bag in list()) paid a full HDF5 file open/close, ~8.6× slower than the per-key layout (240 µs vs 28 µs).

What changed

bagofholding_file.py gains a process-wide cache of open read-only h5py.File handles (_BagHandleCache), keyed by absolute bag-file path:

  • A WeakValueDictionary indexes the open handles; a bounded strong-ref MRU (_MAX_OPEN_BAGS = 32) keeps the most recently used ones alive between operations. A handle evicted from the MRU is never closed eagerly — the strong reference is dropped and the interpreter closes the file once the last in-flight user releases it.
  • Every acquisition re-validates the handle against an (inode, mtime_ns, size) stat signature, so bags rewritten by other processes or other storage instances are detected and reopened. One stat (~1 µs) replaces one HDF5 open (~120 µs).
  • Cached handles are opened with locking=False: a long-lived reader holding HDF5's shared OS lock would otherwise make every write from another process fail for as long as it lives. Cross-process write mutual exclusion was always provided by the filelock sidecar locks, not by HDF5's locking, so nothing is lost.
  • Within a process, HDF5 refuses any open whose mode or locking flags differ from a live handle's. So every path that opens the bag file itself — put/_to_file, get/_from_file, _evict, rebag (H5Bag uses default flags) — runs inside _bag_writer(key): it closes the cached handle and holds a per-bag in-process lock across the operation. Readers (_contains, list) hold the same lock while using the handle, so a writer can never close a handle mid-read.
  • The cache is process-wide rather than literally per-instance for the same reason: a handle cached by one storage instance must be closable by any other instance (equal or not) addressing the same file, or that instance's writes would fail. The per-instance WeakKeyDictionary pattern used for lock tables would leave two non-equal instances on the same root able to brick each other's writes.
  • Fork-safety: the cache detects a changed pid and drops all inherited state (read-only handles close harmlessly in the child); locking=False falls back to a default-flag open on h5py < 3.5 / HDF5 < 1.12.1.

Numbers

op before after pre-regression (#625 baseline)
contains (hit) 240 µs 29 µs 28 µs
contains (miss) ~240 µs 17 µs
evict 490 µs unchanged 79 µs (per-key layout)

evict is deliberately untouched: it genuinely mutates the bag, so it must pay the filelock acquisition (~100 µs) plus an HDF5 write open/del/close (~130 µs+). That cost is structural for a durable per-key delete inside a shared file; caching write handles across operations would hold HDF5's exclusive state hostage against every other process. If evict throughput ever matters, the realistic lever is batching (one write open per bag for N evictions, e.g. a evict_many on the storage or transaction-style deferral), not handle caching.

list() — remaining cost and options

list() now reuses cached handles, so a warm repeated list() costs ~0 opens for up to _MAX_OPEN_BAGS bags. But a cold list() over a root with many bags still opens every file once, and roots with more than 32 bags will churn the MRU. Options, roughly in order of effort:

  1. Leave it (this PR). Warm-path wins only; cold list() on a default prefix_length=2 root is up to 256 opens (~30 ms worst case).
  2. Cache each bag's key list next to its handle, invalidated by the same stat signature. Cheap to add on top of this PR's infrastructure; makes repeated list() on an unchanged root free even beyond the MRU bound (key lists survive handle eviction). Doesn't help the truly cold first call.
  3. A persistent per-root key index (sidecar manifest updated under the bag's filelock on put/evict). Makes cold list() one file read, but adds cost and a consistency obligation to every write, plus a repair story for crashes between bag write and index write. This is the only option that fixes the cold path, and the only one with real correctness surface.
  4. prefix_length=0 already gives O(readdir) list() today for workloads that are list-heavy and don't hit the many-small-files problem multi-bagging exists to solve — worth remembering before building 3.

Happy to follow up with 2 (small) or 3 (needs design agreement) in a separate PR.

Behavioral notes

  • Code that opens a bag file directly with h5py.File(path) (default flags) in the same process while a cached handle is live will now get the flag-mismatch OSError; go through the storage API, or open with locking=False.
  • The stat signature uses st_mtime_ns: on filesystems with coarse mtime granularity, an external rewrite that changes neither size nor inode within the same timestamp tick could go briefly unnoticed by contains. Same-process writes are exact (explicit invalidation); cross-process reads were already best-effort.
  • Cached handles keep files open between operations; on Windows that makes deleting a bag file out from under a warm storage fail (_evict itself closes the handle under the bag lock before unlinking, so the backend's own deletes are safe; CI is Linux-only).

Testing

  • 8 new tests pin: handle reuse across contains/list (open counting), invalidation on sibling-key put, get after a warm contains (the flag-mismatch trap), evict with a warm cache including file removal, writes from a second non-equal instance on the same root, external-process writes being neither blocked nor masked (subprocess test — also pins locking=False), and the MRU bound.
  • Full suite: 1640 passed, 11 skipped. ty check src/ clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_014DWRGyuEoH3qXNSQbSXddp


Generated by Claude Code

The multi-bag layout introduced in PR #746 made every contains() and
list() pay a full HDF5 file open/close (~120 us), regressing contains
8.6x against the per-key layout (240 us vs 28 us, #625).

Add a process-wide cache of open read-only h5py handles, keyed by
absolute path: a WeakValueDictionary index plus a bounded strong-ref MRU
that keeps the most recently used handles alive between operations.
Cached handles are validated against an (inode, mtime_ns, size) stat
signature on every acquisition, so bags rewritten by other processes or
other storage instances are detected and reopened.

Handles are opened with locking=False so a long-lived cached reader
cannot make writes from other processes fail. Within a process, HDF5
rejects any open whose mode or locking flags differ from a live
handle's, so every operation that opens the bag file itself - put,
get, evict, and rebag - closes the cached handle first and holds a
per-bag in-process lock across the operation; readers hold the same
lock while using the handle, so a writer can never close it mid-read.
The cache is process-wide rather than per-instance because a handle
cached by one instance must be closable by any other instance
addressing the same file.

contains(hit) drops from 240 us back to the pre-regression ~29 us.
evict is unchanged: it genuinely writes, and the filelock acquisition
plus HDF5 write open/close are structural for a durable per-key delete
inside a shared file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DWRGyuEoH3qXNSQbSXddp
@claude claude Bot added the benchmark Run benchmark action label Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

Baseline: 071d395 (cached)

Delta

Significant changes (|Δ| > 10%): 161 — 79 other rows hidden

Value Storage

configuration workload function time @ 071d395 time @ head Δ vs base
BagOfHoldingH5File small_strings contains_hit 280 µs 45 µs 🟢 -83.9%
BagOfHoldingH5File nested_structures contains_hit 270 µs 46 µs 🟢 -83.0%
BagOfHoldingH5File numpy_arrays contains_hit 270 µs 46 µs 🟢 -83.0%
CloudpickleFile_Signed small_strings contains_miss 33 µs 24 µs 🟢 -27.3%
DillFile_Signed small_strings contains_miss 33 µs 24 µs 🟢 -27.3%
PickleFile numpy_arrays contains_miss 33 µs 24 µs 🟢 -27.3%
CloudpickleFile_Signed nested_structures contains_miss 34 µs 25 µs 🟢 -26.5%
CloudpickleFile_Signed nested_structures evict 58 µs 43 µs 🟢 -25.9%
PickleFile_Signed nested_structures evict 58 µs 43 µs 🟢 -25.9%
PickleFile nested_structures evict 58 µs 43 µs 🟢 -25.9%
DillFile_Signed numpy_arrays evict 110 µs 82 µs 🟢 -25.5%
DillFile numpy_arrays evict 110 µs 82 µs 🟢 -25.5%
PickleFile_Signed numpy_arrays evict 110 µs 82 µs 🟢 -25.5%
DillFile_Signed numpy_arrays contains_miss 32 µs 24 µs 🟢 -25.0%
CloudpickleFile small_strings load 280 µs 210 µs 🟢 -25.0%
DillFile_Signed nested_structures evict 57 µs 43 µs 🟢 -24.6%
DillFile nested_structures evict 57 µs 43 µs 🟢 -24.6%
CloudpickleFile_Signed numpy_arrays evict 110 µs 83 µs 🟢 -24.5%
PickleFile numpy_arrays evict 110 µs 83 µs 🟢 -24.5%
CloudpickleFile numpy_arrays contains_miss 33 µs 25 µs 🟢 -24.2%
CloudpickleFile_Signed nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile numpy_arrays contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
CloudpickleFile numpy_arrays contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile_Signed nested_structures contains_miss 33 µs 25 µs 🟢 -24.2%
DillFile_Signed nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile_Signed small_strings contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile_Signed numpy_arrays contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile small_strings contains_miss 33 µs 25 µs 🟢 -24.2%
CloudpickleFile_Signed small_strings contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile nested_structures contains_miss 33 µs 25 µs 🟢 -24.2%
CloudpickleFile small_strings contains_miss 33 µs 25 µs 🟢 -24.2%
CloudpickleFile nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
CloudpickleFile nested_structures contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile small_strings contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
PickleFile_Signed small_strings contains_hit 33 µs 25 µs 🟢 -24.2%
PickleFile_Signed nested_structures contains_hit 33 µs 25 µs 🟢 -24.2%
PickleFile_Signed nested_structures contains_miss 33 µs 25 µs 🟢 -24.2%
CloudpickleFile_Signed numpy_arrays contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile nested_structures contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile_Signed small_strings contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile_Signed numpy_arrays contains_miss 33 µs 25 µs 🟢 -24.2%
PickleFile numpy_arrays contains_hit 33 µs 25 µs 🟢 -24.2%
DillFile nested_structures load 290 µs 220 µs 🟢 -24.1%
DillFile_Signed small_strings evict 100 µs 76 µs 🟢 -24.0%
CloudpickleFile numpy_arrays evict 110 µs 84 µs 🟢 -23.6%
CloudpickleFile nested_structures evict 56 µs 43 µs 🟢 -23.2%
CloudpickleFile nested_structures load 270 µs 210 µs 🟢 -22.2%
PickleFile small_strings evict 100 µs 78 µs 🟢 -22.0%
CloudpickleFile_Signed small_strings evict 100 µs 78 µs 🟢 -22.0%
DillFile small_strings evict 100 µs 78 µs 🟢 -22.0%
CloudpickleFile small_strings evict 100 µs 78 µs 🟢 -22.0%
PickleFile_Signed small_strings evict 100 µs 78 µs 🟢 -22.0%
CloudpickleFile_Signed numpy_arrays contains_hit 32 µs 25 µs 🟢 -21.9%
CloudpickleFile small_strings contains_hit 32 µs 25 µs 🟢 -21.9%
DillFile small_strings contains_hit 32 µs 25 µs 🟢 -21.9%
DillFile numpy_arrays contains_miss 32 µs 25 µs 🟢 -21.9%
PickleFile small_strings contains_hit 32 µs 25 µs 🟢 -21.9%
PickleFile_Signed numpy_arrays contains_hit 32 µs 25 µs 🟢 -21.9%
DillFile small_strings load 280 µs 220 µs 🟢 -21.4%
PickleFile nested_structures load 280 µs 220 µs 🟢 -21.4%
PickleFile_Signed nested_structures load 290 µs 230 µs 🟢 -20.7%
CloudpickleFile_Signed nested_structures load 290 µs 230 µs 🟢 -20.7%
CloudpickleFile_Signed small_strings load 290 µs 230 µs 🟢 -20.7%
PickleFile_Signed small_strings load 290 µs 230 µs 🟢 -20.7%
Memory numpy_arrays load 15 µs 12 µs 🟢 -20.0%
CloudpickleFile numpy_arrays load 300 µs 240 µs 🟢 -20.0%
PickleFile numpy_arrays load 300 µs 240 µs 🟢 -20.0%
DillFile_Signed nested_structures load 300 µs 240 µs 🟢 -20.0%
DillFile_Signed small_strings load 300 µs 240 µs 🟢 -20.0%
DillFile numpy_arrays load 320 µs 260 µs 🟢 -18.8%
PickleFile small_strings load 270 µs 220 µs 🟢 -18.5%
BagOfHoldingH5File nested_structures evict 270 µs 220 µs 🟢 -18.5%
PickleFile_Signed numpy_arrays load 370 µs 320 µs 🟢 -13.5%
CloudpickleFile_Signed numpy_arrays load 370 µs 320 µs 🟢 -13.5%
Memory numpy_arrays save 15 µs 13 µs 🟢 -13.3%
BagOfHoldingH5File small_strings contains_miss 34 µs 30 µs 🟢 -11.8%
BagOfHoldingH5File small_strings save 1.7 ms 1.5 ms 🟢 -11.8%
BagOfHoldingH5File small_strings load 1.7 ms 1.5 ms 🟢 -11.8%
PickleFile nested_structures save 360 µs 320 µs 🟢 -11.1%
PickleFile_Signed nested_structures save 370 µs 330 µs 🟢 -10.8%
DillFile_Signed numpy_arrays load 380 µs 340 µs 🟢 -10.5%

Integration

configuration workload function time @ 071d395 time @ head Δ vs base
Memory lightweight contains_miss 82 µs 60 µs 🟢 -26.8%
Memory(Raw) compute_heavy hit 150 µs 110 µs 🟢 -26.7%
Memory(Raw) lightweight hit 150 µs 110 µs 🟢 -26.7%
SizeLimitedCache(Memory,max=10) data_heavy hit 190 µs 140 µs 🟢 -26.3%
Memory(Raw) lightweight contains_miss 76 µs 56 µs 🟢 -26.3%
Memory lightweight miss 350 µs 260 µs 🟢 -25.7%
SizeLimitedCache(Memory,max=100) lightweight contains_hit 82 µs 61 µs 🟢 -25.6%
SizeLimitedCache(Memory,max=100) data_heavy contains_hit 82 µs 61 µs 🟢 -25.6%
SizeLimitedCache(Memory,max=10) compute_heavy contains_hit 82 µs 61 µs 🟢 -25.6%
Memory data_heavy contains_miss 82 µs 61 µs 🟢 -25.6%
Memory data_heavy contains_hit 82 µs 61 µs 🟢 -25.6%
SizeLimitedCache(Memory,max=10) compute_heavy contains_miss 82 µs 61 µs 🟢 -25.6%
Memory(Raw) data_heavy contains_hit 75 µs 56 µs 🟢 -25.3%
Memory(Raw) compute_heavy contains_miss 75 µs 56 µs 🟢 -25.3%
Memory(Raw) compute_heavy contains_hit 75 µs 56 µs 🟢 -25.3%
Memory(Raw) data_heavy contains_miss 75 µs 56 µs 🟢 -25.3%
SizeLimitedCache(Memory,max=100) data_heavy contains_miss 83 µs 62 µs 🟢 -25.3%
SizeLimitedCache(Memory,max=10) data_heavy contains_hit 83 µs 62 µs 🟢 -25.3%
SizeLimitedCache(Memory,max=100) compute_heavy contains_miss 83 µs 62 µs 🟢 -25.3%
SizeLimitedCache(Memory,max=10) data_heavy contains_miss 83 µs 62 µs 🟢 -25.3%
Memory lightweight hit 160 µs 120 µs 🟢 -25.0%
SizeLimitedCache(Memory,max=100) compute_heavy hit 160 µs 120 µs 🟢 -25.0%
Memory compute_heavy hit 160 µs 120 µs 🟢 -25.0%
Memory(Raw) data_heavy hit 160 µs 120 µs 🟢 -25.0%
SizeLimitedCache(Memory,max=100) lightweight hit 160 µs 120 µs 🟢 -25.0%
SizeLimitedCache(Memory,max=100) compute_heavy contains_hit 81 µs 61 µs 🟢 -24.7%
SizeLimitedCache(Memory,max=100) lightweight contains_miss 81 µs 61 µs 🟢 -24.7%
Memory lightweight contains_hit 81 µs 61 µs 🟢 -24.7%
Memory compute_heavy contains_hit 81 µs 61 µs 🟢 -24.7%
SizeLimitedCache(Memory,max=10) lightweight contains_hit 82 µs 62 µs 🟢 -24.4%
Memory compute_heavy contains_miss 82 µs 62 µs 🟢 -24.4%
SizeLimitedCache(Memory,max=10) lightweight contains_miss 82 µs 62 µs 🟢 -24.4%
Memory(Raw) lightweight contains_hit 75 µs 57 µs 🟢 -24.0%
Memory data_heavy hit 170 µs 130 µs 🟢 -23.5%
SizeLimitedCache(Memory,max=100) data_heavy hit 170 µs 130 µs 🟢 -23.5%
SizeLimitedCache(Memory,max=10) lightweight hit 170 µs 130 µs 🟢 -23.5%
SizeLimitedCache(Memory,max=100) lightweight miss 350 µs 270 µs 🟢 -22.9%
SizeLimitedCache(Memory,max=10) compute_heavy hit 180 µs 140 µs 🟢 -22.2%
SizeLimitedCache(Memory,max=10) lightweight miss 370 µs 290 µs 🟢 -21.6%
H5+Sql compute_heavy contains_miss 380 µs 300 µs 🟢 -21.1%
Pickle+Sql lightweight contains_miss 390 µs 310 µs 🟢 -20.5%
Memory+Sqlite(:memory:) data_heavy contains_miss 360 µs 290 µs 🟢 -19.4%
Memory(Raw) lightweight miss 310 µs 250 µs 🟢 -19.4%
Memory+Sqlite(:memory:) data_heavy hit 990 µs 800 µs 🟢 -19.2%
Memory+Sqlite(:memory:) compute_heavy contains_miss 370 µs 300 µs 🟢 -18.9%
Memory+Sqlite(:memory:) lightweight contains_miss 370 µs 300 µs 🟢 -18.9%
Memory+Sqlite(:memory:) data_heavy contains_hit 370 µs 300 µs 🟢 -18.9%
H5+Sql lightweight contains_miss 380 µs 310 µs 🟢 -18.4%
H5+Sql lightweight contains_hit 380 µs 310 µs 🟢 -18.4%
Memory+Sqlite(:memory:) lightweight contains_hit 380 µs 310 µs 🟢 -18.4%
Pickle+Sql compute_heavy contains_miss 380 µs 310 µs 🟢 -18.4%
H5+Sql data_heavy contains_miss 380 µs 310 µs 🟢 -18.4%
H5+Sql data_heavy contains_hit 380 µs 310 µs 🟢 -18.4%
H5+Sql compute_heavy contains_hit 380 µs 310 µs 🟢 -18.4%
SizeLimitedCache(Memory,max=10) data_heavy miss 510 µs 420 µs 🟢 -17.6%
Memory+Sqlite(:memory:) lightweight hit 980 µs 820 µs 🟢 -16.3%
Memory+Sqlite(:memory:) compute_heavy contains_hit 370 µs 310 µs 🟢 -16.2%
SizeLimitedCache(Memory,max=100) data_heavy miss 500 µs 420 µs 🟢 -16.0%
Memory data_heavy miss 500 µs 420 µs 🟢 -16.0%
Pickle+Sql compute_heavy contains_hit 380 µs 320 µs 🟢 -15.8%
Pickle+Sql data_heavy contains_hit 380 µs 320 µs 🟢 -15.8%
Memory+Sqlite(:memory:) compute_heavy hit 950 µs 800 µs 🟢 -15.8%
Pickle+Sql lightweight contains_hit 390 µs 330 µs 🟢 -15.4%
Memory compute_heavy miss 1.3 ms 1.1 ms 🟢 -15.4%
Pickle+Sql lightweight hit 1.4 ms 1.2 ms 🟢 -14.3%
Pickle+Sql data_heavy contains_miss 370 µs 320 µs 🟢 -13.5%
Memory(Raw) data_heavy miss 450 µs 390 µs 🟢 -13.3%
H5+Sql lightweight hit 2.9 ms 2.6 ms 🟢 -10.3%
Memory+Sqlite(:memory:) data_heavy miss 2 ms 1.8 ms 🟢 -10.0%

Call Storage

configuration workload function time @ 071d395 time @ head Δ vs base
SqlMemory calls contains_hit 260 µs 200 µs 🟢 -23.1%
SqlFile calls contains_hit 270 µs 210 µs 🟢 -22.2%
SqlFile calls load 760 µs 600 µs 🟢 -21.1%
SqlMemory calls contains_miss 240 µs 190 µs 🟢 -20.8%
SqlMemory calls evict 290 µs 230 µs 🟢 -20.7%
SqlFile calls contains_miss 250 µs 200 µs 🟢 -20.0%
SqlMemory calls load 730 µs 590 µs 🟢 -19.2%
SqlFile calls evict 460 µs 400 µs 🟢 -13.0%
SqlMemory calls save 2 ms 1.8 ms 🟢 -10.0%

Full results

Call Storage

calls

configuration contains_hit contains_miss evict load save
🔴 SqlFile 🟩 210 µs 🟩 200 µs 🟩 400 µs 🟩 600 µs 🟥 2 ms
🔴 SqlMemory 🟩 200 µs 🟩 190 µs 🟩 230 µs 🟩 590 µs 🟧 1.8 ms
Digest
workload digest
Dict (small) 🟩 1.4 ms
Float 🟩 260 µs
Integer 🟩 130 µs
List (integers, len<100) 🟩 920 µs
List (integers, len>100) 🟥 25 ms
Nested (Random Hypothesis) 🟩 3.4 ms
None 🟩 110 µs
Numpy (integers, len<100) 🟩 900 µs
Numpy (integers, len>100) 🟩 2.8 ms
String (len<100) 🟩 130 µs
String (len>100) 🟩 220 µs
Integration

compute_heavy

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 300 µs 🟨 2.7 ms 🟥 7.1 ms
🟢 Pickle+Sql 🟩 320 µs 🟩 310 µs 🟩 1.2 ms 🟨 4.1 ms
🟣 Memory 🟩 61 µs 🟩 62 µs 🟩 120 µs 🟩 1.1 ms
🟣 Memory(Raw) 🟩 56 µs 🟩 56 µs 🟩 110 µs 🟩 1.2 ms
🟣 SizeLimitedCache(Memory,max=10) 🟩 61 µs 🟩 61 µs 🟩 140 µs 🟩 1.2 ms
🟣 SizeLimitedCache(Memory,max=100) 🟩 61 µs 🟩 62 µs 🟩 120 µs 🟩 1.2 ms
🟤 Memory+Sqlite(:memory:) 🟩 310 µs 🟩 300 µs 🟩 800 µs 🟨 2.7 ms

data_heavy

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 310 µs 🟨 2.8 ms 🟥 6.6 ms
🟢 Pickle+Sql 🟩 320 µs 🟩 320 µs 🟩 1.3 ms 🟨 3.3 ms
🟣 Memory 🟩 61 µs 🟩 61 µs 🟩 130 µs 🟩 420 µs
🟣 Memory(Raw) 🟩 56 µs 🟩 56 µs 🟩 120 µs 🟩 390 µs
🟣 SizeLimitedCache(Memory,max=10) 🟩 62 µs 🟩 62 µs 🟩 140 µs 🟩 420 µs
🟣 SizeLimitedCache(Memory,max=100) 🟩 61 µs 🟩 62 µs 🟩 130 µs 🟩 420 µs
🟤 Memory+Sqlite(:memory:) 🟩 300 µs 🟩 290 µs 🟩 800 µs 🟩 1.8 ms

lightweight

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 310 µs 🟨 2.6 ms 🟥 6.2 ms
🟢 Pickle+Sql 🟩 330 µs 🟩 310 µs 🟩 1.2 ms 🟨 3.1 ms
🟣 Memory 🟩 61 µs 🟩 60 µs 🟩 120 µs 🟩 260 µs
🟣 Memory(Raw) 🟩 57 µs 🟩 56 µs 🟩 110 µs 🟩 250 µs
🟣 SizeLimitedCache(Memory,max=10) 🟩 62 µs 🟩 62 µs 🟩 130 µs 🟩 290 µs
🟣 SizeLimitedCache(Memory,max=100) 🟩 61 µs 🟩 61 µs 🟩 120 µs 🟩 270 µs
🟤 Memory+Sqlite(:memory:) 🟩 310 µs 🟩 300 µs 🟩 820 µs 🟩 1.7 ms
Value Storage

nested_structures

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 210 µs 🟩 320 µs
🔵 CloudpickleFile_Signed 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 230 µs 🟩 340 µs
🟠 BagOfHoldingH5File 🟩 46 µs 🟩 30 µs 🟩 220 µs 🟧 1 ms 🟥 1.4 ms
🟡 DillFile 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 220 µs 🟩 360 µs
🟡 DillFile_Signed 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 240 µs 🟩 360 µs
🟢 PickleFile 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 220 µs 🟩 320 µs
🟢 PickleFile_Signed 🟩 25 µs 🟩 25 µs 🟩 43 µs 🟩 230 µs 🟩 330 µs
🟣 Memory 🟩 6.6 µs 🟩 6.5 µs 🟩 6.7 µs 🟩 8.2 µs 🟩 8.9 µs
🟣 Memory(Raw) 🟩 1.8 µs 🟩 1.7 µs 🟩 1.9 µs 🟩 3.2 µs 🟩 3.7 µs

numpy_arrays

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 25 µs 🟩 25 µs 🟩 84 µs 🟩 240 µs 🟩 430 µs
🔵 CloudpickleFile_Signed 🟩 25 µs 🟩 25 µs 🟩 83 µs 🟩 320 µs 🟩 510 µs
🟠 BagOfHoldingH5File 🟩 46 µs 🟩 30 µs 🟨 680 µs 🟧 1.6 ms 🟥 2 ms
🟡 DillFile 🟩 25 µs 🟩 25 µs 🟩 82 µs 🟩 260 µs 🟩 590 µs
🟡 DillFile_Signed 🟩 25 µs 🟩 24 µs 🟩 82 µs 🟩 340 µs 🟩 660 µs
🟢 PickleFile 🟩 25 µs 🟩 24 µs 🟩 83 µs 🟩 240 µs 🟩 420 µs
🟢 PickleFile_Signed 🟩 25 µs 🟩 25 µs 🟩 82 µs 🟩 320 µs 🟩 490 µs
🟣 Memory 🟩 6.5 µs 🟩 6.5 µs 🟩 6.9 µs 🟩 12 µs 🟩 13 µs
🟣 Memory(Raw) 🟩 1.7 µs 🟩 1.7 µs 🟩 2 µs 🟩 7 µs 🟩 7.6 µs

small_strings

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 25 µs 🟩 25 µs 🟩 78 µs 🟩 210 µs 🟩 330 µs
🔵 CloudpickleFile_Signed 🟩 25 µs 🟩 24 µs 🟩 78 µs 🟩 230 µs 🟩 350 µs
🟠 BagOfHoldingH5File 🟩 45 µs 🟩 30 µs 🟨 590 µs 🟥 1.5 ms 🟥 1.5 ms
🟡 DillFile 🟩 25 µs 🟩 25 µs 🟩 78 µs 🟩 220 µs 🟩 350 µs
🟡 DillFile_Signed 🟩 25 µs 🟩 24 µs 🟩 76 µs 🟩 240 µs 🟩 360 µs
🟢 PickleFile 🟩 25 µs 🟩 25 µs 🟩 78 µs 🟩 220 µs 🟩 320 µs
🟢 PickleFile_Signed 🟩 25 µs 🟩 25 µs 🟩 78 µs 🟩 230 µs 🟩 330 µs
🟣 Memory 🟩 6.5 µs 🟩 6.5 µs 🟩 6.5 µs 🟩 8 µs 🟩 8.7 µs
🟣 Memory(Raw) 🟩 1.8 µs 🟩 1.7 µs 🟩 1.8 µs 🟩 3.1 µs 🟩 3.6 µs

@pmrv pmrv 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.

I see, because bag of holding doesn't expose the file handles nor let's us inject them, your 'caching' caches raw h5py handles only to peek inside the bags without bag of holding and invalidates on any writing operation, then actually using the bag of holding API. I don't like this.

Sketch what API changes we'd need from bag of holding to be able to just hold onto bag instances we might reuse.

Comment thread src/fleche/storage/bagofholding_file.py Outdated
# Cap on read-only bag-file handles kept open between operations. Handles are
# shared process-wide (keyed by absolute path), so this also bounds the
# process's open-fd contribution regardless of how many storages exist.
_MAX_OPEN_BAGS = 32

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.

let's try 8

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.

Done in 4243748.


Generated by Claude Code

Comment on lines +162 to +177
with self._meta_lock:
f = self._files.get(key)
if f is not None and self._signatures.get(key) != signature:
# Rewritten by another process (or storage instance): safe to
# close because we hold the bag lock, so no reader is mid-use.
f.close()
f = None
if f is None:
f = _open_readonly(path)
with self._meta_lock:
self._files[key] = f
self._signatures[key] = signature
self._recent[key] = f
self._recent.move_to_end(key)
while len(self._recent) > _MAX_OPEN_BAGS:
self._recent.popitem(last=False)

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.

does this not expose a race condition between both acquirations of meta_lock?

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.

No — the invariant that closes it is that every caller of acquire() holds the per-bag lock from lock(path) for the whole call (readers additionally for the duration of their use of the handle). So between the two _meta_lock sections no other thread can acquire or invalidate this path; the only interleaving the dropped _meta_lock admits is activity on other paths, and that never touches this path's entries — MRU eviction only drops other paths' strong references, it doesn't close anything. _meta_lock exists solely to keep the shared dicts internally consistent across different bags; per-path mutual exclusion comes from the bag lock.

4243748 adds a comment in acquire() stating this. The alternative — holding _meta_lock across the h5py open — would also be correct and simpler to reason about, at the cost of serializing concurrent cold opens of different bags (~120 µs each); happy to switch if you prefer the simpler model.


Generated by Claude Code

@contextlib.contextmanager
def _bag_writer(self, key: str):
"""Hold *key*'s in-process bag lock across an operation that opens the
bag file itself, closing any cached read handle first. HDF5 refuses a

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 can make our lives easier by just opening all files in read-write mode, so that cached handles can just be given out to any operation that requests them

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 probed this (h5py 3.16 / HDF5 2.0) and it splits cleanly along the process boundary:

In-process it works exactly as you say. A cached default-flag r+ handle coexists with H5Bag's own r and a opens (verified — HDF5 shares the underlying file object when flags are compatible, which is also why the views stay coherent). All the invalidate-before-write and flag juggling would disappear, and evict/future batched ops could even go through the cached handle directly.

Cross-process it's a trap. A default-locking r+ handle holds HDF5's exclusive lock for as long as it's cached — I verified that another process then fails any open, reads included, with BlockingIOError, until we happen to drop the handle (which for a hot bag is never). Today no handle outlives a single operation, so this would turn "two jobs sharing a cache root" from working into deadlocked-until-timeout. And the escape hatch of r+ with locking=False just reintroduces the same flag-mismatch failure with H5Bag's default-flag opens that the current invalidation dance exists to avoid — verified as well.

So RW handles become a real simplification only once every open goes through us, i.e. once bagofholding can accept an injected handle/group — then one r+, locking=False handle per bag serves everything and cross-process coordination stays with the filelock sidecars, exactly as now. That's the sketch in the main thread. Alternatively, if you're happy declaring multi-bag roots single-process-at-a-time, I can switch to cached default-locking r+ handles now and the code does get meaningfully smaller — your call.


Generated by Claude Code

Comment thread src/fleche/storage/bagofholding_file.py Outdated
Comment on lines 470 to 473
with self._bag_reader(p) as f:
if f is not None:
keys.extend(Digest(name) for name in f.keys())
except OSError as e:

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.

f = None only on OSError, in which case this operation here would be skipped, rather than repeated as advised in by bagreaders docstring.

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.

Right, good catch — fixed in 4243748. Reads now go through _read_bag(file_path, reader), which on a mid-read error drops the broken handle and repeats the read once from a fresh open; only a failure of the fresh open still propagates (the file really is unreadable, matching the old per-open semantics). While testing this I also found that a closed h5py handle silently answers False to key in f instead of raising, so acquire() now additionally validates cached handles via h5py truthiness (not f) and reopens dead ones.


Generated by Claude Code

Review follow-ups on the handle cache:
- _MAX_OPEN_BAGS 32 -> 8.
- A cached handle that errors mid-read is now dropped and the read
  repeated once from a fresh open instead of skipping the operation
  (misreporting a rewritten bag as corrupt/missing); only a failure of
  the fresh open still propagates.
- acquire() validates cached handles with h5py truthiness: a closed
  handle silently answers False to `key in f` rather than raising, so
  staleness checks alone would not catch it.
- Comment in acquire() documenting why the two _meta_lock sections are
  race-free (the caller holds the per-bag lock throughout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DWRGyuEoH3qXNSQbSXddp

pmrv commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Fair characterization — the cache peeks at raw h5py handles for contains/list and has to step aside (invalidate + flag-juggle) every time the bagofholding API opens the file itself. Here's the sketch of what bagofholding would need for fleche to hold onto reusable bag state instead, in two sizes:

Option A — minimal: handle injection (two changes)

  1. Construct from an open group. H5Bag(source, ...) accepts a live h5py.Group/h5py.File in addition to a path (or a dedicated H5Bag.from_group(group)). When given a handle, the constructor and load() read from it and never close what they didn't open. The file.h5/subpath addressing 0.1.12 already does internally maps 1:1 to "a group inside an open file", so this is mostly threading a parameter through.
  2. Save into an open group. H5Bag.save(value, target) likewise accepts an open writable h5py.Group (caller does f.require_group(key)), writing in place and flushing rather than opening/closing the path itself.

That's sufficient. fleche then keeps exactly one handle per bag file, opened "r+" with locking=False, and routes every operation through it:

f = cache.acquire(bag_path)              # the existing MRU + stat-signature machinery
key in f                                 # contains
f.keys()                                 # list
H5Bag(f[key]).load(...)                  # get
H5Bag.save(value, f.require_group(key))  # put   (+ f.flush())
del f[key]                               # evict (+ f.flush())

Since nothing ever double-opens the file, the whole invalidate-on-write/flag-mismatch dance disappears (_bag_writer and the H5Bag-vs-cache mode split are deleted); cross-process coordination is unchanged — filelock sidecars for writers, stat-signature reopen for staleness. Fallback to "r" on read-only filesystems.

Option B — nicer: a first-class multi-bag container

Promote the multi-bag file to an object that owns the open handle:

bags = bagofholding.BagFile(path, mode="r+", locking=False)   # long-lived
key in bags;  bags.keys()
bags.load(key, version_validator=...)
bags.save(value, key)
del bags[key]
bags.flush();  bags.close()

fleche's cache then holds BagFile instances directly — the _BagHandleCache machinery (per-path lock, MRU, stat signature, fork guard) carries over unchanged with BagFile in place of h5py.File — and bagofholding_file.py collapses to path layout + locking bookkeeping. This also gives bagofholding a natural home for future batched ops (save_many, evict_many = one flush), which is what an evict faster than ~250 µs would need anyway.

Supporting needs under either option: pass-through of open kwargs (at least locking=), an explicit flush(), and documented single-writer expectations (bagofholding shouldn't need to know about fleche's filelocks, it just must not assume it owns the file exclusively).

Option A is small enough to be one upstream PR and I'd start there; B can grow out of it. Happy to draft the bagofholding side if useful.

Status of the review otherwise: cap is 8, reads retry once from a fresh open instead of skipping (plus closed-handle detection), and the _meta_lock question is answered on its thread — all in 4243748.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

Baseline: 2c236c0 (cached)

Delta

Significant changes (|Δ| > 10%): 220 — 20 other rows hidden

Value Storage

configuration workload function time @ 2c236c0 time @ head Δ vs base
BagOfHoldingH5File numpy_arrays contains_hit 280 µs 35 µs 🟢 -87.5%
BagOfHoldingH5File small_strings contains_hit 280 µs 35 µs 🟢 -87.5%
BagOfHoldingH5File nested_structures contains_hit 270 µs 35 µs 🟢 -87.0%
PickleFile_Signed small_strings evict 100 µs 42 µs 🟢 -58.0%
CloudpickleFile numpy_arrays evict 110 µs 47 µs 🟢 -57.3%
CloudpickleFile_Signed numpy_arrays evict 110 µs 47 µs 🟢 -57.3%
CloudpickleFile small_strings evict 100 µs 43 µs 🟢 -57.0%
CloudpickleFile_Signed small_strings evict 100 µs 43 µs 🟢 -57.0%
DillFile_Signed small_strings evict 100 µs 43 µs 🟢 -57.0%
DillFile numpy_arrays evict 110 µs 48 µs 🟢 -56.4%
DillFile_Signed numpy_arrays evict 110 µs 48 µs 🟢 -56.4%
DillFile small_strings evict 100 µs 44 µs 🟢 -56.0%
PickleFile numpy_arrays evict 110 µs 49 µs 🟢 -55.5%
PickleFile_Signed numpy_arrays evict 110 µs 49 µs 🟢 -55.5%
PickleFile small_strings evict 100 µs 45 µs 🟢 -55.0%
CloudpickleFile small_strings contains_miss 33 µs 17 µs 🟢 -48.5%
CloudpickleFile small_strings contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile_Signed numpy_arrays contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile numpy_arrays contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile nested_structures contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile_Signed nested_structures contains_miss 33 µs 17 µs 🟢 -48.5%
PickleFile nested_structures contains_hit 33 µs 17 µs 🟢 -48.5%
PickleFile_Signed nested_structures contains_hit 33 µs 17 µs 🟢 -48.5%
PickleFile_Signed nested_structures contains_miss 33 µs 17 µs 🟢 -48.5%
PickleFile_Signed small_strings contains_hit 33 µs 17 µs 🟢 -48.5%
PickleFile_Signed small_strings contains_miss 33 µs 17 µs 🟢 -48.5%
CloudpickleFile_Signed nested_structures contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile nested_structures contains_miss 33 µs 17 µs 🟢 -48.5%
DillFile small_strings contains_miss 33 µs 17 µs 🟢 -48.5%
DillFile_Signed numpy_arrays contains_hit 33 µs 17 µs 🟢 -48.5%
DillFile nested_structures contains_hit 33 µs 17 µs 🟢 -48.5%
CloudpickleFile_Signed small_strings contains_miss 33 µs 17 µs 🟢 -48.5%
CloudpickleFile_Signed nested_structures evict 56 µs 29 µs 🟢 -48.2%
PickleFile_Signed nested_structures evict 57 µs 30 µs 🟢 -47.4%
DillFile_Signed small_strings contains_hit 32 µs 17 µs 🟢 -46.9%
DillFile small_strings contains_hit 32 µs 17 µs 🟢 -46.9%
CloudpickleFile_Signed small_strings contains_hit 32 µs 17 µs 🟢 -46.9%
CloudpickleFile nested_structures load 280 µs 150 µs 🟢 -46.4%
DillFile nested_structures evict 56 µs 30 µs 🟢 -46.4%
DillFile nested_structures contains_miss 33 µs 18 µs 🟢 -45.5%
PickleFile nested_structures contains_miss 33 µs 18 µs 🟢 -45.5%
PickleFile_Signed numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
DillFile_Signed small_strings contains_miss 33 µs 18 µs 🟢 -45.5%
DillFile numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
DillFile numpy_arrays contains_hit 33 µs 18 µs 🟢 -45.5%
DillFile_Signed nested_structures contains_miss 33 µs 18 µs 🟢 -45.5%
DillFile_Signed nested_structures contains_hit 33 µs 18 µs 🟢 -45.5%
DillFile_Signed numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
PickleFile small_strings contains_miss 33 µs 18 µs 🟢 -45.5%
PickleFile numpy_arrays contains_hit 33 µs 18 µs 🟢 -45.5%
PickleFile small_strings contains_hit 33 µs 18 µs 🟢 -45.5%
PickleFile numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
CloudpickleFile_Signed numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
CloudpickleFile numpy_arrays contains_miss 33 µs 18 µs 🟢 -45.5%
CloudpickleFile nested_structures evict 55 µs 30 µs 🟢 -45.5%
CloudpickleFile_Signed small_strings load 290 µs 160 µs 🟢 -44.8%
DillFile nested_structures load 290 µs 160 µs 🟢 -44.8%
CloudpickleFile_Signed nested_structures load 290 µs 160 µs 🟢 -44.8%
PickleFile_Signed nested_structures load 290 µs 160 µs 🟢 -44.8%
PickleFile_Signed small_strings load 290 µs 160 µs 🟢 -44.8%
DillFile_Signed nested_structures evict 56 µs 31 µs 🟢 -44.6%
DillFile numpy_arrays load 320 µs 180 µs 🟢 -43.8%
PickleFile_Signed numpy_arrays contains_hit 32 µs 18 µs 🟢 -43.7%
PickleFile nested_structures evict 55 µs 31 µs 🟢 -43.6%
CloudpickleFile numpy_arrays load 300 µs 170 µs 🟢 -43.3%
DillFile_Signed nested_structures load 300 µs 170 µs 🟢 -43.3%
PickleFile numpy_arrays load 300 µs 170 µs 🟢 -43.3%
DillFile_Signed small_strings load 300 µs 170 µs 🟢 -43.3%
CloudpickleFile small_strings load 280 µs 160 µs 🟢 -42.9%
PickleFile nested_structures load 280 µs 160 µs 🟢 -42.9%
DillFile small_strings load 280 µs 160 µs 🟢 -42.9%
PickleFile small_strings load 270 µs 160 µs 🟢 -40.7%
CloudpickleFile nested_structures save 370 µs 230 µs 🟢 -37.8%
CloudpickleFile_Signed numpy_arrays load 370 µs 230 µs 🟢 -37.8%
PickleFile_Signed numpy_arrays load 370 µs 230 µs 🟢 -37.8%
DillFile_Signed numpy_arrays load 380 µs 240 µs 🟢 -36.8%
BagOfHoldingH5File numpy_arrays contains_miss 33 µs 21 µs 🟢 -36.4%
BagOfHoldingH5File small_strings contains_miss 33 µs 21 µs 🟢 -36.4%
BagOfHoldingH5File nested_structures contains_miss 33 µs 21 µs 🟢 -36.4%
PickleFile nested_structures save 360 µs 230 µs 🟢 -36.1%
DillFile nested_structures save 390 µs 250 µs 🟢 -35.9%
PickleFile_Signed nested_structures save 370 µs 240 µs 🟢 -35.1%
BagOfHoldingH5File nested_structures evict 260 µs 170 µs 🟢 -34.6%
CloudpickleFile small_strings save 350 µs 230 µs 🟢 -34.3%
PickleFile_Signed small_strings save 350 µs 230 µs 🟢 -34.3%
CloudpickleFile_Signed nested_structures save 360 µs 240 µs 🟢 -33.3%
CloudpickleFile_Signed small_strings save 360 µs 240 µs 🟢 -33.3%
DillFile small_strings save 370 µs 250 µs 🟢 -32.4%
PickleFile small_strings save 340 µs 230 µs 🟢 -32.4%
DillFile_Signed small_strings save 380 µs 270 µs 🟢 -28.9%
DillFile_Signed nested_structures save 380 µs 270 µs 🟢 -28.9%
Memory numpy_arrays save 15 µs 11 µs 🟢 -26.7%
BagOfHoldingH5File numpy_arrays save 2.3 ms 1.7 ms 🟢 -26.1%
Memory nested_structures load 8.8 µs 6.7 µs 🟢 -23.9%
Memory(Raw) nested_structures evict 2.1 µs 1.6 µs 🟢 -23.8%
BagOfHoldingH5File small_strings load 1.7 ms 1.3 ms 🟢 -23.5%
BagOfHoldingH5File small_strings save 1.7 ms 1.3 ms 🟢 -23.5%
Memory nested_structures save 9.4 µs 7.2 µs 🟢 -23.4%
Memory small_strings load 8.6 µs 6.6 µs 🟢 -23.3%
Memory small_strings contains_hit 7 µs 5.4 µs 🟢 -22.9%
Memory(Raw) small_strings load 3.1 µs 2.4 µs 🟢 -22.6%
Memory(Raw) nested_structures contains_miss 1.8 µs 1.4 µs 🟢 -22.2%
Memory(Raw) small_strings save 3.6 µs 2.8 µs 🟢 -22.2%
Memory(Raw) small_strings contains_miss 1.8 µs 1.4 µs 🟢 -22.2%
Memory(Raw) numpy_arrays contains_miss 1.8 µs 1.4 µs 🟢 -22.2%
BagOfHoldingH5File numpy_arrays load 1.8 ms 1.4 ms 🟢 -22.2%
Memory(Raw) nested_structures load 3.2 µs 2.5 µs 🟢 -21.9%
Memory small_strings save 9.2 µs 7.2 µs 🟢 -21.7%
Memory numpy_arrays contains_hit 6.9 µs 5.4 µs 🟢 -21.7%
Memory small_strings contains_miss 6.9 µs 5.4 µs 🟢 -21.7%
Memory numpy_arrays load 14 µs 11 µs 🟢 -21.4%
DillFile numpy_arrays save 660 µs 520 µs 🟢 -21.2%
Memory(Raw) small_strings evict 1.9 µs 1.5 µs 🟢 -21.1%
Memory numpy_arrays evict 7.3 µs 5.8 µs 🟢 -20.5%
BagOfHoldingH5File nested_structures save 1.5 ms 1.2 ms 🟢 -20.0%
Memory nested_structures contains_hit 7 µs 5.6 µs 🟢 -20.0%
Memory nested_structures evict 7.1 µs 5.7 µs 🟢 -19.7%
Memory(Raw) numpy_arrays evict 2.1 µs 1.7 µs 🟢 -19.0%
Memory(Raw) nested_structures save 3.7 µs 3 µs 🟢 -18.9%
Memory small_strings evict 6.9 µs 5.6 µs 🟢 -18.8%
Memory nested_structures contains_miss 6.9 µs 5.6 µs 🟢 -18.8%
Memory numpy_arrays contains_miss 6.9 µs 5.6 µs 🟢 -18.8%
Memory(Raw) numpy_arrays load 7.3 µs 6 µs 🟢 -17.8%
Memory(Raw) small_strings contains_hit 1.8 µs 1.5 µs 🟢 -16.7%
Memory(Raw) numpy_arrays contains_hit 1.8 µs 1.5 µs 🟢 -16.7%
Memory(Raw) nested_structures contains_hit 1.8 µs 1.5 µs 🟢 -16.7%
Memory(Raw) numpy_arrays save 8.2 µs 6.9 µs 🟢 -15.9%
BagOfHoldingH5File small_strings evict 600 µs 510 µs 🟢 -15.0%
BagOfHoldingH5File nested_structures load 1.1 ms 940 µs 🟢 -14.5%
DillFile_Signed numpy_arrays save 690 µs 590 µs 🟢 -14.5%
CloudpickleFile_Signed numpy_arrays save 550 µs 480 µs 🟢 -12.7%
PickleFile numpy_arrays save 420 µs 470 µs 🔴 +11.9%

Integration

configuration workload function time @ 2c236c0 time @ head Δ vs base
SizeLimitedCache(Memory,max=10) lightweight contains_hit 84 µs 58 µs 🟢 -31.0%
SizeLimitedCache(Memory,max=100) lightweight contains_miss 84 µs 58 µs 🟢 -31.0%
Memory lightweight miss 360 µs 250 µs 🟢 -30.6%
Memory compute_heavy contains_miss 82 µs 57 µs 🟢 -30.5%
SizeLimitedCache(Memory,max=10) data_heavy contains_hit 83 µs 58 µs 🟢 -30.1%
SizeLimitedCache(Memory,max=100) compute_heavy contains_miss 83 µs 58 µs 🟢 -30.1%
SizeLimitedCache(Memory,max=100) data_heavy contains_miss 83 µs 58 µs 🟢 -30.1%
SizeLimitedCache(Memory,max=10) lightweight contains_miss 83 µs 58 µs 🟢 -30.1%
SizeLimitedCache(Memory,max=100) lightweight hit 170 µs 120 µs 🟢 -29.4%
Memory(Raw) lightweight contains_hit 75 µs 53 µs 🟢 -29.3%
Memory data_heavy contains_miss 82 µs 58 µs 🟢 -29.3%
SizeLimitedCache(Memory,max=100) compute_heavy contains_hit 82 µs 58 µs 🟢 -29.3%
SizeLimitedCache(Memory,max=10) compute_heavy contains_hit 82 µs 58 µs 🟢 -29.3%
Memory compute_heavy contains_hit 82 µs 58 µs 🟢 -29.3%
Memory(Raw) lightweight contains_miss 76 µs 54 µs 🟢 -28.9%
Memory(Raw) compute_heavy contains_miss 76 µs 54 µs 🟢 -28.9%
SizeLimitedCache(Memory,max=10) lightweight miss 380 µs 270 µs 🟢 -28.9%
SizeLimitedCache(Memory,max=100) lightweight contains_hit 83 µs 59 µs 🟢 -28.9%
Memory lightweight contains_miss 83 µs 59 µs 🟢 -28.9%
SizeLimitedCache(Memory,max=10) compute_heavy contains_miss 83 µs 59 µs 🟢 -28.9%
SizeLimitedCache(Memory,max=10) data_heavy contains_miss 84 µs 60 µs 🟢 -28.6%
Memory(Raw) lightweight miss 320 µs 230 µs 🟢 -28.1%
Memory lightweight contains_hit 82 µs 59 µs 🟢 -28.0%
SizeLimitedCache(Memory,max=100) lightweight miss 360 µs 260 µs 🟢 -27.8%
SizeLimitedCache(Memory,max=10) compute_heavy hit 180 µs 130 µs 🟢 -27.8%
SizeLimitedCache(Memory,max=100) data_heavy hit 180 µs 130 µs 🟢 -27.8%
SizeLimitedCache(Memory,max=100) data_heavy contains_hit 83 µs 60 µs 🟢 -27.7%
Memory(Raw) data_heavy miss 460 µs 340 µs 🟢 -26.1%
Memory data_heavy miss 500 µs 370 µs 🟢 -26.0%
Memory data_heavy contains_hit 81 µs 60 µs 🟢 -25.9%
SizeLimitedCache(Memory,max=10) data_heavy miss 510 µs 380 µs 🟢 -25.5%
Memory(Raw) compute_heavy contains_hit 75 µs 56 µs 🟢 -25.3%
Memory compute_heavy hit 160 µs 120 µs 🟢 -25.0%
Memory(Raw) data_heavy hit 160 µs 120 µs 🟢 -25.0%
Memory lightweight hit 160 µs 120 µs 🟢 -25.0%
Memory(Raw) data_heavy contains_miss 76 µs 57 µs 🟢 -25.0%
SizeLimitedCache(Memory,max=100) data_heavy miss 500 µs 380 µs 🟢 -24.0%
Memory(Raw) data_heavy contains_hit 75 µs 57 µs 🟢 -24.0%
Memory+Sqlite(:memory:) compute_heavy contains_hit 380 µs 290 µs 🟢 -23.7%
H5+Sql compute_heavy contains_miss 380 µs 290 µs 🟢 -23.7%
Memory data_heavy hit 170 µs 130 µs 🟢 -23.5%
SizeLimitedCache(Memory,max=10) lightweight hit 170 µs 130 µs 🟢 -23.5%
Pickle+Sql lightweight hit 1.4 ms 1.1 ms 🟢 -21.4%
SizeLimitedCache(Memory,max=10) data_heavy hit 190 µs 150 µs 🟢 -21.1%
H5+Sql data_heavy contains_miss 390 µs 310 µs 🟢 -20.5%
H5+Sql data_heavy contains_hit 390 µs 310 µs 🟢 -20.5%
H5+Sql compute_heavy contains_hit 390 µs 310 µs 🟢 -20.5%
H5+Sql lightweight contains_hit 390 µs 310 µs 🟢 -20.5%
Memory(Raw) compute_heavy hit 150 µs 120 µs 🟢 -20.0%
Memory(Raw) lightweight hit 150 µs 120 µs 🟢 -20.0%
Pickle+Sql lightweight contains_miss 370 µs 300 µs 🟢 -18.9%
Memory+Sqlite(:memory:) compute_heavy contains_miss 370 µs 300 µs 🟢 -18.9%
Memory+Sqlite(:memory:) lightweight contains_miss 370 µs 300 µs 🟢 -18.9%
Pickle+Sql data_heavy contains_miss 370 µs 300 µs 🟢 -18.9%
SizeLimitedCache(Memory,max=100) compute_heavy hit 160 µs 130 µs 🟢 -18.8%
Memory+Sqlite(:memory:) lightweight contains_hit 380 µs 310 µs 🟢 -18.4%
H5+Sql lightweight contains_miss 380 µs 310 µs 🟢 -18.4%
Pickle+Sql data_heavy contains_hit 380 µs 310 µs 🟢 -18.4%
Pickle+Sql compute_heavy contains_miss 380 µs 310 µs 🟢 -18.4%
Pickle+Sql lightweight contains_hit 380 µs 310 µs 🟢 -18.4%
Memory+Sqlite(:memory:) compute_heavy hit 980 µs 810 µs 🟢 -17.3%
H5+Sql compute_heavy hit 2.9 ms 2.4 ms 🟢 -17.2%
H5+Sql data_heavy hit 3 ms 2.5 ms 🟢 -16.7%
Memory+Sqlite(:memory:) data_heavy contains_hit 370 µs 310 µs 🟢 -16.2%
Memory+Sqlite(:memory:) data_heavy hit 1 ms 840 µs 🟢 -16.0%
Pickle+Sql compute_heavy contains_hit 380 µs 320 µs 🟢 -15.8%
Pickle+Sql compute_heavy hit 1.4 ms 1.2 ms 🟢 -14.3%
Pickle+Sql data_heavy hit 1.4 ms 1.2 ms 🟢 -14.3%
Memory+Sqlite(:memory:) data_heavy contains_miss 360 µs 310 µs 🟢 -13.9%
H5+Sql lightweight hit 2.9 ms 2.5 ms 🟢 -13.8%
Pickle+Sql compute_heavy miss 4 ms 4.5 ms 🔴 +12.5%

Digest

workload function time @ 2c236c0 time @ head Δ vs base
Float digest 270 µs 210 µs 🟢 -22.2%
List (integers, len>100) digest 27 ms 21 ms 🟢 -22.2%
String (len<100) digest 140 µs 110 µs 🟢 -21.4%
Dict (small) digest 1.5 ms 1.2 ms 🟢 -20.0%
digest 120 µs 99 µs 🟢 -17.5%
Numpy (integers, len<100) digest 920 µs 760 µs 🟢 -17.4%
Nested (Random Hypothesis) digest 3.6 ms 3 ms 🟢 -16.7%
List (integers, len<100) digest 960 µs 810 µs 🟢 -15.6%
Integer digest 130 µs 110 µs 🟢 -15.4%
String (len>100) digest 230 µs 200 µs 🟢 -13.0%

Call Storage

configuration workload function time @ 2c236c0 time @ head Δ vs base
SqlMemory calls evict 290 µs 240 µs 🟢 -17.2%
SqlMemory calls contains_miss 240 µs 200 µs 🟢 -16.7%
SqlMemory calls contains_hit 250 µs 210 µs 🟢 -16.0%
SqlMemory calls load 720 µs 630 µs 🟢 -12.5%
SqlFile calls contains_miss 240 µs 210 µs 🟢 -12.5%
SqlFile calls contains_hit 250 µs 220 µs 🟢 -12.0%
SqlFile calls load 740 µs 660 µs 🟢 -10.8%

Full results

Call Storage

calls

configuration contains_hit contains_miss evict load save
🔴 SqlFile 🟩 220 µs 🟩 210 µs 🟩 430 µs 🟩 660 µs 🟥 2.2 ms
🔴 SqlMemory 🟩 210 µs 🟩 200 µs 🟩 240 µs 🟩 630 µs 🟧 1.8 ms
Digest
workload digest
Dict (small) 🟩 1.2 ms
Float 🟩 210 µs
Integer 🟩 110 µs
List (integers, len<100) 🟩 810 µs
List (integers, len>100) 🟥 21 ms
Nested (Random Hypothesis) 🟩 3 ms
None 🟩 99 µs
Numpy (integers, len<100) 🟩 760 µs
Numpy (integers, len>100) 🟩 2.6 ms
String (len<100) 🟩 110 µs
String (len>100) 🟩 200 µs
Integration

compute_heavy

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 290 µs 🟨 2.4 ms 🟥 6.8 ms
🟢 Pickle+Sql 🟩 320 µs 🟩 310 µs 🟩 1.2 ms 🟨 4.5 ms
🟣 Memory 🟩 58 µs 🟩 57 µs 🟩 120 µs 🟩 1.2 ms
🟣 Memory(Raw) 🟩 56 µs 🟩 54 µs 🟩 120 µs 🟩 1.2 ms
🟣 SizeLimitedCache(Memory,max=10) 🟩 58 µs 🟩 59 µs 🟩 130 µs 🟩 1.3 ms
🟣 SizeLimitedCache(Memory,max=100) 🟩 58 µs 🟩 58 µs 🟩 130 µs 🟩 1.2 ms
🟤 Memory+Sqlite(:memory:) 🟩 290 µs 🟩 300 µs 🟩 810 µs 🟨 2.7 ms

data_heavy

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 310 µs 🟨 2.5 ms 🟥 7 ms
🟢 Pickle+Sql 🟩 310 µs 🟩 300 µs 🟩 1.2 ms 🟨 3.5 ms
🟣 Memory 🟩 60 µs 🟩 58 µs 🟩 130 µs 🟩 370 µs
🟣 Memory(Raw) 🟩 57 µs 🟩 57 µs 🟩 120 µs 🟩 340 µs
🟣 SizeLimitedCache(Memory,max=10) 🟩 58 µs 🟩 60 µs 🟩 150 µs 🟩 380 µs
🟣 SizeLimitedCache(Memory,max=100) 🟩 60 µs 🟩 58 µs 🟩 130 µs 🟩 380 µs
🟤 Memory+Sqlite(:memory:) 🟩 310 µs 🟩 310 µs 🟩 840 µs 🟩 1.9 ms

lightweight

configuration contains_hit contains_miss hit miss
🟠 H5+Sql 🟩 310 µs 🟩 310 µs 🟨 2.5 ms 🟥 5.7 ms
🟢 Pickle+Sql 🟩 310 µs 🟩 300 µs 🟩 1.1 ms 🟨 3.5 ms
🟣 Memory 🟩 59 µs 🟩 59 µs 🟩 120 µs 🟩 250 µs
🟣 Memory(Raw) 🟩 53 µs 🟩 54 µs 🟩 120 µs 🟩 230 µs
🟣 SizeLimitedCache(Memory,max=10) 🟩 58 µs 🟩 58 µs 🟩 130 µs 🟩 270 µs
🟣 SizeLimitedCache(Memory,max=100) 🟩 59 µs 🟩 58 µs 🟩 120 µs 🟩 260 µs
🟤 Memory+Sqlite(:memory:) 🟩 310 µs 🟩 300 µs 🟩 980 µs 🟩 1.7 ms
Value Storage

nested_structures

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 17 µs 🟩 17 µs 🟩 30 µs 🟩 150 µs 🟩 230 µs
🔵 CloudpickleFile_Signed 🟩 17 µs 🟩 17 µs 🟩 29 µs 🟩 160 µs 🟩 240 µs
🟠 BagOfHoldingH5File 🟩 35 µs 🟩 21 µs 🟩 170 µs 🟧 940 µs 🟥 1.2 ms
🟡 DillFile 🟩 17 µs 🟩 18 µs 🟩 30 µs 🟩 160 µs 🟩 250 µs
🟡 DillFile_Signed 🟩 18 µs 🟩 18 µs 🟩 31 µs 🟩 170 µs 🟩 270 µs
🟢 PickleFile 🟩 17 µs 🟩 18 µs 🟩 31 µs 🟩 160 µs 🟩 230 µs
🟢 PickleFile_Signed 🟩 17 µs 🟩 17 µs 🟩 30 µs 🟩 160 µs 🟩 240 µs
🟣 Memory 🟩 5.6 µs 🟩 5.6 µs 🟩 5.7 µs 🟩 6.7 µs 🟩 7.2 µs
🟣 Memory(Raw) 🟩 1.5 µs 🟩 1.4 µs 🟩 1.6 µs 🟩 2.5 µs 🟩 3 µs

numpy_arrays

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 17 µs 🟩 18 µs 🟩 47 µs 🟩 170 µs 🟩 480 µs
🔵 CloudpickleFile_Signed 🟩 17 µs 🟩 18 µs 🟩 47 µs 🟩 230 µs 🟩 480 µs
🟠 BagOfHoldingH5File 🟩 35 µs 🟩 21 µs 🟨 570 µs 🟧 1.4 ms 🟥 1.7 ms
🟡 DillFile 🟩 18 µs 🟩 18 µs 🟩 48 µs 🟩 180 µs 🟩 520 µs
🟡 DillFile_Signed 🟩 17 µs 🟩 18 µs 🟩 48 µs 🟩 240 µs 🟨 590 µs
🟢 PickleFile 🟩 18 µs 🟩 18 µs 🟩 49 µs 🟩 170 µs 🟩 470 µs
🟢 PickleFile_Signed 🟩 18 µs 🟩 18 µs 🟩 49 µs 🟩 230 µs 🟩 480 µs
🟣 Memory 🟩 5.4 µs 🟩 5.6 µs 🟩 5.8 µs 🟩 11 µs 🟩 11 µs
🟣 Memory(Raw) 🟩 1.5 µs 🟩 1.4 µs 🟩 1.7 µs 🟩 6 µs 🟩 6.9 µs

small_strings

configuration contains_hit contains_miss evict load save
🔵 CloudpickleFile 🟩 17 µs 🟩 17 µs 🟩 43 µs 🟩 160 µs 🟩 230 µs
🔵 CloudpickleFile_Signed 🟩 17 µs 🟩 17 µs 🟩 43 µs 🟩 160 µs 🟩 240 µs
🟠 BagOfHoldingH5File 🟩 35 µs 🟩 21 µs 🟨 510 µs 🟥 1.3 ms 🟥 1.3 ms
🟡 DillFile 🟩 17 µs 🟩 17 µs 🟩 44 µs 🟩 160 µs 🟩 250 µs
🟡 DillFile_Signed 🟩 17 µs 🟩 18 µs 🟩 43 µs 🟩 170 µs 🟩 270 µs
🟢 PickleFile 🟩 18 µs 🟩 18 µs 🟩 45 µs 🟩 160 µs 🟩 230 µs
🟢 PickleFile_Signed 🟩 17 µs 🟩 17 µs 🟩 42 µs 🟩 160 µs 🟩 230 µs
🟣 Memory 🟩 5.4 µs 🟩 5.4 µs 🟩 5.6 µs 🟩 6.6 µs 🟩 7.2 µs
🟣 Memory(Raw) 🟩 1.5 µs 🟩 1.4 µs 🟩 1.5 µs 🟩 2.4 µs 🟩 2.8 µs

@pmrv

pmrv commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

I'd like to see a sketch of an option C as well, where bagofholding does everything transparently and keeps a file open behind a timeout for a while in case of repeated operations.

pmrv commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Option C — transparent handle pool inside bagofholding

The public API stays path-based (H5Bag(path).load(), H5Bag.save(value, path)); internally every h5py.File open routes through a process-wide pool that keeps handles open for a while after use:

# bagofholding internals
_pool = FilePool(max_open=8, idle_timeout=5.0)  # both configurable

class FilePool:
    def open(self, path, *, write=False) -> ContextManager[h5py.File]: ...
    # checkout: refcount += 1; checkin: refcount -= 1, stamp idle deadline
    def flush_all(self): ...   # close every idle handle now (tests, Windows, rsync)

# every internal open becomes
with _pool.open(path, write=True) as f:
    ...
    f.flush()   # writes flush at checkin — durability must not wait for the timeout

Key internals (all four are things this PR already had to solve, so they transplant directly):

  • One handle per absolute path, opened r+ with locking=False, falling back to r on read-only files. Because all opens go through the pool, the same-process mode/flag conflicts that force the invalidation dance in this PR vanish by construction — there is only ever the one handle. Cross-process exclusion stays with the caller (fleche's sidecar filelocks); the pool must not add OS-level locking of its own.
  • Stat-signature revalidation at checkout (inode, mtime_ns, size) so files rewritten by other processes are reopened, and a fork guard (pid check) that abandons inherited handles.
  • Lifetime = refcount + idle deadline + LRU cap. A checked-in handle isn't closed; it gets deadline = now + idle_timeout. Reaping is the one genuinely new design decision:
    • (a) lazy — every pool operation closes expired handles in passing. No background thread, but a handle can linger past its deadline in a quiet process (harmless on Linux; visible as held fds/locks on Windows). Needs the explicit flush_all() escape hatch.
    • (b) daemon timer thread — prompt reaping, but bagofholding grows a background thread with fork- and interpreter-shutdown care.
    • I'd start with (a) + flush_all() + an atexit/weakref.finalize sweep; (b) can be added behind the same interface.
  • Per-path checkout lock (readers share, writers exclusive) — the direct analogue of this PR's per-bag lock, just living next to the handle it guards.

What C does not remove: fleche's contains/list/evict still need something to ask "is this group in the file / which groups / delete this group" — today that's the raw-h5py peeking you objected to. So C still wants a small query surface, which is exactly Option A's injected-handle constructor or Option B's BagFile container. In other words, C is less an alternative than a placement decision: the pool slots naturally underneath B (BagFile(path) checks out of the pool transparently; contains becomes key in BagFile(path) with zero opens when warm).

Trade-offs vs A/B:

A (inject handle) B (BagFile container) C (transparent pool)
upstream effort smallest moderate largest (pool, reaping, config, docs)
fleche-side cache code keeps _BagHandleCache machinery moves onto BagFile deleted entirely
who benefits fleche fleche + explicit users every bagofholding user
handle lifetime explicit (caller) explicit (object) implicit (timeout) — most surprising: fds stay open, files "busy" on Windows, after a bare H5Bag.save(); needs opt-out (FilePool(idle_timeout=0) ≡ today's behavior)
eviction/batching path caller's problem natural (del bags[key], future save_many) natural once B's surface exists

My recommendation stands as a sequence rather than a choice: land A's injected-handle constructor (small, unblocks fleche dropping the raw-h5py peeking), grow B's BagFile on top of it, then move this PR's cache logic upstream as C's pool under BagFile — at which point bagofholding_file.py here reduces to path layout + filelocks, and the fleche-side cache is deleted rather than maintained in parallel. Happy to draft the bagofholding side starting from whichever rung you want.


Generated by Claude Code

@pmrv
pmrv marked this pull request as draft July 30, 2026 17:03
@pmrv

pmrv commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Will let this sit for a bit and discuss with liam. 300ums per open hurt my pride, but are also not life threatening.

pmrv added a commit that referenced this pull request Aug 23, 2026
…theme (#889)

Scheduled AGENTS.md audit, 2026-08-23.

No code has landed on `main` since the 2026-08-21 audit (63a0584), and
the issue/PR state recorded in `agents/DEVELOPING.md` is still current:
in-flight PRs #873#878, #881, #887, and #797 all remain open; no new
issues since #883#886. Spot-checks of the Quick Reference and
Architecture claims against `src/` all pass (public `__all__`,
`_lazy_default`/`_sticky_set`/`_hard_set`, `PreparedCall`/two-phase
save, `register_storage`, `_CACHE_TEMPLATES`,
`Runtime.cputime`/`systime`, module line counts quoted in #789/#832).
`AGENTS.md` and `agents/USAGE.md` need no changes.

One gap found: the performance theme names "pooled file handles in
`bagofholding_file.py`" as a fix candidate without noting that draft PR
#786 (open since 2026-07-23) already implements it, and PR #804 (open
since 2026-07-31) — the benchmark-harness fix that removes the
always-evicting `SizeLimitedCache(max_size=10)` config — was recorded
nowhere. Both are now listed under the perf theme as
check-before-duplicating entries, added as separate paragraphs to keep
future edits conflict-free.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01H9uSJpX8SLJrtYbc2ZaSv5

---
_Generated by [Claude
Code](https://claude.ai/code/session_01H9uSJpX8SLJrtYbc2ZaSv5)_

Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
pmrv added a commit that referenced this pull request Aug 28, 2026
…qlFile fsync cheap fix (#898)

Weekly AGENTS.md/DEVELOPING.md audit. Only one change since the
2026-08-27 pass (#897): issue #625's perf audit was refreshed the same
morning.

- Update the perf-audit pointer in `agents/DEVELOPING.md` from
"refreshed 2026-08-20" to 2026-08-27 and record the run's verdict (no
new source-caused regressions; flagged rows are the chronic
`BagOfHoldingH5File` per-op open cost or noise).
- Record the still-open SQL-side hot spot the refreshed audit re-flags:
`Sql` fsyncs once per key on `save`/`evict` because
`_configure_sqlite_pragmas` (`src/fleche/storage/sql.py:190-227`) sets
`journal_mode=WAL` but no `synchronous` pragma; the cheap fix (`PRAGMA
synchronous=NORMAL`) has been flagged in every audit since 2026-05-07.
Verified against the source — the function sets only `foreign_keys` and
`journal_mode`.

Everything else checked and current: no merges to `main` since #897;
open PRs (#873, #874, #887, #892, #894, #896, #797, #786, #804) and
issues (#893, #895, #625) are all already recorded.
`eisenforschung/landau` was audited in the same pass and needs no update
(nothing landed since its 2026-08-25 pass; in-flight PRs
#391/#394/#395/#414/#422 unchanged; spot-checked claims hold).

---
_Generated by [Claude
Code](https://claude.ai/code/session_012SCLy9y7aUR7F44Q7UviGo)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark Run benchmark action

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants