Skip to content

feat(ai-hist): read-only reads, lock diagnostics, and write guardrails - #46

Merged
khaliqgant merged 1 commit into
mainfrom
fix/db-contention-hardening
Aug 3, 2026
Merged

feat(ai-hist): read-only reads, lock diagnostics, and write guardrails#46
khaliqgant merged 1 commit into
mainfrom
fix/db-contention-hardening

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 3, 2026

Copy link
Copy Markdown
Member

Items 2–4 of the hardening plan for a SQLite database shared by several long-lived processes. Follows #44 (corrupt state file) and #45 (checkpointed progress).

Context: a multi-day sync outage traced to an agent-relay process that was SIGSTOP'd mid-write-transaction. A stopped process never runs again on its own, so it held the single WAL write lock permanently. No timeout or retry survives that. These changes reduce the blast radius and make the condition diagnosable in one command.

1. Read-only handles for read commands

Every command went through open_db, which calls init_db — applying the schema is a write, so search, recent, and stats were acquiring a write lock they never needed.

They now open with SQLITE_OPEN_READ_ONLY and skip init_db. A read can then neither block the writer nor be blocked behind it; WAL readers proceed against their snapshot regardless of who holds the write lock.

is_read_only is deliberately conservative — anything not explicitly listed keeps a writable handle, so a miscategorised command fails safe (an unnecessary lock) rather than unsafe (a write on a read-only connection). export qualifies because it only writes to a separate destination file.

Verified against the live wedged database: stats, recent, and search all return normally while the write lock is held.

2. ai-hist doctor

Reports size, WAL size, free space, whether a writer can start, and who holds the file open — flagging holders in state T (stopped) or Z (zombie), which can never release.

Actual output against the wedged database:

database: /Users/khaliqgant/.local/share/ai-hist/ai-history.db
  size:  2.8 GB
  WAL:   149.0 MB
  free:  11.8 GB
  write lock: BLOCKED (database is locked)
  holders:
    pid 13214    T     .../agent-relay node up  <-- WEDGED
    pid 85652    Ts    .../agent-relay node up  <-- WEDGED

Problems:
  - write lock unavailable: database is locked
  - pid 13214 is T and holds the database open; it cannot release the write lock (resume it: kill -CONT 13214)
  - WAL is 149.0 MB -- checkpointing is starved, usually by a long-lived reader

That diagnosis previously took hours of manual lsof/ps work. --json is available for scripting. The probe uses a short 1.5s timeout so doctor reports promptly rather than inheriting the 30s production wait.

3. Write guardrails

  • Free-space floor — sync refuses to start below 512MB rather than failing partway. An out-of-space write is precisely what truncated .sync-state.json and started this whole incident; stopping up front with an actionable message beats discovering it through torn state.
  • WAL checkpoint — after a successful sync, PRAGMA wal_checkpoint(TRUNCATE), best effort (a concurrent reader blocks a full checkpoint, which is not a reason to fail a sync that did its work), plus a warning when the WAL stays large. Unchecked it reached 156MB in practice.

Testing

66 tests pass. New coverage:

  • a_read_only_handle_can_read_but_never_write — reads succeed, writes are rejected
  • only_non_mutating_commands_get_a_read_only_handle — asserts Sync/Tag are not classified read-only, the direction that would break at runtime
  • a_stopped_or_zombie_holder_is_reported_as_wedgedT/Ts/Z flagged, S/R/Ss not
  • human_bytes_scales_units

doctor was validated against the real wedged database, not just unit tests.

Note

Also applies rustfmt to a few pre-existing non-conforming spots in these files, so both crates are now fmt-clean. Formatting-only hunks, separable in review.

Not included

Item 5 — routing agent-relay and the MCP server through append-only spools so only ai-hist sync writes SQLite. That's the change that makes this class of failure impossible rather than merely survivable, and it deserves its own ADR.

🤖 Generated with Claude Code

Review in cubic

@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 69e04620-c42a-4d72-a132-b0a84c701098

📥 Commits

Reviewing files that changed from the base of the PR and between a038aff and 42a8685.

📒 Files selected for processing (2)
  • crates/ai-hist-core/src/lib.rs
  • crates/ai-hist/src/lib.rs
📝 Walkthrough

Walkthrough

The core library adds shared SQLite lock timeouts and read-only connections. The CLI adds the doctor command, read-only command routing, database diagnostics, disk-space checks, and checkpointed, chunked synchronization.

Changes

Database reliability features

