Skip to content

fix(ai-hist): stop an interrupted sync from wedging into a permanent re-scan loop - #45

Merged
khaliqgant merged 3 commits into
mainfrom
fix/sync-state-checkpointing
Aug 3, 2026
Merged

fix(ai-hist): stop an interrupted sync from wedging into a permanent re-scan loop#45
khaliqgant merged 3 commits into
mainfrom
fix/sync-state-checkpointing

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 3, 2026

Copy link
Copy Markdown
Member

Follow-up to #44. That PR stopped a truncated .sync-state.json from aborting sync. This one stops an interrupted sync from throwing away everything it had done.

Problem

sync_basic wrote .sync-state.json exactly once, at the very end of the run. Any death before that point discarded every source that had already completed, so the next run re-scanned the same files from scratch — and died in the same place. One interrupted run becomes a loop that never persists anything.

Observed on a live ~3GB database: ~148MB of Claude history re-scanned every cycle, indefinitely.

Changes

1. Checkpoint after each source, and within a source. State is saved as soon as a source completes, and JSONL ingestion now runs in 2000-line chunks that checkpoint their byte offset per commit. An interrupted run resumes mid-file rather than restarting. Chunking also takes the write lock once per chunk instead of once per row, and avoids holding it for minutes on a large backlog — which would starve the other writers.

Checkpoints are deliberately non-fatal: the rows are already committed, so a run that did real work shouldn't be reported as failed because a bookkeeping write didn't land.

2. Merge checkpoints rather than overwriting. Sync runs aren't serialized (CLI, background service, and in-process napi can overlap), so writing the whole in-memory map wholesale let a slow run replace a faster run's newer cursors. Checkpoints now merge per key, and monotonic cursors mean a non-advancing offset is dropped — a checkpoint can never rewind another run's progress. Also skips the write when disk is already current, instead of rewriting the full file once per source every minute.

3. Raise the busy timeout from rusqlite's 5s default to 30s. Scope note: this was written believing it addressed the outage that motivated this PR. It does not. That outage was caused by a process holding the write lock permanently (see below), which no timeout survives. This change only helps ordinary transient overlap between active writers, and is included on those narrower merits. Bounded retry with backoff would be the better mechanism — tracked in #49.

Scope — what this does not do

None of this explains why a writer holds the lock. The outage that prompted the work was an agent-relay process SIGSTOP'd mid-transaction: a stopped process never runs again on its own, so it holds the single WAL write lock forever. sqlite3 with a 10s busy timeout waited the full 10s and still failed.

This PR makes sync survivable: progress accumulates across runs instead of resetting, so sync converges over several cycles even when individual runs die. It does not make it contention-free. Tracked follow-ups: #47 (single-writer architecture), #48 (per-source isolation), #49 (BUSY retry), #50 (why brokers get suspended).

Testing

  • jsonl_ingest_checkpoints_mid_source_and_resumes_from_there — checkpoints land inside the source, advance monotonically, and resuming from one ingests exactly the remainder
  • a_slow_run_cannot_rewind_or_clobber_a_faster_runs_cursors — verified as a real regression test: fails against the wholesale write, rewinding the cursor to 400
  • an_unchanged_source_does_not_rewrite_the_state_file
  • checkpoints_persist_between_sources_and_never_abort_a_run — including that an unwritable destination warns instead of unwinding
  • open_db_waits_longer_than_the_rusqlite_default_for_a_busy_writer — verified as a real regression test: fails left: 5000, right: 30000 without the change

Full workspace suite passes (64 tests). Validated end-to-end against the live database exhibiting the loop: the rollback-then-checkpoint path is what persisted state there.

🤖 Generated with Claude Code

khaliqgant and others added 2 commits August 3, 2026 08:30
sync_basic wrote .sync-state.json once, at the very end of the run. Any
death before that point -- killed process, locked database, full disk --
discarded every source that had already finished, so the next run re-scanned
the same files from scratch. One interrupted run turns into a loop that
never persists anything: observed locally as ~148MB of Claude history being
re-scanned every cycle, indefinitely, because the run consistently died
after the claude source but before the final save.

Save after each source that advances state, so a cursor becomes durable as
soon as its source completes. An interrupted run now costs only the source
it died in, not the whole run.

