Skip to content

perf(fullmap): upgrade redb 2→4, open fullmap readers with shared locks - #68

Merged
SkyeAv merged 10 commits into
mainfrom
feat/redb4-readonly-fullmap
Aug 7, 2026
Merged

perf(fullmap): upgrade redb 2→4, open fullmap readers with shared locks#68
SkyeAv merged 10 commits into
mainfrom
feat/redb4-readonly-fullmap

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

Upgrades the fullmap entity-resolution store's embedded database engine from redb 2.6 → 4.1 and switches the entire fullmap read path from an exclusive-lock redb::Database open to a shared-lock redb::ReadOnlyDatabase open.

Why

redb 2.x takes an exclusive flock on every open — even pure reads — so only one opener per fullmap file could exist system-wide. The agent supervisor, its code-executor subprocesses, and parallel agent run processes all serialized on that lock (masked only by the Python-side _call_with_lock_retry backoff), and a killed-mid-build subprocess could strand it.

redb ≥ 3 added ReadOnlyDatabase (shared lock), so any number of processes read concurrently; only a build-fullmap rebuild (exclusive writer) briefly blocks readers. redb 4.1 additionally speeds up concurrent multi-threaded reads (~15% on upstream benchmarks) and general write performance (~1.5x on upstream write benchmarks), which benefits the build's redb write phase.

Changes

  • Bump redb 2 → 4 (deps + dev-deps); ReadableDatabase trait import where begin_read() moved.
  • Read path (DB_CACHE, open_cached/open_cached_shard, schema validation, shard fan-out lookups, CURIE/dim hydration, post-build cache prime) now uses ReadOnlyDatabase.
  • Map DatabaseError::UpgradeRequired(_)/RepairAborted at read-only open to the actionable rebuild hint.
  • Write path keeps the exclusive-lock Database; set_durability handled as Result. The 16-shard concurrent-writer scheme is unchanged (still redb's maximum write parallelism).
  • Schema tag bumped to tablassert.fullmap.v5 (layout unchanged) to make the rebuild explicit.
  • New tests: two coexisting read-only handles; writer-blocks-reader-until-dropped; byte-patch tests proving the UpgradeRequired/RepairAborted → rebuild-hint mapping.

⚠️ Breaking — one-time rebuild required

redb ≥ 3 dropped the v2 file format, so existing fullmap.redb (and sibling fullmap.s*.redb) files must be rebuilt once via tablassert build-fullmap after upgrading (BABEL downloads stay cached, so the rebuild is cheap). Until rebuilt, lookups fail with the actionable error: fullmap DB is outdated or needs repair; rebuild with 'tablassert build-fullmap'.

Verification

make check (local gate): ✅ All checks passed!

  • ruff lint + format: clean
  • pyright: 0 errors, 0 warnings
  • pytest: 745 passed (98% coverage)
  • cargo test: 59 lib + 10 golden passed
  • cargo clippy --all-targets -- -D warnings: clean

End-to-end proof of the fix: built a small fullmap and ran concurrent lookup_fullmap_terms from two separate processes — both succeeded (this is exactly what fails under redb 2's exclusive lock).

Code review: CODE_REVIEWER subagent, 2 rounds. Round 1 returned 2 should-fixes (incorrect lock-semantics comments) + 3 nits; all fixed in b175c55. Round 2: approve, zero findings.

Summary by CodeRabbit

  • Improvements

    • Fullmap lookups now support concurrent read-only access across processes.
    • Coverage operations can run concurrently without leaving locks behind after interruptions.
    • Rebuilds use exclusive access and keep cached data consistent as files change.
    • Cached lookups now follow newly rebuilt fullmaps automatically.
  • Breaking Changes

    • Fullmaps must be rebuilt for the new v5 schema, including databases created with v4.
    • Older, incompatible, or damaged fullmaps now provide guidance to rebuild them.

SkyeAv added 3 commits August 6, 2026 14:33
…locks

Fullmap is the sole redb user. redb 2.x takes an EXCLUSIVE flock on every
open — even pure reads — so only one opener per fullmap file could exist
system-wide: the agent supervisor, its code-executor subprocesses, and
parallel agent runs serialized on that lock (masked by the Python-side
lock-retry backoff), and a killed-mid-build subprocess could strand it.

redb ≥ 3 added ReadOnlyDatabase (shared lock); 4.1 additionally speeds up
concurrent multi-threaded reads (~15% on upstream benchmarks) and general
write performance (~1.5x on upstream write benchmarks), which benefits the
build's redb write phase. This change:

- Bumps redb 2.6 → 4.1 (deps + dev-deps).
- Switches the ENTIRE read path (DB cache, open_cached/open_cached_shard,
  schema validation, shard fan-out lookups, CURIE/dim hydration, and the
  post-build cache prime) to ReadOnlyDatabase — shared locks, so any number
  of processes read concurrently; only a build-fullmap rebuild (writer)
  briefly blocks readers.
- Maps UpgradeRequired/RepairAborted at read-only open to the actionable
  rebuild hint (a read-only open can neither upgrade the old v2 file format
  nor repair a crash-damaged file).
- Write path keeps the exclusive-lock Database; set_durability now returns
  Result (redb ≥ 3). The 16-shard concurrent-writer scheme is unchanged —
  it remains redb's maximum write parallelism (single WriteTransaction per
  file by design).
- Bumps the schema tag to tablassert.fullmap.v5 (layout unchanged) so the
  rebuild is explicit and a downgraded extension rejects new files loudly.

BREAKING: redb ≥ 3 dropped the v2 file format; existing fullmap files must
be rebuilt once via 'tablassert build-fullmap' (BABEL downloads stay
cached, so the rebuild is cheap).
Follow-up comment/docstring updates for the redb 4 shared-lock read path
(no behavior change):

- fullmap.py: the lock-retry comment now describes the reader-vs-writer
  (rebuild) window — readers no longer contend with each other under the
  shared lock; note in _db_cache_key that read-only opens never touch the
  mtime, so the mtime-keyed lookup caches are stable across lookups.
- agent.py: derive_coverage derivations no longer 'serialize on the
  fullmap lock across processes' (shared locks run concurrently; only a
  rebuild blocks them); a killed executor no longer strands an EXCLUSIVE
  lock (at most a shared one, released on process death) — the
  execution_timeout rationale is reworded accordingly.
