Skip to content

feat(libcalibre): enable WAL + busy_timeout PRAGMAs and add connection pools#134

Merged
phildenhoff merged 1 commit into
mainfrom
cdl-18-libcalibre-enable-wal-busy_timeout-pragma
Jul 1, 2026
Merged

feat(libcalibre): enable WAL + busy_timeout PRAGMAs and add connection pools#134
phildenhoff merged 1 commit into
mainfrom
cdl-18-libcalibre-enable-wal-busy_timeout-pragma

Conversation

@phildenhoff

Copy link
Copy Markdown
Member

Why

libcalibre opened SQLite as a single bare connection with no PRAGMAs — no WAL, no busy_timeout, default rollback journal. Fine for one desktop user; it blocks any concurrent-access scenario (a future citadel-server, or the app and a server sharing one metadata.db). Both halves of this are independently useful to the desktop app today: WAL + busy_timeout remove the spurious SQLITE_BUSY risk from the DB-write + metadata.opf-write pair per book edit.

What

PRAGMAs on every connection (apply_pragmas), in order: busy_timeout = 3000 first (so the WAL switch itself waits out contention), then journal_mode = WAL, synchronous = NORMAL, foreign_keys = ON, wal_autocheckpoint = 1000. foreign_keys = ON is future-proofing — neither Calibre's base schema nor the custom-column DDL declares any FK constraint, verified against the fixture and a generated stress library.

Pooled paths (diesel r2d2 feature):

  • create_write_pool — exactly one connection (SQLite has one write lock; a multi-writer pool only queues on it). Registers Calibre's triggers once: triggers are persistent database objects, so per-checkout DDL would both serialize opens on the write lock and open windows where a concurrent insert fires no trigger.
  • create_read_pool — N connections with PRAGMA query_only = ON; writing through a read connection is a hard readonly error rather than a documented convention.
  • A CustomizeConnection applies the genuinely per-connection state (PRAGMAs + custom SQL functions) to every physical connection.

Error handling: establish_connection returns CalibreError instead of Result<_, ()>, surfacing SQLite's real open error ("Unable to open the database file"); Library::new propagates it. No caller matched the old LibraryNotInitialized collapse.

retry_on_busy helper for the locked errors busy_timeout cannot cover (read→write transaction upgrades fail immediately to avoid deadlock).

The desktop app's single-connection path is unchanged apart from the PRAGMAs and the improved error.

Tests

11 tests in connection_pragmas_test.rs: WAL + busy_timeout + -wal/-shm sidecars on a file DB; :memory: fallback; write-pool setup (PRAGMAs, functions, triggers, trigger-driven sort/uuid generation); query_only enforcement; and multi-connection contention — a reader proceeding with snapshot isolation during an open write transaction, a second writer failing instantly at busy_timeout = 0 but waiting out a 300ms-held lock at the 3000ms default, and retry_on_busy retrying through a held lock. Contention tests ran 5× consecutively without flakes.

Soak: generated a 2000-book stress library with all writes under WAL — foreign_key_check and integrity_check clean. Full libcalibre suite (17 binaries) green; workspace clippy/fmt clean.

Out of scope (per ticket)

The server crate, the cross-process write policy (research doc D6), and the pre-existing trigger-DDL-on-open race between two processes opening the same library.

Fixes CDL-18

🤖 Generated with Claude Code

…n pools

Every connection now gets sane PRAGMAs (busy_timeout 3000, WAL,
synchronous NORMAL, foreign_keys ON, wal_autocheckpoint 1000), applied
before anything else so the WAL switch itself waits out contention.
foreign_keys=ON is future-proofing: Calibre's schema declares no FK
constraints today.

Adds pooled connection paths via diesel's r2d2 feature:
- create_write_pool: exactly one connection (SQLite has one write
  lock); registers Calibre's triggers once — triggers are persistent
  database objects, so per-checkout DDL would serialize opens on the
  write lock and open windows where inserts fire no trigger.
- create_read_pool: N connections with PRAGMA query_only, so writing
  through a read connection is a hard error, not a convention.
- Per-connection state (PRAGMAs + custom SQL functions) is applied by
  a CustomizeConnection on every physical connection.

Also:
- establish_connection returns CalibreError instead of Result<_, ()>,
  surfacing SQLite's real open error; Library::new propagates it.
- retry_on_busy helper for the locked errors busy_timeout cannot
  cover (read->write transaction upgrades fail immediately).
- Tests: PRAGMAs + sidecar files, :memory: fallback, pool setup,
  query_only enforcement, and multi-connection contention (reader
  unblocked during write txn; busy_timeout waiting out a held lock
  vs failing instantly at timeout 0; retry_on_busy under contention).

The desktop app's single-connection path is unchanged apart from the
PRAGMAs and improved error.

Fixes CDL-18

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

CDL-18

@phildenhoff phildenhoff enabled auto-merge (squash) July 1, 2026 21:40
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

libcalibre Test Coverage Report

Overall coverage: 82.07%

📊 Download HTML Report

Coverage breakdown available in the artifacts.

@phildenhoff phildenhoff merged commit f5e2219 into main Jul 1, 2026
7 checks passed
@phildenhoff phildenhoff deleted the cdl-18-libcalibre-enable-wal-busy_timeout-pragma branch July 1, 2026 21:52
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