Checkpoints are deliberately non-fatal. The rows are already committed, so a
run that did real work should not be reported as failed because a bookkeeping
write did not land; the next checkpoint retries, and save_sync_state leaves
the prior state intact on failure, so the worst case is a re-scan rather
than corruption.

This does not explain why the process dies mid-run -- that is still
unidentified -- but it stops a single mid-run death from erasing all
progress and wedging sync into a permanent re-scan loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sync died mid-run with "database is locked" (SQLITE_BUSY). The database is
shared by several long-lived writers -- the launchd sync agent, the MCP
server, relay brokers -- and WAL admits one writer at a time. rusqlite
already waits 5s by default, but a lock here can outlast that: the database
is ~3GB with a large WAL, and recovery or checkpointing holds it well past
five seconds. Sync is the longest-running writer, so it is the one most
often waiting when that happens.

Raise the busy timeout on the shared connection to 30s. sync_opencode_db
already sets its own timeout for the same reason; this applies the same
treatment to the main connection, which had none of its own.

This is a mitigation for long lock holds, not a fix for their cause: a
writer holding the lock beyond 30s will still fail. Paired with per-source
checkpointing, a failure now costs one source rather than the whole run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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

Warning

Review limit reached

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

Next review available in: 51 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: bef602d1-691f-44c7-bca9-aeb22efc07ce

📥 Commits

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

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

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: 6f88066bb2

ℹ️ 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
/// leaves the previous state intact when a write fails, so the worst case is a
/// re-scan rather than corruption.
fn checkpoint_sync_state(path: &Path, state: &Map<String, Value>) {
if let Err(err) = save_sync_state(path, state) {

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 Prevent stale checkpoints from replacing newer sync state

When two sync_basic runs overlap, each loads an independent copy of the entire state map, and this checkpoint atomically replaces the whole file without merging or serialization. A lagging run that has only completed Claude can therefore overwrite newer Codex/Cursor/etc. cursors published by a run that finished those sources; if the lagging run then fails, the next run rescans that work, recreating the loop this change is intended to prevent. Overlap is possible through the unlocked CLI, cron, and in-process NAPI entry points, so sync runs should be serialized or checkpoints should update only the source state they just completed.

Useful? React with 👍 / 👎.

Comment thread crates/ai-hist/src/lib.rs
Comment on lines +1693 to +1695
checkpoint_sync_state(&state_path, &state);
sync_claude_session_metadata(conn, &mut state, &home.join(".claude/projects"))?;
checkpoint_sync_state(&state_path, &state);

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 Avoid rewriting unchanged state after every source

During a steady-state watch or the default 60-second service run, sources commonly report “up to date” without changing state, but these unconditional calls still serialize and rename the complete .sync-state.json seven times per cycle. Because the map contains per-file entries for rollouts, transcripts, and trajectories, large installations can incur seven full-file rewrites every minute instead of the previous single rewrite; checkpoint only when the just-completed source actually changed the state.

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 across 2 files

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

Re-trigger cubic

Comment thread crates/ai-hist/src/lib.rs
Comment thread crates/ai-hist/src/lib.rs
…e file

Review feedback (raised independently by two reviewers). Checkpoints wrote
this run's entire in-memory state map wholesale. Sync runs are not
serialized -- the CLI, the background service, and the in-process napi entry
point can all overlap -- and each holds its own copy of that map, so a slow
run that had only finished claude could replace a faster run's newer codex
and cursor positions with its own stale ones. The next run then rescanned
work that was already done, which is the re-scan loop checkpointing exists
to prevent.

Merge per key against what is on disk instead. Sources this run did not
touch are preserved, and because cursors are monotonic byte offsets, an
offset that has not advanced past the stored one is stale and is dropped --
so a checkpoint can never rewind another run's progress.

Also skip the write entirely when disk already reflects our state. In steady
state every source reports "up to date" and checkpoints an unchanged map, so
the previous code rewrote the full file once per source, seven times a
minute, for no change.

Two tests: a_slow_run_cannot_rewind_or_clobber_a_faster_runs_cursors (fails
against the wholesale write, rewinding the cursor to 400) and
an_unchanged_source_does_not_rewrite_the_state_file.

This narrows the race rather than eliminating it -- the read-modify-write is
still not atomic across processes -- but combined with monotonic cursors it
converges safely. Full serialization is tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant
khaliqgant merged commit 2dbd925 into main Aug 3, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/sync-state-checkpointing branch August 3, 2026 07:48
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