Skip to content

Fix batch rewind archival in watcher#574

Open
EtanHey wants to merge 1 commit into
mainfrom
fix/batch-rewind-archival
Open

Fix batch rewind archival in watcher#574
EtanHey wants to merge 1 commit into
mainfrom
fix/batch-rewind-archival

Conversation

@EtanHey

@EtanHey EtanHey commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Buffer rewind session ids in watcher, flush as batched UPDATE with IN (...).
  • Reuse one VectorStore for rewind archival until shutdown.
  • Add interval and threshold-based batching with fallback shutdown flush.
  • Keep direct write path and idempotent guard .
  • Add tmp_path-only tests for batching, threshold, interval, shutdown, and idempotency.

Note

Medium Risk
Changes how the live watcher writes to the chunk store on rewind; behavior should match prior soft-archive rules, but delayed flushes mean archival is no longer immediate per rewind until batch/interval/shutdown triggers.

Overview
Rewind archival in brainlayer watch is batched instead of one DB round-trip per session.

When a JSONL checkpoint rewind is detected, the watcher no longer opens VectorStore and runs a per-session UPDATE immediately. It queues session IDs in _RewindArchiveBatcher, which issues a single batched UPDATE (with conversation_id IN (...)) for realtime_watcher chunks that are not yet archived, reusing one store connection until shutdown.

Flushes run when the pending session count hits a threshold (default 50, overridable via BRAINLAYER_REWIND_ARCHIVE_BATCH), after a time interval (default flush_interval * 3, overridable via BRAINLAYER_REWIND_ARCHIVE_INTERVAL_MS), and always on watcher exit in a finally block. The archived_at IS NULL guard keeps repeat rewinds idempotent.

New tests in test_rewind_batch_archival.py cover multi-session batching, threshold and interval flushes, shutdown flush, and idempotency.

Reviewed by Cursor Bugbot for commit 7cf7fcc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix batch rewind archival in the watcher by coalescing session updates into a single DB write

  • Introduces _RewindArchiveBatcher in brainlayer/cli/__init__.py to buffer session IDs and flush them in a single UPDATE against the chunks table, rather than one DB write per rewind event.
  • Flush is triggered when the pending batch reaches a size threshold (default 50) or a configurable time interval elapses; a final flush runs on watcher shutdown via try/finally.
  • Batch size and interval are configurable via BRAINLAYER_REWIND_ARCHIVE_BATCH and BRAINLAYER_REWIND_ARCHIVE_INTERVAL_MS environment variables, with fallback defaults and warnings on invalid values.
  • Adds tests in tests/test_rewind_batch_archival.py covering threshold flush, interval flush, idempotency, and shutdown flush.
  • Behavioral Change: the on_rewind handler no longer writes to the DB immediately; archival is deferred until a flush condition is met.
📊 Macroscope summarized 7cf7fcc. 1 file reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@greptile-apps greptile-apps 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f39eac7e-d8f5-4847-97c8-63944b625819)

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 48 seconds

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: ASSERTIVE

Plan: Pro

Run ID: 33f7d0dc-ee98-4b02-914e-f7409d67cd2b

📥 Commits

Reviewing files that changed from the base of the PR and between 25d612a and 7cf7fcc.

📒 Files selected for processing (2)
  • src/brainlayer/cli/__init__.py
  • tests/test_rewind_batch_archival.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/batch-rewind-archival

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.

flush_interval_ms=rewind_archive_interval_ms,
)

def on_rewind(filepath: str, session_id: str, old_offset: int, new_offset: int):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High cli/__init__.py:3361

Buffering rewinds by session_id causes the delayed batch flush in _RewindArchiveBatcher.flush() to archive all rows with that conversation_id and archived_at IS NULL. Because JSONLWatcher.poll_once() continues processing new_lines from the restored timeline immediately after calling on_rewind, any replacement chunks inserted before the batch flush will also be soft-archived, wiping out the new timeline instead of only the reverted pre-rewind content. Consider recording a cutoff (e.g. old_offset or timestamp) at rewind time and limiting the archival SQL to chunks created before that cutoff, rather than archiving by conversation_id alone.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/cli/__init__.py around line 3361:

Buffering rewinds by `session_id` causes the delayed batch flush in `_RewindArchiveBatcher.flush()` to archive *all* rows with that `conversation_id` and `archived_at IS NULL`. Because `JSONLWatcher.poll_once()` continues processing `new_lines` from the restored timeline immediately after calling `on_rewind`, any replacement chunks inserted before the batch flush will also be soft-archived, wiping out the new timeline instead of only the reverted pre-rewind content. Consider recording a cutoff (e.g. `old_offset` or timestamp) at rewind time and limiting the archival SQL to chunks created before that cutoff, rather than archiving by `conversation_id` alone.

self._last_flush_at = time.perf_counter()
self.archived_total = 0

def _get_vector_store(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High cli/__init__.py:3221

_RewindArchiveBatcher._get_vector_store() instantiates a bare VectorStore, which acquires the global writer pidfile. The watch command sets BRAINLAYER_ARBITRATED=1 and advertises that the drain process owns DB writes, so when the drain daemon is running the first flush() call raises WriterInUseError instead of archiving reverted chunks. Every rewind archival attempt therefore fails and reverted chunks stay live. Consider routing archival through the same arbitrated write path the rest of the watcher uses, or document why direct writer acquisition is intentional here.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/cli/__init__.py around line 3221:

`_RewindArchiveBatcher._get_vector_store()` instantiates a bare `VectorStore`, which acquires the global writer pidfile. The `watch` command sets `BRAINLAYER_ARBITRATED=1` and advertises that the drain process owns DB writes, so when the drain daemon is running the first `flush()` call raises `WriterInUseError` instead of archiving reverted chunks. Every rewind archival attempt therefore fails and reverted chunks stay live. Consider routing archival through the same arbitrated write path the rest of the watcher uses, or document why direct writer acquisition is intentional here.

self._last_flush_at = time.perf_counter()
return affected

def maybe_flush(self, reason: str) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium cli/__init__.py:3269

maybe_flush() is only invoked from the rewind callback, so when a single rewind is followed by normal watcher traffic with no further rewinds, the session id stays in _pending_session_ids indefinitely. The reverted realtime_watcher chunks remain unarchived until shutdown or a later rewind, so search keeps returning stale rolled-back content for the rest of the run. Consider calling maybe_flush() on a periodic tick (e.g., the watcher poll loop) so the flush_interval_ms timer is actually enforced.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/cli/__init__.py around line 3269:

`maybe_flush()` is only invoked from the rewind callback, so when a single rewind is followed by normal watcher traffic with no further rewinds, the session id stays in `_pending_session_ids` indefinitely. The reverted `realtime_watcher` chunks remain unarchived until shutdown or a later rewind, so search keeps returning stale rolled-back content for the rest of the run. Consider calling `maybe_flush()` on a periodic tick (e.g., the watcher poll loop) so the `flush_interval_ms` timer is actually enforced.

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