Repository navigation
Conversation
…snapshots change Two ingest volume issues from the 2026-10-09 SQL plan audit (#215): - The drain read every unprocessed frame, payload included, after every enqueued frame, so a frame left unprocessed after exhausting its transient retries was re-read, re-decoded and re-applied on every later frame. The drain now pages frame positions with a (received_at_ms, inbox_id) cursor, loads one frame at a time, and backs off a deferred frame in-process (1 s doubling to 5 min; Run wakes for the retry). No frame is dropped; a restart retries everything. New /api/status counter: deferred. - Google conversation snapshots rewrote every identity, the conversation row and the whole roster on every push. UpsertIdentity and UpsertConversation now skip unchanged rows (IS NOT excluded.* guards), the roster sync writes only differing rows (and moves the conversation's updated_at_ms when only the roster changed), the worker skips upserts it can see are no-ops, BumpConversationRecency writes only when newer, and the reaction fence upsert skips only an unchanged sequence. The fence still advances on every newer snapshot, empty ones included: that write is what stops an older retried snapshot from resurrecting reactions. identities/conversations updated_at_ms now record the last change rather than the last refresh. Replaying 30,394 real inbox frames from a store copy through origin/main and this branch gives identical stores apart from those timestamps, with 42% fewer row writes; the replay harness is committed behind the replayharness build tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two ingest volume issues from the 2026-10-09 SQL plan audit (#215, lens "callers-hotness"). Their plans were fine; the cost was volume. Both were checked against current code before changing anything.
MessageRepository.Unprocessedread every unprocessed frame with its payload, andWorker.draincalled it after every enqueued frame. A frame that exhausts its transient retries is left unprocessed by design, so a stuck backlog was re-read, re-decoded and re-applied (four attempts, 35 ms of backoff sleeps) on every later frame.(received_at_ms, inbox_id)cursor (UnprocessedAfter, a range seek on the existing partialinbox_unprocessed_idx) and loads one frame at a time (UnprocessedRecord).Runwakes for the earliest retry by itself./api/statuscounter:v2_ingest.per_account.<account>.deferred.DELETE+ re-INSERT). Every Google message frame also rewrote the conversation's recency row, even when the message was older.UpsertIdentityandUpsertConversationnow carryWHERE … IS NOT excluded.*guards.ReplaceConversationParticipantsdiffs the roster through the newSyncConversationParticipants. It writes only the differing rows, confirms an unchanged roster without the write lock, and keeps the old validation and errors. When only the roster changed, it also advances the conversation'supdated_at_msin the same transaction.BumpConversationRecencywrites only when the message is newer.The fence write per Google message frame stays, deliberately
The audit suggested skipping the fence write when a snapshot is empty and no fence exists. That is not exact. The fence is what rejects an older snapshot, and frames do arrive out of order: a frame left unprocessed after a transient failure is retried after newer ones. An empty snapshot at time 200 must still record 200, or a retried snapshot from time 100 carrying a reaction would bring that reaction back. Two tests execute this:
TestEmptySnapshotFenceIsLoadBearingWithoutReactions(store level). Dropping the empty snapshot's fence row lets the stale reaction come back.TestOlderGoogleReactionSnapshotCannotOverrideNewerEmptyOne(worker level).The existing
TestReplaceEmbeddedReactionsEmptyFirstSnapshotFencesOlderNonEmptySnapshotalready pinned this. The mutation check below plants the audit's skip, and the tests catch it. So the fence still advances on every newer snapshot; only a write at the fence's own sequence is skipped.Semantic change:
updated_at_msnow records the last changeThe guards are the point of the change, so
identities.updated_at_msandconversations.updated_at_msnow move only when the row's content changes. A roster change also moves the conversation'supdated_at_ms. I searchedcmd/andinternal/for readers:Store.ListConversationsUpdatedSincefeeds the id-space repair's clobber detection. It now lists the threads whose title, kind, revision or roster changed at or after--since. A re-keyed snapshot that overwrites a thread changes its roster or title, so that thread is still listed. A thread whose snapshot was merely re-sent in the window is no longer listed. Before, if such a thread's roster had changed legitimately between the reference copy and--since, the repair reverted it to the reference roster.Store.SelfIdentityID(repair) orders byupdated_at_ms. With two or more self identities on one account it now picks the most recently changed one rather than the most recently refreshed.v2read,v2wireor migration code reads either column.Measured on copies of the live store
The live store was never opened. Its files were copied with
cp, and the copy passedintegrity_check. The new harness (internal/ingest/replay_harness_test.go, build tagreplayharness) replays a copy's inbox through the real decoders into a fresh store, frame by frame in receipt order, with every clock pinned to the frame's receipt time. SQLite's pre-update and commit hooks count row writes and commits, and the WAL is never checkpointed during the replay, so its final size is the volume written.Full corpus: 30,394 real frames (9,295 Google, 13,943 Signal, 7,156 WhatsApp):
Google frames only (9,295 frames, two alternating runs each):
The machine's load average was 86–94, so read the timings as indicative; the counts are deterministic.
Differential. I compared the two full-corpus result stores table by table, every column. They are identical except
updated_at_mson 831 identities and 264 conversations, which is always older on the branch and never newer. Fence rows are identical in every column,updated_at_msincluded.Stuck backlog. 600 Google frames, with the first 20 conversation frames selected to fail transiently on every attempt. On both commits, 6 of the 20 fail validation before the fault point and are quarantined.
One unchanged 20-member snapshot. I counted statements from the code path:
TestRepeatedGroupSnapshotWritesNothingasserts zero identity, conversation and roster writes, counted by triggers.Residual cost. SQLite does not treat a partial index as covering for its own WHERE column. A backed-off frame therefore still costs one inbox row read per drain to confirm
processed_at_ms IS NULL, plus its overflow pages when the payload is larger than a page (about 2% of frames). It costs no decode or apply. A covering index would need a migration, and 0011 is already contested by #196, #201, #210 and #215.Invariants (stated, and tested for every input the generators reach)
Content. For any sequence of writes, the guarded statements leave every row equal to what the replaced statements leave, and raise errors of the same classes. The exceptions are
updated_at_mson identities and conversations, and the fence'supdated_at_ms. The replaced statements are kept verbatim inwrite_guards_test.goas the reference. The differential properties cover:UpsertIdentityandUpsertConversation, including CHECK failures and conflicts under a different primary key;Update time. A guarded row's
updated_at_msis the time of its last content change (for a conversation, a roster change counts), or its creation time. It is never later than the replaced statement's value.No-op. An unchanged identity, conversation, roster, older recency bump, or same-sequence or stale fence write touches no row. An unchanged roster takes no write lock (tested under a held
BEGIN IMMEDIATE).Fence. An older snapshot is rejected exactly when its sequence is below the newest accepted one, and an emptying snapshot always advances the fence.
Paging. Walking
UnprocessedAfterfrom the zero cursor visits every frame that is unprocessed when its page is listed, exactly once, in(received_at_ms, inbox_id)order. This holds with receipt-time ties, frames processed mid-walk and frames appended mid-walk.No frame lost or repeated. Under random transient failures and random interleavings of appends, drains and clock advances:
A deferred frame is not decoded again before its retry time, and is retried without new traffic.
Worker model.
resolveIdentityForandrefreshConversationleave the identity, conversation and roster equal to a model of the merge rules after every random snapshot. The update time equals the last change.Real traffic. The 30,394-frame differential above.
Mutation check.
mutants.pyplants 31 bugs, one at a time, and runs the relevant tests:All 31 are caught at this head, each by a named failing test. A build or setup failure is reported as an error, not a catch, and the script uses its own Go cache.
How the test set got there:
TestSyncConversationParticipantsRejectsRepeatedIdentitynow catches it.archived_at_mschange too rarely.TestUpsertGuardsWriteEveryChangedColumnnow changes every guarded column on its own, NULL transitions included.Coordination
worker.go,messages.goandrepositories.go(Prune old v2 inbox payloads but keep every row and dedupe key #196, Keep same-roster Google threads distinct; re-key only threads whose id is dead #201, Leave unaddressable roster entries off live Google snapshots instead of quarantining them #211, Keep v2 reads off whole-table scans found by a SQL plan audit #215). Whichever lands second carries the other's change over.go vet -tags replayharness ./internal/ingest/) so it cannot rot.Test plan
GOWORK=off go test ./...-race(sqlite and ingest packages)Evidence (local):
~/reviews/openmessage-ingest-write-volume-2026-10-09/. It holds the metrics JSON for every run, the comparison report, the mutation script and report, and the review brief.🤖 Generated with Claude Code