Skip to content

perf: batch message inserts in a per-session transaction during sync - #63

Merged
elecnix merged 2 commits into
mainfrom
perf/batch-message-inserts
Aug 9, 2026
Merged

perf: batch message inserts in a per-session transaction during sync#63
elecnix merged 2 commits into
mainfrom
perf/batch-message-inserts

Conversation

@elecnix

@elecnix elecnix commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What this does

When the syncer imports a session's chat history into the database, it used to write each message as its own little "commit". Now it writes all of one session's messages as a single unit — one commit per session instead of one per message. Same data on disk, just written much more cheaply.

Why it's safe (the important part)

The one thing that can silently go wrong with batching is the resume cursor (the marker that says "this session was imported up to line N"). If a session is interrupted halfway, the old code left the partial messages and left the cursor far ahead — so the next run would skip past messages that were never written.

This change puts the cursor advance inside the same transaction as the messages. If a session fails partway, the database rolls back both the messages and the cursor together — nothing partial is left, and the next run re-imports cleanly from where it left off. Committed rows are never skipped.

Plain-English acceptance check

  • Same rows on disk: a re-sync is a no-op (dedup by message id), and message counts/stats are identical.
  • An interrupted session leaves no partial session — it rolls back atomically.
  • The cursor is advanced in the same transaction so no committed rows are skipped.

Tests (new — this is behaviour under partial failure)

Two component tests in tests/component/sync-transaction.test.ts exercise the rollback path directly, using a gated RAISE(ABORT) trigger as a deterministic write failure (the sync path's parsing and INSERT OR IGNORE are otherwise fully defensive, so a real session file can't naturally throw):

  1. A session that fails mid-sync leaves no partial session — its session row, messages, and cursor all roll back together (zero rows, not a few).
  2. A partial failure does not advance the resume cursor past rolled-back rows — after a successful commit, an appended line that fails rolls back (including a succeeding appended line) and the cursor stays put; removing the failure and re-syncing imports those rows.

Both were falsified: reverting to per-row autocommit, or letting the cursor update commit despite a rollback, makes each test fail — so they are not vacuous and genuinely protect the invariant.

Technical detail

Both sync paths (syncPiSession, syncClaudeSession) now wrap the per-session work — session upsert, the message-insert loop, updateCursor, and the message count — in a db.transaction(...). In WAL mode each autocommit is a commit cycle, so this replaces one commit per row with one commit per session. The FTS trigger (messages_ai) still fires per row, so its cost is unchanged, which is exactly why this win is smaller than the ~5.7× seen batching analysis-node writes (no trigger attached).

Measurement

Synthetic corpus matching the issue (60 sessions, 2,160 messages) on a fresh scratch SQLite DB under /tmp, no LLM calls, node --import tsx, wall time of the full runSync, 3 runs each:

Path ms (avg of 3)
baseline (main, per-row autocommit) ~116.5
this branch (per-session transaction) ~47.0

~2.5× end-to-end. The issue's micro-benchmark baseline (115 ms) matches mine (116.5 ms), confirming the sync insert path had not already been batched by #55 (which was the analysis-node path), so this is not a double-count.

Out of scope, found while testing

While writing the resume-cursor test I confirmed a pre-existing cursor miscount on incremental re-sync, present in unchanged main: last_line is set to lines.length (the split count), which includes the trailing empty element from a final newline. Appending new lines to an already-synced session therefore skips the first appended line. This affects the untouched autocommit code identically and is independent of this change, so it is not fixed here — flagged for a follow-up.
— crystal-marlin-69

Message inserts during sync currently autocommit one row at a time. Wrap
each session's insert loop, cursor advance, and message count in a single
transaction so a session's messages commit as one unit.

The cursor update is inside the same transaction on purpose: if a session
fails partway, SQLite rolls rows and the cursor back together, so a resync
reprocesses the file from the old cursor instead of skipping rows that
were never committed.

Co-authored-by: crystal-marlin-69 <crystal-marlin-69@pi-agent.local>
Two component tests prove the change's contract under partial failure,
using a gated RAISE(ABORT) trigger as a deterministic write failure (the
sync path's parse + INSERT OR IGNORE are otherwise fully defensive.

1. A session that fails mid-sync leaves zero rows — its session row,
   messages, and cursor all roll back together.
2. Appending a failing row after a successful commit does not advance the
   resume cursor past the rolled-back rows; removing the failure and
   re-syncing imports them.

Both were falsified: reverting to per-row autocommit, or letting the
cursor commit despite a rollback, makes each test fail.

Co-authored-by: crystal-marlin-69 <crystal-marlin-69@pi-agent.local>
EOF
)
@elecnix
elecnix merged commit c22df45 into main Aug 9, 2026
3 checks passed
@elecnix
elecnix deleted the perf/batch-message-inserts branch August 9, 2026 06:19
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