CODE_REVIEWER findings on the redb 4 shared-lock change (behavior was
already correct; these fix invariant-bearing comments and harden tests):

- DB_CACHE doc: stop claiming a shared lock blocks an external rebuild.
  Rebuilds remove+recreate the files on fresh inodes (unlink needs no
  lock), so a stale cross-process handle reads the unlinked old file as a
  consistent snapshot — state that as the real invariant.
- lookup_terms doc: opening the primary once is a cache choice, not a lock
  constraint (read-only opens coexist now).
- set_durability doc: it fails only on PersistentSavepointModified; this
  crate never uses savepoints, so the Result is unreachable (not 'once the
  transaction has been used').
- Rename build_fullmap_db_writes_schema_v4_sharded_layout →
  build_fullmap_db_writes_sharded_layout (it writes/asserts v5 now).
- Byte-patch tests: assert the raw DatabaseError variant (UpgradeRequired(2)
  / RepairAborted) BEFORE the mapped rebuild hint, so they pin which variant
  fired instead of both passing on the shared match arm.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: db1134ea-6767-49cf-9e3f-a7201c9d7f90

📥 Commits

Reviewing files that changed from the base of the PR and between b175c55 and 64fc2f1.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • docs/fullmap.md
  • rust/src/fullmap.rs
  • rust/tests/build_golden.rs
  • src/tablassert/agent.py
  • src/tablassert/fullmap.py
  • tests/test_fullmap.py

📝 Walkthrough

Walkthrough

The fullmap storage layer upgrades redb from 2 to 4, introduces schema v5, and uses shared read-only handles for lookups. Rebuilds use exclusive access, outdated databases return rebuild guidance, and generation-aware caching keeps primary and shard reads consistent.

Changes

Fullmap redb 4 migration

Layer / File(s) Summary
Schema and dependency contracts
rust/Cargo.toml, rust/src/fullmap.rs, docs/fullmap.md, CHANGELOG.md, rust/tests/build_golden.rs
redb changes to version 4. Fullmap schema v5 becomes current, and v1–v4 databases require rebuilding. Metadata tests verify matching numeric build IDs across shards.
Build metadata and generation-aware caching
rust/src/fullmap.rs
Builds persist a shared build ID in the primary and shard databases. Read-only cache entries track file identity and generation, reject mixed bundles, and retry transient generation changes.
Read-only lookup and hydration paths
rust/src/fullmap.rs, rust/examples/count_tables.rs
Primary and shard opens, shard fan-out, table loading, CURIE hydration, and the table-count example use read-only database handles. Legacy shard counts remain supported.
Concurrency validation and integration support
rust/src/fullmap.rs, src/tablassert/agent.py, src/tablassert/fullmap.py, tests/test_fullmap.py
Tests cover shared-handle coexistence, writer contention, generation replacement, snapshot behavior, layout metadata, and redb error mapping. Python retry handling and documentation describe shared locks and generation changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LookupProcess
  participant ReadOnlyDatabase
  participant FullmapShards
  participant RebuildWriter
  LookupProcess->>ReadOnlyDatabase: open primary read-only handle
  ReadOnlyDatabase->>FullmapShards: open shard handles
  LookupProcess->>FullmapShards: validate matching build IDs
  RebuildWriter->>ReadOnlyDatabase: acquire exclusive rebuild lock
  RebuildWriter->>FullmapShards: write new primary and shard generation
  LookupProcess->>ReadOnlyDatabase: retry changed generation
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the redb upgrade and shared-lock fullmap reader changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/redb4-readonly-fullmap

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/src/fullmap.rs (1)

2333-2343: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Pin each fullmap lookup to one primary-plus-shards generation.

open_cached and open_cached_shard cache handles by path only. After build_fullmap_db unlinks and recreates the files, a lookup can retain an old primary while opening new shards. The primary provides META, dimensions, and CURIE IDs; the shards provide record IDs. Rebuilds can change these IDs and shard routing, which can return incorrect CURIEs, misses, or missing-ID errors.

  • rust/src/fullmap.rs:2333-2392,2595-2607: Coordinate the primary and shard handles as one generation. Use an atomic bundle publication or a generation token with device/inode checks before and after opening all files, then retry when the generation changes.
  • rust/src/fullmap.rs:3756-3801: Add a replacement-during-lookup test. Verify that one lookup never mixes generations and that the next lookup opens the replacement.
  • Update the cache comments and Python mtime-based caches to use the same generation boundary.
  • Qualify the concurrent-reader claims in docs/fullmap.md, src/tablassert/fullmap.py, and src/tablassert/agent.py until rebuild and lookup coordination covers the complete primary-plus-shards bundle.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/fullmap.rs` around lines 2333 - 2343, Make fullmap lookups
generation-consistent: update open_cached and open_cached_shard in
rust/src/fullmap.rs:2333-2392,2595-2607 to publish or validate a shared
primary-plus-shards generation using device/inode checks before and after
opening all files, retrying when it changes; update cache comments at
rust/src/fullmap.rs:70-83 and add replacement-during-lookup coverage at
rust/src/fullmap.rs:3756-3801. Align Python mtime-based caches at
src/tablassert/fullmap.py:25-29 and src/tablassert/agent.py:2316-2319 with that
generation boundary, and qualify concurrent-reader claims in
docs/fullmap.md:125-127 until complete bundle coordination is guaranteed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 8: Update the CHANGELOG entry for rebuilding older fullmap databases to
remove the claim that the rebuild is cheap. Retain the note that cached BABEL
downloads avoid re-downloading inputs, while accurately stating that the rebuild
reprocesses inputs and rewrites every fullmap file.

---

Outside diff comments:
In `@rust/src/fullmap.rs`:
- Around line 2333-2343: Make fullmap lookups generation-consistent: update
open_cached and open_cached_shard in rust/src/fullmap.rs:2333-2392,2595-2607 to
publish or validate a shared primary-plus-shards generation using device/inode
checks before and after opening all files, retrying when it changes; update
cache comments at rust/src/fullmap.rs:70-83 and add replacement-during-lookup
coverage at rust/src/fullmap.rs:3756-3801. Align Python mtime-based caches at
src/tablassert/fullmap.py:25-29 and src/tablassert/agent.py:2316-2319 with that
generation boundary, and qualify concurrent-reader claims in
docs/fullmap.md:125-127 until complete bundle coordination is guaranteed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b8a4315-0c47-41c2-bf37-ec825c2c3e2f

📥 Commits

Reviewing files that changed from the base of the PR and between f1bdea5 and b175c55.

⛔ Files ignored due to path filters (1)
  • rust/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/fullmap.md
  • rust/Cargo.toml
  • rust/examples/count_tables.rs
  • rust/src/fullmap.rs
  • rust/tests/build_golden.rs
  • src/tablassert/agent.py
  • src/tablassert/fullmap.py

Comment thread CHANGELOG.md Outdated
@SkyeAv SkyeAv closed this Aug 6, 2026
@SkyeAv SkyeAv reopened this Aug 6, 2026
SkyeAv added 6 commits August 7, 2026 12:18
The rebuild still reprocesses the input and rewrites every fullmap file;
only the BABEL downloads stay cached. CodeRabbit wording fix on PR #68.
A rebuild replaces the fullmap files on fresh inodes (unlink + recreate),
but DB_CACHE keyed handles by canonical path only — so a cache hit could
serve an old-generation handle next to new-generation ones (in-process:
cache_database swapped only the primary entry; cross-process: stale
handles read the unlinked files forever).

Record (st_dev, st_ino) via MetadataExt for every cached handle
(cache_database included) and validate it on every cache hit in
open_cached / open_cached_shard: a mismatch evicts the entry and reopens
the replacement (the primary re-runs validate_schema). A hit whose path
is absent (mid-rebuild window) keeps serving the old snapshot, preserving
the cross-process reader behavior; on a miss the generation is stat'ed
before the open so a racing rebuild costs at most one extra reopen.

open_cached_shards now pins one primary-plus-shards generation: it
captures the primary's (dev,ino) before opening shards and re-stats after
the last one, retrying the whole bundle up to 5 times before raising
PyRuntimeError, so a lookup never mixes files from two builds.
… lookups

Deterministic rename-over swaps (no sleeps/races): replacing a cached
primary or shard file makes the next open see the new content; a full
lookup after a rebuild-at-same-path returns only the new generation
(old term gone, new term hydrated against the new primary); an absent
path serves the cached old snapshot until the replacement appears.
…oundary

Add the missing docstring to the nested get_fullmap in make_tools (the
only undocumented changed function behind CodeRabbit's docstring-coverage
warning) and qualify the concurrent-reader claims with the generation
boundary — lookups pin one primary-plus-shards generation; readers follow
a rebuild on the next lookup — in the make_tools docstring, the fullmap
lock-comment block, the _db_cache_key Note, and docs/fullmap.md.
…window

A rebuild commits the new primary BEFORE the shard files are created, so
mid-build the primary path holds the new inode while every shard path is
absent. A warm-cache reader could then mix the new primary with old shard
snapshots: each shard hit took the absent-path arm while the stable new
primary passed the post-stat generation check.

- open_cached_path now reports HOW it served: Served::Current vs
  Served::StaleAbsent, plumbed through open_cached_shard to
  open_cached_shards; only io::ErrorKind::NotFound serves the stale
  snapshot — any other metadata error propagates.
- open_cached_shards accepts a bundle only when generationally
  consistent: ALL Current with a stable primary generation, or ALL
  StaleAbsent (the documented old-snapshot arm); MIXED retries the whole
  bundle, then raises the "changing generation" exhaustion error.
- "changing generation" joins Python's _LOCK_RETRY_TOKENS so
  _call_with_lock_retry retries the exhaustion; the primary-appears-first
  invariant is cross-referenced at the up-front unlink and drop(database).
- Tests: mixed window exhausts then follows the new generation; all-absent
  serves the old snapshot; exhaustion message carries the retry token.
Generate one build_id per fullmap build and write it to the primary META plus every shard META. Shard META is safe here because v5 already mandates a rebuild, so no shard files without META exist in the wild.

Cache each handle's build_id and require primary/shard build_id equality before serving any bundle, including all-stale cached snapshots. Retry mixed or diverged bundles with a short delay so slow shard creation does not burn all attempts instantly.
@SkyeAv

SkyeAv commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

All review feedback addressed:

Rebuild wording (inline comment)d464291 applies the proposed CHANGELOG wording: BABEL downloads stay cached, but the command rebuilds the fullmap files.

Generation pinning (Major, outside-diff) — implemented as a generation token, in three layers:

  • Every cached read handle records its (dev, ino) file generation and is re-validated on every cache hit; a rebuild at the same path evicts the stale entry and reopens the replacement (2df04a9).
  • open_cached_shards pins one primary-plus-shards generation per lookup: serve kinds are tracked per handle (current vs absent-path snapshot), mixed bundles are retried, and an absent path serves the old snapshot only for NotFound (f76fd37).
  • Each build stamps a build_id into the primary META and every shard META, and a bundle is accepted only when every member's build_id matches — this closes the mid-build window (new primary committed before shard files exist) and diverged-cache cases deterministically, where stat-based checks alone could still pair two builds (64fc2f1).

Tests cover: cached handles following a replacement, absent-path snapshot serving, mixed-window retry-then-follow, diverged-cache rejection, the reverse unlink-phase shape, and rebuild-at-same-path lookup consistency.

Docstring coverageget_fullmap now has a docstring, taking changed-function coverage to 100%.

Known boundary, deliberately out of scope: lookup_rows makes separate extension calls for pairs and CURIE hydration, so a rebuild landing between two calls is not pinned — each individual call is generation-consistent.

@SkyeAv
SkyeAv merged commit ec5e75f into main Aug 7, 2026
4 of 5 checks passed
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