Skip to content

perf: open long threads from their tail, drop superseded diffs, bound client history - #611

Merged
Tryanks merged 1 commit into
mainfrom
perf/storage
Oct 6, 2026
Merged

Tryanks merged 1 commit into
mainfrom
perf/storage

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Storage performance on the Turso store (#594). Closes #522, #524, #525. One commit; three parts, each built and verified on a copy of the maintainer's data (3,047 threads, 9.7 GB of rows).

1. Superseded turn-change snapshots lose their diffs (#522)

Codex sends the whole cumulative diff of a turn on every change, so a turn stored every intermediate snapshot; only the last one is ever read. Now, when a turn_changes_updated is appended, the store writer empties the diff of the previous snapshot of that turn in the same transaction, resolving the turn the way the fold does (tcode_core::session::TurnSnapshots: first turn with that provider id, else the current turn, within one turn lifetime across rewinds); the resident SessionLog does the same in memory, so host and store agree and the wire's old superseded_turn_changes/without_diffs rule is gone. A one-time pass at startup does this for existing threads, one per writer transaction, listing candidates from sessions and marking each thread in a diff_pass table (created idempotently; no schema version change) so it never rescans; an undecodable row leaves the thread untouched and marked. No row is added, removed or moved, so nothing on the wire changes.

On the data copy: 79,410 snapshots in 819 threads emptied, stored bytes 9.73 GB → 5.55 GB (file size unchanged until VACUUM INTO: 10.8 GB → 6.6 GB), every thread folds equal to its original (independent check), restart rewrites nothing. The pass holds the writer up to ~0.6 s on the largest thread, once. Replayed provider warnings are no longer logged by the fold (live ones are logged once at on_event).

2. Cold threads no longer stall the host; long threads open from their tail (#524, re-scoped)

One hydration per session (crates/runtime/src/app/history.rs): the store writer answers SnapshotLog with next_row in queue order, the rows below it are read and folded on HostCx::unblock, records that arrive meanwhile are held in order and sent after the waiting windows, a subscription's ack follows its window, and the log is kept only if the session is resident. Pages and ReadItemOutput of non-resident threads use the same hydration; orchestrate reads and title regeneration wait for a writer barrier.

A turn_index table (session → turn count and turn-opening rows, maintained in the append's transaction from the resident fold, built once after a full read, forgotten by any unfolded write) lets a thread with more than 400 rows answer its first window from the tail rows alone — read backwards in 256-row steps to the baseline's turn start or the window byte budget — while the full read continues for the resident fold. The early window is identical to the whole log's (asserted). total_turns comes from the index because counting turn starts is both as slow as the read and inexact.

Measured (real spawn_host, 1 kHz ping as another session): largest thread first window 434 ms → 8.3 ms with the index (first open without an index: same time as before but off the mailbox); other sessions' round trips ≤ 3.4 ms during an open instead of up to 434 ms; peak RSS unchanged (the full read still happens).

Cursors are now stored row positions rather than record indices — identical on all real data; they differ only in a log with blank or undecodable rows. No protocol field changed; an Unreleased note sits above PROTOCOL_VERSION.

Left out of #524's text: two-file layout, crash truncation, byte offsets, an item-output index, gzip, persisted search text.

3. The client bounds the history it holds (#525)

The store keeps replicas for the selected thread and the four threads left most recently, drops a deleted or unlisted thread's at once, and drops pages read far above the tail after 30 s at the tail or on leaving, keeping the tail's own pages so nothing is refetched; the chat list splices out rows that vanish above every surviving row so the tail does not move. Live records are ignored until the selected thread's window has arrived. 50 visited threads: 20,100 held records / 8.7 MB → 2,010 / 0.9 MB. No protocol change.

Tests

Core: superseded-snapshot cases (reused turn id, snapshot that opens or names its turn, rewind lifetime, late snapshot) with Timeline ==. Services: in-place diff drop with literal rows and marker, undecodable row keeps every row, turn index forgotten by unfolded writes. Runtime: append-time drop in the same commit (fails if rows are numbered by record index), startup pass over cold and resident threads, a SIGKILL harness (ignored; 30 kills, never a half-done thread), shared cold reads, the event that starts a read, leaving before the read completes, pages and outputs of an unopened thread, a child completing mid-read, two mux clients, and a long thread opening from its tail with the whole log's window (fails without the index or with a stale one). UI: 50-thread retention through a real host, the scroll-up/stay-at-tail trim cycle through the real ChatView, list splice scenario. Driver fixes: tests that assumed a synchronous snapshot now drive the pipe or wait for the state (incl. set_session_replica_for_test, the paged fixture, classifier_stop_and_review… recording through the host's real path, orchestrate_dispatch_resolves_cwd_before_reply checking inside the update).

Checks run

On the rebased tree: cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo nextest run --workspace --locked twice (965 passed, 13 skipped), cargo machete, Web and iOS checks. Looked at in the desktop app on a scratch data dir: scroll-up and return, tail stays put, released threads reopen at the tail; both themes, wide and 760 pt. Not run: Android, Windows, Linux.

  • This change alters the wire protocol, so the next release needs a
    PROTOCOL_VERSION bump: a note was added under "Unreleased" above the
    constant in crates/protocol/src/lib.rs (the number itself changes only
    when the release is cut — CONTRIBUTING.md, principle 9).

… client history

Storage performance on the Turso store, three parts.

Superseded turn-change snapshots lose their diffs (#522): when a turn's
new cumulative diff is appended, the previous snapshot of that turn is
emptied in the same transaction, as the fold itself resolves the turn; a
one-time pass does the same for existing threads, marking each as done.
Rows and positions never change, so nothing on the wire moves.

Cold threads no longer stall the host, and long ones open from their
tail (#524): one hydration per session reads and folds the log off the
mailbox at a writer-ordered snapshot, holding the records that arrive
meanwhile and answering waiting subscriptions first; a stored turn
index, maintained at append time and built lazily after a full read,
lets the first window come from the last rows only. Cursors are stored
row positions.

The client keeps history for the selected thread and the four left most
recently, and drops pages read far above the tail once the reader has
stayed at the tail (#525).

Closes #522
Closes #524
Closes #525
@Tryanks
Tryanks enabled auto-merge October 6, 2026 16:32
@Tryanks
Tryanks merged commit 59d9247 into main Oct 6, 2026
7 checks passed
@Tryanks
Tryanks deleted the perf/storage branch October 6, 2026 16:42
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.

Storage: compact thread logs at turn end

1 participant