Layer / File(s) Summary
Read-only database access and lock handling
crates/ai-hist-core/src/lib.rs, crates/ai-hist/src/lib.rs
The core library adds open_db_readonly and a shared 30-second busy timeout. Non-mutating CLI commands use read-only connections when an existing database is available. Tests cover read-only writes and writer lock waiting.
Doctor diagnostics and reporting
crates/ai-hist/src/lib.rs
The doctor command reports database size, WAL size, free space, lock availability, open holders, wedged processes, and detected problems in text or JSON.
Checkpointed synchronization and chunked ingestion
crates/ai-hist/src/lib.rs
Sync checks free space, checkpoints progress between sources and JSONL chunks, rolls back only failed chunks, preserves prior checkpoints, and attempts WAL truncation.

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

Suggested reviewers: miyaontherelay, kjgbot

Poem

A rabbit checks the WAL at night,
Then hops through chunks in orderly flight.
Locks wait calmly, checkpoints stay,
JSONL resumes another day.
“Doctor,” says Bun, “the database is bright!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: read-only database access, lock diagnostics, and write safeguards.
Description check ✅ Passed The description directly explains the database hardening changes, diagnostics, safeguards, testing, and excluded work.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 fix/db-contention-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a038affc01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/ai-hist/src/lib.rs Outdated
Comment on lines +507 to +510
let conn = if is_read_only(&cli.command) && db_path.exists() {
open_db_readonly(&db_path)?
} else {
open_db(&db_path)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run pending schema migrations before read-only opens

When an existing database was created by an older release, this branch opens it read-only and permanently skips init_db; for example, search_all unconditionally queries session_events/session_events_fts, so a user upgrading from before those tables were added gets no such table on their first search instead of the previous automatic migration. The read-only path needs a schema-version check or a one-time writable migration before serving queries.

Useful? React with 👍 / 👎.

Comment thread crates/ai-hist/src/lib.rs
Comment on lines +494 to +495
| Command::Export { .. }
| Command::Doctor { .. }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route Pack through the read-only connection

When ai-hist pack runs while another process holds the database write lock, it still falls through to open_db, which executes schema initialization and can wait or fail on that lock. pack_entries only calls the read-only search path and formats output, so omitting Command::Pack defeats the new noncontending-read behavior for this query command.

Useful? React with 👍 / 👎.

Comment thread crates/ai-hist/src/lib.rs Outdated
Comment on lines +1836 to +1840
for holder in holders.iter().filter(|h| h.is_wedged()) {
problems.push(format!(
"pid {} is {} and holds the database open; it cannot release the write lock (resume it: kill -CONT {})",
holder.pid, holder.state, holder.pid
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify a stopped holder actually blocks the write lock

When a stopped process merely has an idle or read-only database handle open, lsof includes it even though probe_write_lock succeeds, yet this loop reports that it cannot release the write lock and recommends kill -CONT. Having the main database file open does not establish ownership of a write transaction, so this produces a contradictory false diagnosis; only associate the holder with a wedged write lock when the probe is blocked and ownership can be established.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/ai-hist/src/lib.rs
Three hardening changes for a database shared by several long-lived
processes, each addressing something that made a recent multi-day sync
outage harder to survive or to diagnose.

Read-only handles for read commands. Every command went through open_db,
which runs init_db -- applying the schema is a write, so `search`, `recent`,
`stats`, and `pack` were taking a write lock they never needed. They now
open with SQLITE_OPEN_READ_ONLY and skip init_db, so a query can neither
block the writer nor be blocked behind it. is_read_only is conservative:
anything unlisted keeps a writable handle, so a miscategorised command fails
safe. Because init_db is also where migrations live, a read-only open is
gated on schema_is_current -- a database from an older release falls back to
a writable open that migrates it, rather than being served queries against
tables it does not have.

`ai-hist doctor`. Reports database and WAL size, free space, whether a
writer can start, and which processes hold the file open with their process
state, flagging holders that are stopped (T) or zombies (Z). Those never run
again on their own, so a write transaction they hold is held forever and no
busy timeout escapes it. It claims causation only when a writer is actually
blocked: holding the file open is not the same as owning a write
transaction, and SQLite will not say who holds the lock. Diagnosing exactly
this took hours of manual lsof/ps work; it is now one command that also
prints the remedy.

Write guardrails. Sync refuses to start below a free-space floor rather than
failing partway -- an out-of-space write is what truncated .sync-state.json
and wedged sync in the first place. After a successful sync it checkpoints
the WAL (TRUNCATE, best effort) and warns when the WAL stays large, which
indicates a long-lived reader pinning an old snapshot; an unchecked WAL grew
to 156MB in practice.

Formatting: also applies rustfmt to a handful of pre-existing spots in these
files that were already non-conforming, so the crates are now fmt-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant
khaliqgant force-pushed the fix/db-contention-hardening branch from 71f13ec to 42a8685 Compare August 3, 2026 07:52
@khaliqgant
khaliqgant merged commit bfc5e9c into main Aug 3, 2026
2 checks passed
@khaliqgant
khaliqgant deleted the fix/db-contention-hardening branch August 3, 2026 07:54

@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: 2

🧹 Nitpick comments (3)
crates/ai-hist/src/lib.rs (2)

1706-1709: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Build the WAL path from the OS string, not from Display.

Path::display() replaces invalid UTF-8 with U+FFFD. For a non-UTF-8 database path, wal_path then returns a path that does not exist, and both doctor and sync_basic silently report a WAL size of 0.

♻️ Proposed change
 fn wal_path(db_path: &Path) -> PathBuf {
-    PathBuf::from(format!("{}-wal", db_path.display()))
+    let mut name = db_path.as_os_str().to_os_string();
+    name.push("-wal");
+    PathBuf::from(name)
 }
🤖 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 `@crates/ai-hist/src/lib.rs` around lines 1706 - 1709, Update wal_path to
construct the “-wal” suffix from the database path’s raw OS-string
representation rather than Path::display(), preserving non-UTF-8 bytes and
returning the correct sidecar path for doctor and sync_basic.

1855-1914: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a non-zero exit status when doctor finds problems.

doctor returns Ok(()) in both branches. A cron job or health check must parse stdout or the JSON payload to learn that the database is wedged. A distinct exit code makes the command usable in a script directly. If you change this, document the codes in the command help, because the change is user-visible.

🤖 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 `@crates/ai-hist/src/lib.rs` around lines 1855 - 1914, Update the doctor
command’s final status handling so it returns a non-zero exit status whenever
the problems collection is non-empty, including the JSON output path, while
preserving zero for healthy databases. Document the resulting exit-code meanings
in the doctor command’s help text, using the existing doctor implementation and
problems handling symbols.
crates/ai-hist-core/src/lib.rs (1)

270-280: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop SQLITE_OPEN_URI for path consistency.

open_db opens the user-supplied path as a literal filename, while open_db_readonly treats the same value as a URI when accepted. If it starts with file: or contains URI query parameters, the two paths behave differently.

♻️ Proposed change
     let conn = Connection::open_with_flags(
         path,
-        OpenFlags::SQLITE_OPEN_READ_ONLY
-            | OpenFlags::SQLITE_OPEN_NO_MUTEX
-            | OpenFlags::SQLITE_OPEN_URI,
+        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
     )
🤖 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 `@crates/ai-hist-core/src/lib.rs` around lines 270 - 280, Remove
OpenFlags::SQLITE_OPEN_URI from the flags used by open_db_readonly, keeping only
the read-only and no-mutex flags so user-supplied paths are interpreted
consistently with open_db.
🤖 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 `@crates/ai-hist/src/lib.rs`:
- Around line 504-511: The doctor command must not create a missing database. In
crates/ai-hist/src/lib.rs lines 504-511, dispatch Command::Doctor before opening
the connection so open_db is never called; in lines 1793-1798, check db_path
existence and return an explicit “database does not exist” result instead of
calling Connection::open.
- Around line 3298-3322: Update the JSONL read loop around reader.read_line so
an unterminated trailing line is not parsed, counted, committed, or included in
consumed; break before advancing the checkpoint when read_line returns bytes
without a terminating newline, leaving the offset at the start of that partial
record for the next run.

---

Nitpick comments:
In `@crates/ai-hist-core/src/lib.rs`:
- Around line 270-280: Remove OpenFlags::SQLITE_OPEN_URI from the flags used by
open_db_readonly, keeping only the read-only and no-mutex flags so user-supplied
paths are interpreted consistently with open_db.

In `@crates/ai-hist/src/lib.rs`:
- Around line 1706-1709: Update wal_path to construct the “-wal” suffix from the
database path’s raw OS-string representation rather than Path::display(),
preserving non-UTF-8 bytes and returning the correct sidecar path for doctor and
sync_basic.
- Around line 1855-1914: Update the doctor command’s final status handling so it
returns a non-zero exit status whenever the problems collection is non-empty,
including the JSON output path, while preserving zero for healthy databases.
Document the resulting exit-code meanings in the doctor command’s help text,
using the existing doctor implementation and problems handling symbols.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bbced79f-6228-4dfa-a335-952c9ae6697f

📥 Commits

Reviewing files that changed from the base of the PR and between 07e5aea and a038aff.

📒 Files selected for processing (2)
  • crates/ai-hist-core/src/lib.rs
  • crates/ai-hist/src/lib.rs

Comment thread crates/ai-hist/src/lib.rs
Comment on lines +504 to +511
// Read-only commands get a handle that cannot take the write lock, so a
// query never contends with the writer. Falls back to a writable open when
// the database does not exist yet, since that first open has to create it.
let conn = if is_read_only(&cli.command) && db_path.exists() {
open_db_readonly(&db_path)?
} else {
open_db(&db_path)?
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

doctor creates the database it is supposed to diagnose. Both sites open the database with an opener that creates the file when it is missing, so running ai-hist doctor on a machine with no database reports on a database it just created instead of reporting that none exists.

  • crates/ai-hist/src/lib.rs#L504-L511: dispatch Command::Doctor before the connection is opened, so open_db never runs for this command.
  • crates/ai-hist/src/lib.rs#L1793-L1798: return an explicit "database does not exist" result when db_path does not exist, instead of calling Connection::open.
📍 Affects 1 file
  • crates/ai-hist/src/lib.rs#L504-L511 (this comment)
  • crates/ai-hist/src/lib.rs#L1793-L1798
🤖 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 `@crates/ai-hist/src/lib.rs` around lines 504 - 511, The doctor command must
not create a missing database. In crates/ai-hist/src/lib.rs lines 504-511,
dispatch Command::Doctor before opening the connection so open_db is never
called; in lines 1793-1798, check db_path existence and return an explicit
“database does not exist” result instead of calling Connection::open.

Comment thread crates/ai-hist/src/lib.rs
Comment on lines +3298 to +3322
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
break;
}
consumed += read as u64;
if !line.trim().is_empty() {
match parser(&line) {
Ok(Some(entry)) => inserted += insert_history(conn, &entry)?,
Ok(None) => {}
Err(_) => errors += 1,
}
}
pending += 1;
if pending >= JSONL_CHUNK_LINES {
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));
checkpoint(state);
conn.execute_batch("BEGIN")?;
pending = 0;
}
}
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

A partial trailing line advances the checkpoint and loses the record.

read_line returns the bytes it read even when the file ends without \n. The source files are appended by a live agent, so the last line can be half-written at the moment of the read. The code then counts those bytes in consumed, hands the torn text to parser (which usually increments errors), and commits the advanced offset. The remainder of that record arrives later in the file but is never read, because the offset already moved past its start.

Stop at an unterminated line and leave the offset before it. The next run reads the complete record.

🐛 Proposed fix
                 let read = reader.read_line(&mut line)?;
                 if read == 0 {
                     break;
                 }
+                // The producer appends live: a line without a terminator may be
+                // half-written. Leave the offset before it and read it next run.
+                if !line.ends_with('\n') {
+                    break;
+                }
                 consumed += read as u64;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
break;
}
consumed += read as u64;
if !line.trim().is_empty() {
match parser(&line) {
Ok(Some(entry)) => inserted += insert_history(conn, &entry)?,
Ok(None) => {}
Err(_) => errors += 1,
}
}
pending += 1;
if pending >= JSONL_CHUNK_LINES {
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));
checkpoint(state);
conn.execute_batch("BEGIN")?;
pending = 0;
}
}
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
break;
}
// The producer appends live: a line without a terminator may be
// half-written. Leave the offset before it and read it next run.
if !line.ends_with('\n') {
break;
}
consumed += read as u64;
if !line.trim().is_empty() {
match parser(&line) {
Ok(Some(entry)) => inserted += insert_history(conn, &entry)?,
Ok(None) => {}
Err(_) => errors += 1,
}
}
pending += 1;
if pending >= JSONL_CHUNK_LINES {
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));
checkpoint(state);
conn.execute_batch("BEGIN")?;
pending = 0;
}
}
conn.execute_batch("COMMIT")?;
state.insert(name.to_string(), json!(consumed));
🤖 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 `@crates/ai-hist/src/lib.rs` around lines 3298 - 3322, Update the JSONL read
loop around reader.read_line so an unterminated trailing line is not parsed,
counted, committed, or included in consumed; break before advancing the
checkpoint when read_line returns bytes without a terminating newline, leaving
the offset at the start of that partial record for the next run.

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