Mail P1b: search save-log token and paged reconcile #1011
Open
kayg
wants to merge 54 commits from
t3/paged-search-reconcile-final into dev
pull from: t3/paged-search-reconcile-final
merge into: kayg:dev
kayg:main
kayg:feat/style-sheet-fixes
kayg:storage/wal-checkpoint-off-save-1051
kayg:feat/pass8
kayg:review/calendar-2
kayg:fix/shared-row-swipes
kayg:feat/contrast
kayg:dev
kayg:feat/collections-db
kayg:fix/instant-keys-1056
kayg:feat/money-round
kayg:perf/stalls-1054
kayg:wip/tiering-v3-cold4
kayg:wip/tiering-v3-cold5
kayg:port/rows-actions
kayg:port/rows-drop
kayg:port/hidden-blocks
kayg:port/rows
kayg:port/port-tables
kayg:port/port-tables-2
kayg:t3/mail-1011-liveness
kayg:port/devices
kayg:port/port-attachments
kayg:port/port-attachments-2
kayg:port/port-attachments-3
kayg:docs/design-consistency
kayg:docs/engineering-standards
kayg:fix/rusqlite-0.41-rustsec-2026-0459
kayg:feat/iphone-fixes
kayg:shared/restore-and-stale-918-919
kayg:fix/phone-top-band-contrast
kayg:feat/notes-approve
kayg:perf/soak-max-gate
kayg:feat/files-s1-identity
kayg:fix/lifecycle-event-id
kayg:feat/quickfix
kayg:wip/tiering-v3-cold
kayg:perf/web-mockup-frame-fixes
kayg:port/contacts-merge
kayg:port/contacts-edit
kayg:test/shared-room-no-deleted-text
kayg:perf/first-paint-credits
kayg:fix/shared-history-after-grant
kayg:fix/phone-swipe-regressions
kayg:feat/contrast-r1
kayg:review/sharing-pr3-review
kayg:feat/tiering-v3-s2-tags
kayg:feat/folder-drop
kayg:fix/deskpills-lazy-share
kayg:feat/sharing-s1-pr3
kayg:tmp/money-round-before
kayg:fix/phone-pane-backdrop-roots
kayg:fix/palette-preload-chips
kayg:fix/invite-defaults
kayg:port/tasks-matrix
kayg:fix/restore-green-gates
kayg:fix/sharing-fast-path-hardening
kayg:t3/photos-s1
kayg:feat/tiering-v3-s1-namespace
kayg:notes-archive-hide
kayg:feat/tasks-actions
kayg:t3/photos-plan-slice-one
kayg:feat/whiteboard-s1
kayg:t3/paged-search-reconcile
kayg:t3/smooth-pane-transitions
kayg:t3/index-position-startup-scan
kayg:fix/private-staging-759b-r2
kayg:feat/dav-transport
kayg:feat/imap-notes-fetch
kayg:feat/imap-notes-projection
kayg:feat/imap-notes-readonly
kayg:feat/imap-notes-auth
kayg:feat/imap-notes-server-foundation
kayg:docs/undo-block-ids-plan
kayg:fix/undo-768
kayg:proto/block-identity-v7d
kayg:issue/carddav-read-perf-fix-r2
kayg:issue/carddav-read-r2
kayg:docs/imap-766-plan-v2
kayg:docs/imap-notes-plan
kayg:docs/quota-plan-2
kayg:fix/parallel-test-flakes
kayg:feat/contacts-companion-pure
kayg:docs/calendar-backend-v2-plan
kayg:feat/calendar-ical-converter
kayg:docs/tasks-writes-v2-plan
kayg:docs/carddav-writes-v2-plan
kayg:docs/contacts-import-v2-plan
kayg:issue/610-pm-midnight
kayg:feat/calendar-v2-s1-containment
kayg:docs/dav-recorder-v2-plan
kayg:plan/previews-865
kayg:plan/save-log-durable-entry
kayg:fix/e2e-data-on-ram
kayg:feat/appwindow3
kayg:feat/appwindow2
kayg:feat/appwindow
kayg:feat/money-p1
kayg:feat/keys-sheet
kayg:review/calendar-2-themes
kayg:feat/smooth-cursor
kayg:fix/float-style
kayg:review/calendar-2-float
kayg:release/2026-11-06-hotfix
kayg:archive/port/rows-drop-code
kayg:archive/port/rows-actions
kayg:archive/port/rows
kayg:scratch/tasks-894-ui-gates
kayg:review/calendar-2-night-wip
kayg:release/2026-11-05
kayg:archive/feat/tiering-v3-s1-namespace
kayg:archive/flaky/rows-drop-fixed
kayg:archive/flaky/rows-actions
kayg:archive/feat/tiering-v3-reader
kayg:archive/feat/tiering-v3-roots
kayg:archive/flaky/rows
kayg:sim/bm15-final
kayg:sim/bm15-fix
kayg:sim/bm15-bis-932
kayg:sim/bm15-bis-909
kayg:sim/bm15
kayg:sim/bm15-bis-887
kayg:perf/tiering-option-b
kayg:port/style-3
kayg:port/style-2
kayg:port/style
kayg:fix/sw-lazy-precache
kayg:fix/vim-w-waits-for-save
kayg:fix/signout-test-races
kayg:fp-scratch/all
kayg:sim/bm15-first-paint
kayg:archive/bm15/rows-drop
kayg:archive/bm15/rows-actions
kayg:fix/palette-snippet-contrast
kayg:archive/bm15/rows
kayg:archive/bm15/hidden
kayg:archive/plan/save-log-durable-entry
kayg:mock/money-fix2
kayg:archive/fix/e2e-data-on-ram
kayg:mock/appearance-all
kayg:mock/pill-press
kayg:archive/docs/design-consistency
kayg:mock/auto-swipes
kayg:mock/mail-actions
kayg:archive/feat/mail-p1
kayg:mock/money-static
kayg:archive/review/standards-917
kayg:archive/docs/engineering-standards
kayg:perf/tiering-761-fix
kayg:mock/type-audit
kayg:mock/third-pane
kayg:feat/contacts-import-v2-s1
kayg:perf/executor-hot-path
kayg:archive/port/hidden-blocks
kayg:archive/port/devices
kayg:scratch/executor-on-894
kayg:scratch/executor-spans2
kayg:scratch/executor-spans
kayg:train/2026-11-03-bm14c
kayg:archive/port/rows-drop
kayg:feat/tiering-761-d-scale
kayg:feat/tiering-761-c-index
kayg:feat/tiering-761-b-reader
kayg:archive/review/sharing-pr3-review-9fec3d1f
kayg:train/2026-11-03-bm14b
kayg:perf/ports-lazy-budgets
kayg:feat/tiering-761-a-migration
kayg:train/2026-11-03-bm14
kayg:train/2026-11-03-bm12
kayg:archive/review/sharing-pr3-review
kayg:archive/feat/sharing-s1-pr3
kayg:train/2026-11-03-bm11
kayg:archive/feat/imap-notes-fetch
kayg:train/2026-11-03-bm10
kayg:archive/review/sharing-pr3-review-code
kayg:archive/plan/previews-865
kayg:train/2026-11-02-bm9
kayg:archive/port/port-attachments-3
kayg:archive/port/contacts-merge
kayg:fix/motion-audit-859
kayg:train/2026-11-02-bm8
kayg:archive/scratch/bm8-prep
kayg:archive/port/port-pickers
kayg:archive/port/port-tables-2
kayg:archive/port/port-tables
kayg:archive/feat/sharing-s1-pr3-code
kayg:archive/scratch/bm7-fixed
kayg:train/2026-11-02-bm7
kayg:archive/fix/parallel-test-flakes-code-minipc01
kayg:train/2026-11-02-bm6
kayg:docs/imap-766-readonly-slice
kayg:feat/dav-recorder-v3
kayg:research/788-diagnosis
kayg:archive/fix/parallel-test-flakes
kayg:archive/perf/pr792-cold-index-edit
kayg:docs/sharing-slice-1
kayg:archive/docs/imap-766-plan-v2
kayg:feat/dav-recorder-v2
kayg:archive/docs/dav-recorder-v2-plan
kayg:spike/imap-766-s0-cases
kayg:archive/fix/undo-768
kayg:feat/dav-recorder
kayg:archive/docs/contacts-import-v2-plan
kayg:archive/feat/calendar-ical-converter
kayg:feat/contacts-import
kayg:perf/cold-growth-on-764
kayg:perf/cold-growth-on-tiering
kayg:archive/docs/tasks-writes-v2-plan
kayg:archive/docs/calendar-backend-v2-plan
kayg:archive/feat/contacts-companion-pure
kayg:feat/tasks-set-status
kayg:feat/tasks-move-reorder
kayg:archive/docs/carddav-writes-v2-plan
kayg:archive/feat/calendar-v2-s1-containment
kayg:feat/tasks-delete-promote-duplicate
kayg:archive/feat/dav-transport
kayg:archive/fix/carddav-rebuild-r1
kayg:archive/fix/carddav-retention-r1
kayg:feat/carddav-writes
kayg:perf/quota-on-tiering
kayg:perf/quota-simple
kayg:perf/cold-growth-on-quota-stack
kayg:feat/calendar-identity
kayg:feat/calendar-event-parser
kayg:feat/calendar-props-maps
kayg:port/glass-when-ring
kayg:feat/tiering-761-s1
kayg:wip/10by32-01-impl-759b-scratch-20261030
kayg:wip/6by32-01-bm3-webkit-pause-20261030
kayg:wip/10by32-01-bm3-chromium-pause-20261030
kayg:wip/6by32-01-tier-761-s1-pause-20261030
kayg:spike/imap-766-s0
kayg:wip/6by32-02-calternal-pause-20261030
kayg:wip/8by64-01-review-688-pause-20261030
kayg:wip/6by32-01-verify-744-pause-20261030
kayg:wip/6by32-01-verify-742-pause-20261030
kayg:fix/private-staging-759b
kayg:wip/6by32-01-review-748-pause-20261030
kayg:wip/6by32-01-review-733c-pause-20261030
kayg:wip/6by32-01-review-733b-pause-20261030
kayg:wip/6by32-01-perf-idle-8-pause-20261030
kayg:wip/6by32-01-perf767-base-pause-20261030
kayg:wip/6by32-01-perf767b-767-pause-20261030
kayg:wip/6by32-01-perf767b-764-pause-20261030
kayg:wip/6by32-01-perf767-767-pause-20261030
kayg:wip/6by32-01-perf767-764-pause-20261030
kayg:wip/6by32-01-lists-b-pause-20261030
kayg:wip/6by32-01-ids-verify5-pause-20261030
kayg:wip/6by32-01-ids-verify4-pause-20261030
kayg:review/calendar-month
kayg:wip/10by32-02-undo-768-20261030
kayg:test/ids-verify3
kayg:wip/10by32-02-bm3-chromium-20261030
kayg:wip/10by32-01-review-753-20261030
kayg:wip/10by32-01-perf-idle-2-20261030
kayg:wip/10by32-01-bm2-webkit2-20261030
kayg:plan/759b-staging
kayg:train/2026-10-30-bm2
kayg:review/758b-tests
kayg:review/758-tests
kayg:review/764d-tests
kayg:review/767b-tests
kayg:review/preview-2026-10-29
kayg:review/767-tests
kayg:docs/tiering-731-plan
kayg:wip/c6b02-when2
kayg:wip/c6b02-tasks-create-fix5
kayg:wip/c6b02-settings-frost-mock
kayg:wip/c6b02-settings-frost-base
kayg:wip/c6b02-settings-frost
kayg:wip/c6b02-ring
kayg:wip/c6b02-review-733d
kayg:wip/c6b02-bm1-full-a
kayg:wip/compute02-when-field
kayg:wip/compute02-webkit-lists-repro
kayg:wip/compute02-train-bl2-dev
kayg:wip/compute02-dev-green-pw164
kayg:docs/deploy-plan
kayg:review/764c-tests
kayg:review/764b-tests
kayg:review/748g-tests
kayg:review/764-tests
kayg:review/759b-tests
kayg:fix/storage-owner-liveness-642
kayg:fix/storage-fix-7
kayg:wip/compute02-storage-fix-7
kayg:review/759-tests
kayg:feat/tiering-731
kayg:wip/tiering-731-first-attempt
kayg:wip/compute02-storage-fix-1
kayg:review/754g-tests
kayg:perf/idle-honest-2
kayg:review/754f-tests
kayg:review/754e-tests
kayg:review/755-tests
kayg:review/754d-tests
kayg:review/754c-tests
kayg:review/754b-tests
kayg:review/754-tests
kayg:fix/storage-private-tmp-642-dev
kayg:wip/compute02-verify-750
kayg:test/ids-verify5
kayg:test/ids-verify4
kayg:fix/storage-exact-file-quarantine-642
kayg:wip/compute02-verify-740
kayg:perf/one-flush-save
kayg:review/calendar-static
kayg:wip/compute02-verify-737
kayg:review/indexing-ring
kayg:review/when-field-2
kayg:wip/compute02-review-733
kayg:proto/block-identity-2-review
kayg:proto/block-identity-review
kayg:wip/c6b02-tc-design
kayg:review/when-field
kayg:issue/tasks-create-ids
kayg:issue/tasks-create
kayg:review/combined
kayg:wip/compute02-review-combined
kayg:review/task-polish
kayg:review/top-buttons
kayg:review/q1-q5
kayg:train/2026-10-26-bl2
kayg:archive/wt-398h
kayg:archive/train-bm-proptest-seed
kayg:archive/integ325-293
kayg:archive/integ325-ci
kayg:archive/client-auth-review-baseline
kayg:archive/wt-sim-4853
kayg:archive/wt-sim-4811
kayg:archive/wt-448m
kayg:archive/wt-398a-allocator
kayg:issue/calendar-adr
kayg:archive/old/train/2026-10-23bk2
kayg:archive/old/train/2026-10-23bk
kayg:issue/469-mockup-9a
kayg:issue/smart-tasks-mockup
kayg:issue/469-menus
kayg:issue/652-signout-flake
kayg:issue/652-history-fix7
kayg:issue/652-history-fix6
kayg:archive/old/issue/spring-check-perf
kayg:issue/spring-check-perf
kayg:issue/659-rename-fast
kayg:archive/old/train/2026-10-21ar
kayg:issue/search-hardening-3
kayg:issue/652-history-fix5
kayg:issue/660-port-sheets-glass
kayg:issue/652-history-fix4
kayg:issue/652-history-fix3
kayg:issue/652-history-fix2
kayg:issue/652-history-flake
kayg:train/2026-10-21an
kayg:issue/settings-admin-port
kayg:issue/search-port
kayg:train/2026-10-20am
kayg:issue/storage-split-fix2
kayg:issue/storage-split
kayg:archive/old/issue/folder-ids-fix
kayg:train/2026-10-20al
kayg:archive/old/issue/folder-ids
kayg:train/2026-10-20ak
kayg:issue/tables-mockup
kayg:train/2026-10-19aj
kayg:train/2026-10-19ai
kayg:issue/469-merge-fixes
kayg:issue/sidebar-band
kayg:archive/old/issue/608-settings-spec
kayg:train/2026-10-19ah
kayg:issue/swipe-offscreen
kayg:train/2026-10-19ag
kayg:archive/old/issue/590-nlp-adr
kayg:archive/old/issue/587-event-after-write-2
kayg:train/2026-10-19af
kayg:archive/old/issue/587-event-after-write
kayg:issue/469-pill-room-c
kayg:issue/469-pill-room
kayg:train/2026-10-19ae
kayg:issue/469-narrow-handle
kayg:issue/397-ios-swipe-2
kayg:archive/old/issue/adrs-photos-files-tasks
kayg:issue/469-desk-fixes
kayg:archive/old/issue/auto-update-adr
kayg:issue/469-filter-gap
kayg:issue/469-port-1b
kayg:issue/442-opacity-flake
kayg:archive/397-ios-swipe-2
kayg:issue/327-web-fast-3
kayg:archive/old/issue/469-port-1
kayg:issue/469-port-1
kayg:issue/327-web-fast-2
kayg:archive/old/issue/327-web-fast
kayg:archive/old/issue/425-stt-bench-vq1
kayg:archive/old/issue/445-no-bleed
kayg:archive/old/issue/400-swipe-mockup-2
kayg:issue/426-client-review
kayg:issue/426-client-2
kayg:issue/426-client
kayg:archive/old/issue/476-safe-area-lab
kayg:archive/445-top-light-2
kayg:archive/old/issue/445-top-light
kayg:issue/471-blocks-mockup
kayg:archive/old/issue/448-links-app-2
kayg:archive/old/issue/448-links-app
kayg:archive/old/issue/398-cold-links
kayg:issue/434-442-small-2
kayg:issue/434-442-small
kayg:archive/old/issue/398-unload
kayg:archive/old/issue/445-bottom-band
kayg:issue/445-bottom-band
kayg:issue/351-rem-3
kayg:archive/312-views-mockup
kayg:issue/351-rem-2
kayg:issue/351-rem
kayg:archive/old/issue/327-first-note
kayg:issue/395-width-phone
kayg:archive/old/issue/327-typing
kayg:issue/396-ios-edges
kayg:issue/397-ios-swipe
kayg:archive/398-allocator
kayg:archive/old/issue/327-notes-index
kayg:issue/345-347-contrast-empty-2
kayg:archive/old/issue/44-arenas
kayg:issue/44-arenas
kayg:archive/integ325
kayg:archive/old/issue/327-perf-harness
kayg:archive/old/issue/44-thp
kayg:issue/44-thp
kayg:archive/old/issue/285-sticky-pills
kayg:archive/old/issue/44-trim-3
kayg:issue/44-trim-3
kayg:archive/old/issue/165-cards
kayg:archive/old/issue/165-picker-2
kayg:archive/old/issue/212-multi-select
No reviewers
Labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
kayg/calternal!1011
Loading…
Reference in a new issue
No description provided.
Delete branch "t3/paged-search-reconcile-final"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Rethink after the 2026-11-07 follow-up review
The review found that the legacy-cursor path scanned pages to find the final page, then checked only that page. A pre-fence cursor can already cover a stale
notes.savedentry several pages earlier, so this tail check cannot prove the whole cursor safe. The generation fence for new receipts remains unchanged; legacy cursor recovery is revised below.The implementation review found that ordinary room saves publish notes.saved through a separate path from Home executor actions. Both paths now attach the accepted per-note History version to the event and publish the same version with the accepted source. Room saves reuse the already durable Room.version; the version rides on the existing live_meta generation commit, with no added file read, wait, or fsync. The ordinary_room_save_receipt_matches_published_source_version regression proves that the receipt and provider agree.
Revised restart path
Engine::position; the indexer re-reads its last journal page and checks each known source generation before trusting the cursor.legacy_stale_receipt_before_final_page_forces_paged_reindexThis one-time legacy path can take as long as today's full reconcile because its old checkpoint carries no proof of which earlier pages were verified. The performance table will separate this compatibility repair from current-cursor startup and report both against the same-epoch control.
Design after the 2026-11-07 generation-fence ruling
P1b stays a shared, backend-only search change. Notes remains the current provider; Mail can use the same cursor and page API later. Notes search, save behavior, result order, and UI stay unchanged. The indexer owns the fence: it will not index a source older than a
notes.savedreceipt or checkpoint past that receipt.Fence and restart rules
History::install_executoralready returns the accepted per-note version. Append that value assource_versionto each newnotes.savedevent (at most 37 serialized bytes including its comma). Publish the same version with the accepted Notes source and expose it to search. Existing events without the field areunknown: read the item again from a fresh pinned Notes generation, compare its source stamp, and validate the snapshot before checkpointing.When a journal page touches an item, compare every saved version in that page with the published source version. If the source is behind, drop the private page stage, keep the previous cursor, and park the entry. Subscribe to the existing
MetaDb.populatedwatch before pinning the provider snapshot; its publication notification retries the same page without a polling loop. If the watch produces no notification, allow five bounded exponential-backoff rereads (10, 20, 40, 80, 160 ms), then wait on the watch. A still-pending entry never advances the cursor.Every commit covers a contiguous run of verified entries, with documents, use scores, and the page token in one SQLite transaction. On startup, validate a current cursor and re-read the final journal page before trusting it. Store that page's starting token beside each new cursor. A legacy cursor without that metadata cannot identify which earlier receipts may have raced, so treat it as wholly untrusted and run a full paged provider reconcile. Publish the verified documents, use scores and current cursor together; a crash leaves the legacy cursor untrusted and the reconcile repeats.
A missing or expired cursor does a full paged reconcile. Before publishing its head token, scan the retained journal through that head and stage the latest per-item fence in SQLite's temporary file. Compare each provider page against those fences; a pending source drops the whole private reconcile. A restored or truncated log is detected only through the engine contract: P1b uses
Engine::positionandEngine::journal; #928 remains the only owner of signed token proof and high-water restore detection. P1b does not read the high-water file or add/parse token formats. If #928 lands first, rebase and keep this API boundary.Sizes and cost
O(K log K + R + D); memoryO(256 + R + 64)O(256 + D)time and memoryO(K + N log M + M)time, bounded memorytemp_store=FILE, 2,000 KiB writer cacheO(K + N log M + M)forKjournal entries,Nprovider rows,Mindexed rows; RAM independent of Home size; temp diskO(S)for changed rows and per-item fencesNo page uses
OFFSETor retains a Home-sized vector. The version is returned by the existing history install; Notes does no extra work on the notes-save path beyond encoding the appended field.Failure table
notes.savedis visible before source publicationkill_restart_at_every_provider_page_boundary_convergeslegacy_stale_receipt_before_final_page_forces_paged_reindexEngine::positionreturnsSyncTokenExpiredpositionresult plus page sequence checksahead_cursor_reindexes_from_current_head,valid_behind_cursor_uses_pages_without_listingposition/page read expires, or cursor is absenttruncated_log_reindexes_from_pages,missing_index_rebuilds_in_pagesTests
Ordinary room save: edit and save a real Note, then prove the receipt version matches the published Notes source version.
Real Notes race: pause
EventRecorder::record_onceafter the durable receipt and before it returns toBatchPublished; run incremental catch-up and missing-index reconcile; prove neither indexes the old text nor passes the cursor; release publication, restart the indexer, and prove the new text is indexed.Crash before publication: stop after the receipt, reopen Notes so executor recovery replays it, and prove the cursor never skips the entry.
Reopen after every journal/provider page boundary and compare the final index to a clean build. A legacy cursor without page-start metadata must also repair a stale receipt more than 256 entries before the cursor.
Property test over save versions, delayed/reordered publication notifications, duplicate touches, arbitrary page cuts, and restarts; assert one indexed row per item, latest text, and a cursor no later than the contiguous verified prefix.
Restored-older-backup cursor, cursor ahead/behind, truncated log, missing index, and 1M-entry paged reconcile with a 256-row maximum and bounded memory.
Gates
CARGO_BUILD_JOBS=4 CARGO_INCREMENTAL=0 ~/bin/slot /nix/var/nix/profiles/default/bin/nix develop -c bash -c 'cd /home/claude/Developer/Worktrees/mail-p1b-test-e0fc && cargo fmt --check'CARGO_BUILD_JOBS=4 CARGO_INCREMENTAL=0 ~/bin/slot /nix/var/nix/profiles/default/bin/nix develop -c bash -c 'cd /home/claude/Developer/Worktrees/mail-p1b-test-e0fc && cargo clippy --workspace --all-targets -- -D warnings'CARGO_BUILD_JOBS=4 CARGO_INCREMENTAL=0 ~/bin/slot /nix/var/nix/profiles/default/bin/nix develop -c bash -c 'cd /home/claude/Developer/Worktrees/mail-p1b-test-e0fc && cargo test --workspace --no-fail-fast'cargo test -p calternal --test notes_crash_typing kill_nine_while_people_type_loses_and_doubles_nothing -- --exact~/bin/slot /nix/var/nix/profiles/default/bin/nix develop -c bash -c 'cd /home/claude/Developer/Worktrees/mail-p1b-test-e0fc/apps/web && CALTERNAL_BIN=/home/claude/Developer/Worktrees/mail-p1b-test-e0fc/target/debug/calternal bunx playwright test e2e/search-palette.spec.ts e2e/search-corpus.spec.ts e2e/editor.spec.ts e2e/list-catch-up.spec.ts --project=chromium-1440 --project=chromium-390 --workers=1'~/bin/slot /nix/var/nix/profiles/default/bin/nix develop -c bash -c 'cd /home/cua/Developer/Worktrees/mail-p1b-webkit-b822165c6/apps/web && CALTERNAL_BIN=/home/cua/Developer/Worktrees/mail-p1b-webkit-b822165c6/target/debug/calternal bunx playwright test e2e/search-palette.spec.ts e2e/search-corpus.spec.ts e2e/editor.spec.ts e2e/list-catch-up.spec.ts --project=webkit-1440 --project=webkit-390 --workers=1'~/bin/perf-lockon perf-01 then perf-02 for the complete same-epoch session. Run dev-vs-dev control and candidate/dev ABAB ×5; each sample runscalternal-perf --bin "$SERVER_BIN" --work "$WORK" --data-dir "$DATA" --state-dir "$STATE" --budgets perf/budgets.json --sizes 100k,1000000 --runs 1 --cold-runs 1 --idle-secs 0 --json "$OUT/run.json" --md "$OUT/run.md"andstorage-bench save --dir "$SAVE_DIR" --saves 200.Any regression beyond the control spread blocks qualification. Post commands, rates, all metric deltas, and the same-epoch control in PR comments. Never merge.
Design note: the indexer never waits forever on a receipt fence (liveness fix, 2026-11-09)
Problem. Search holds a save receipt back until the provider has published the version the receipt names (the "fence"). If that version is never published,
PublicationWait::retrywaits on the publication signal with no limit, and the full rebuild enforces the same fences, so it waits too. Search then reportsindexing: truefor ever. Seen on compute02 and perf-01 with the gap stage (idle server,DIAG waiting for publication signal)."Receipt ahead of provider". A receipt for item X names source version N (
SourceFence::Known(N)), but the provider's published document for X has version M < N (or none), and no further publication arrives. Real ways to get there: a Home restored from an older backup, or note history rebuilt, while the save-log keeps its receipts.The cap.
Options::publication_cap, default 30 s, counted as time stuck on one page: it starts at the first reread for that page, restarts when the page is staged, and is not extended by publication signals for other items (so steady typing elsewhere cannot keep it alive). Tests set it to milliseconds.After the cap (both places). One relaxed attempt for that page or rebuild, using the same pinned provider snapshot:
catch_up_to,recheck_cursor_tail,apply_live_batch): the page is staged without itsKnownfences;reconcile_pages): the attempt ignorescomparison.pending, passes no fences to the refresh, and skipshas_unseen_known_fence.The receipt is superseded: the provider's current document is indexed, the cursor advances,
indexinggoes false. OneWARNper pass names the provider and the time waited, and the process-wide countercalternal_search::fences_superseded()goes up by one per relaxed page or rebuild. Relaxing lasts for that page or attempt only; the next page is strict again.Why this does not publish stale search results.
Tests. (1) A receipt ahead of any publication:
sync_to_headfinishes, the doc is the provider's current one, andfences_superseded()rose, within cap + slack, for both the catch-up path and the rebuild path. (2) A publication that arrives inside the cap is honoured: the indexed doc has the receipt's version and the counter did not rise. Plus the in-repo gap stage in the perf runner and its ssh retry.Update (same day, after coding). Two details that were not in the note above:
--gap-saves Nincrates/perf(with--search-onlyfor the smoke set plus search). It makes a real gap through the real server: build the index (search.cold_reconcile), keep a copy, save N edits so every receipt is valid, then put the older index back. The earlier out-of-tree stage could not be recovered; this is a new, reproducible one.Crash-typing flake comparison
The first branch-native run reported candidate 18/20 and dev 2/20, but those runs used different test sources: the candidate test retries the documented retryable
not_saved_yetresponse, while dev's older test expects an immediate 200. I reran the test interleaved with the exact candidate test source on both revisions (SHA-256f9947402f29e4bacafbc5dc803c4236228411cd923bf63a80e2e65969b7d2555).0efea4a1cd7f8443b never convergedEach arm ran the exact command
CARGO_BUILD_JOBS=4 cargo test -p calternal --test notes_crash_typing kill_nine_while_people_type_loses_and_doubles_nothing -- --exact, alternating candidate then dev for 20 rounds. The dev-only failure is retained in the log; the candidate had no failures.macOS gate rerun on 2026-11-08
#995 is closed and marked superseded by #1011. The open #1011 head
5a83ac274504b0b5fdb9e657cafa6a82e2a6544fhasorigin/devmerge-base93d9a321a39044afddfe9075db2e1c1f729e3b78, so it already includes #1013.The first Mac run exposed a test-fixture path collision:
Notes::newcolocates data and state, and case-insensitive APFS aliases<user>/Noteswith<user>/notes. The write executor then correctly refused a journal inside Notes. Commitsf73e93209and5a83ac274change the P1b test fixtures to use separate data and state roots throughNotes::with_state; Notes runtime behavior is unchanged.Each Cargo gate below ran as its own Nix/slot step on
/Users/lume/Developer/Worktrees/mail-p1b-mac-1011-eb1a4294, with a 40-minute per-step timeout. The helper acquired/tmp/mac-bench.lock, wrote its holder, and removed it in its EXIT trap before the next step. Other Mac lanes used the lock between our steps.timeout --kill-after=30s 40m cargo fmt --checktimeout --kill-after=30s 40m cargo clippy --workspace --all-targets -- -D warningstimeout --kill-after=30s 40m cargo test -p calternal-search --libtimeout --kill-after=30s 40m cargo test --workspace --no-fail-fastThe full suite's first Mac attempt also had one
kill_nine_while_people_type_loses_and_doubles_nothingfailure (b never converged). It passed on the final full-suite rerun. The requested interleaved dev-02 repetitions passed: candidate 20/20, dev 20/20, usingcargo test -p calternal --test notes_crash_typing kill_nine_while_people_type_loses_and_doubles_nothing -- --exact.P1b perf remains pending; these results make no performance claim.
P1b perf ABAB status, 2026-11-09 ~10:45 CET (no numbers yet)
Head under test:
02baeb54212095d956d1fcce49594d6c36d36ea2. Staged binaries verified withsha256sum -c.data/statedirs (exact paths).block-1-a1-devfinished.block-1-a2-candidatethen stalled at "100000 notes, warm run 1" from 06:49. Harness and server were both idle, no sockets, every thread in futex/epoll wait. I stopped my own unit. Cause not found: the same stage passed on both dev arms.notes.list500 on dev too, at the same step as before. The 100k cold index writes 4.5 GB, so 1M needs about 45 GB, and the shared perf disk has 16-25 GB free. Unconfirmed as ENOSPC.Changes to the rig: 100k only (no 1M), 25-minute per-arm timeout with one retry.
Not done: the ABAB x5 and the dev-vs-dev control. Another lane held perf-01 for over 3 hours after 09:11, and I did not get a turn. I cancelled my queued unit and hold no lock.
The candidate stall is unresolved. If it recurs under the new timeout, treat it as a PR bug: capture its stack and fix it before merge.
P1b perf turn 2, 2026-11-09 (FIFO): candidate warm-run stall reproduced, no ABAB numbers
PR head (API
head.sha):02baeb54212095d956d1fcce49594d6c36d36ea2.refs/pull/1011/headstill shows000873f49…, which is the stale-ref gotcha; the brancht3/paged-search-reconcile-finalis at02baeb542.block-1-a1-devblock-1-a2-candidate100000 notes, warm run 1from 11:39:35 CET, same as the 06:49 stall in the first session.GET /healthzreturned 200 on its real port. Server idle:search-writerparked on an emptystd::sync::mpmc::Channel<calternal_search::index::Job>, all tokio workers in futex wait. Harness: main thread parked intokio::runtime::park::Inner::parkundercalternal_perf::main, all workers parked, one worker inepoll_wait, no TCP connection open, no timer armed.gdb -p <pid> thread apply all btof both processes, kept on perf-01 underresults-turn-patched-harness/block-1-a2-candidate/stall-gdb-*.txt.Reading: the harness is waiting on something that never arrives, while the server has finished its work. Dev arms pass the same stage on the same harness build. The staged harness (
harness/*/calternal-perf, sha21c0679b…candidate,e02f9fc2…dev) contains an "offline saves for a warm search catch-up gap" edit that is not in02baeb542, and its source is in no worktree I can find. So I cannot yet say whether the stall is in the candidate server or in that edit.Next test, queued but not run: the clean harnesses built from each tree (
bin/*/calternal-perf,c6149a26…candidate,fa53d86f…dev). If the stall disappears, it is the edit. If it stays, it is a PR bug and needs a fix on compute02 before merge.Not run: the ABAB x5 and any per-metric verdict.
Process notes:
~/bin/perf-turnwas edited while my waiting copy ran, so bash hitsyntax error near unexpected token doneat 13:56 CET and my ticket (2 h into the wait) was dropped. A ticket named0000000000-782670-sharing-842b(timestamp 0) was at the head of the line for about an hour. I now hold no ticket and no lock.P1b: stall root-caused off-lock (compute02, no perf lock), Mac gates re-run at
02baeb542PR head (API):
02baeb54212095d956d1fcce49594d6c36d36ea2. No perf numbers yet; I did not take the perf turn because the stall is a design question (below).Mac gates at
02baeb542(macos-01, one lock hold per step, each released)cargo fmt --checkcargo clippy --workspace --all-targets -- -D warningscargo test -p calternal-search --libcargo test --workspace --no-fail-fastStall repro, compute02 (12 cores), 100k warm, 20 min cap, no perf lock
e02f9fc2…, has the save-log gap stage)21c0679b…, gap stage)notes.list incomplete: true, host load ~35 (rc=124 at the cap)The clean harness has no gap stage, so
search.catch_upis about 0 s andsearch.cold_reconcileis absent. It does not measure what #1011 changes. The edited harnesses' gap source is in no repo or worktree I could find.Root cause (candidate server)
At the stall the server answers
/healthz200 andnotes.listreturnsincomplete: false, butsearch.queryreturnsindexing: trueindefinitely, with every thread idle. A diagnostic build (extrawarn!only) loggedDIAG waiting for publication signal provider="notes"and never logged that it arrived. The indexer is parked inPublicationWait::retry(crates/search/src/indexer/sync.rs): after its 5 backoff rereads it waits onreceiver.changed().awaitwith no cap, for a receipt whosesource_versionis ahead of what the notes provider publishes. Nothing publishes again, because the notes scan is already finished.What I tried and why I stopped
I capped that wait at 30 s and returned
SyncTokenExpiredso the caller rebuilds. The stall changed shape but did not go away: the rebuild (reconcile_pages) enforces the same receipt fences, so it waits and times out too, and the log repeatssearch receipt was never published; rebuilding/search could not synchronize its index. Diff kept on compute02 at~/perf/mail-1011-repro/bounded-wait-experiment.diff, not pushed.So the fence design has no escape when a receipt's version is never published. Decision needed from the owner, not a quick patch:
Cleanup: no ticket, no lock, no unit left on perf-01, perf-02 or compute02. Repro files kept at
~/perf/mail-1011-reproon compute02 (data 1.5 GB).P1b perf ABAB x5 with the save-log gap stage, 100k, perf-01/perf-02 (2026-11-09 18:52-19:30 CET)
PR head
dcb50cda8df62e2cad6d3fdd9218f7b3538f6352(candidate server built from it; harness--gap-saves 600 --search-only). Dev =93d9a321build. Run through plain FIFOperf-turn(front-of-line grant), both perf locks held for the whole run, released after.Order per block: dev, candidate, dev, candidate; 5 blocks, 20 arms, every arm passed (no stall, no failure). The control is dev vs dev: "spread" is the median relative gap between the two dev arms of a block.
arms: 20 failures: []
| metric | stat | dev median | cand median | delta | dev-vs-dev spread (median |a1-a3|) | cand/dev blocks worse | verdict |
|---|---|---:|---:|---:|---:|---:|---|
| search.catch_up | p50 | 2.01 | 2.01 | -0.3% | 0.4% | 1/5 | better |
| search.cold_reconcile | p50 | 47.9 | 49.2 | +2.8% | 2.3% | 4/5 | SLOWER beyond noise |
| search.query | p50 | 1.61 | 3.2 | +98.1% | 8.1% | 5/5 | SLOWER beyond noise |
| search.query | p95 | 3.94 | 7.46 | +89.6% | 8.2% | 5/5 | SLOWER beyond noise |
| search.first_query | p50 | 3.44e+03 | 2.09e+03 | -39.1% | 18.7% | 0/5 | better |
| startup.healthz | p50 | 5.15 | 5.28 | +2.5% | 31.3% | 2/5 | flat/better |
| open.api_get | p50 | 2.49 | 2.94 | +18.1% | 6.4% | 4/5 | SLOWER beyond noise |
| open.api_get | p95 | 21.9 | 20.5 | -6.5% | 10.9% | 1/5 | better |
| open.note | p50 | 2.53 | 3.27 | +29.2% | 6.1% | 5/5 | SLOWER beyond noise |
| open.note | p95 | 9.61 | 98.7 | +927.0% | 23.6% | 5/5 | SLOWER beyond noise |
This is not mergeable as it stands.
search.queryis consistently about 2x slower on the candidate in all 5 blocks (dev 1.5-1.7 ms, candidate 3.0-3.7 ms at p50; p95 3.9 vs 7.4 ms), far outside the dev-vs-dev spread.open.notep50 is a little higher (about +0.5 ms) and its p95 is outlier-driven (one ~100 ms open in 7 of 10 candidate arms against 1 of 10 dev arms).search.cold_reconcileis +2.8% against a 2.3% spread (borderline).search.first_queryis better andsearch.catch_upis flat.Caveats on this table:
search.catch_upreads 2.01 s in both arms because the harness polled "still indexing?" every 2 s, so any catch-up under 2 s is invisible. The poll is now 50 ms inae2383a7d(harness only); a second turn with the fine harness is needed for a real catch-up number.Investigating the
search.queryslowdown next (index shape after the paged rebuild is the first suspect; no read-path code changed in the PR).P1b: liveness fix done; perf regression on
search.queryfound, plan below (stopping, not patching)PR head
dcb50cda8df62e2cad6d3fdd9218f7b3538f6352(the liveness fix and the gap stage are pushed to it;ae2383a7don the side brancht3/mail-1011-livenessadds a 50 ms catch-up poll to the harness and is not on the PR yet).Done
crates/search/src/indexer/publication.rs,sync.rs): the wait is capped (Options::publication_cap, 30 s) and the page or rebuild is then staged without its fences. Counterfences_superseded(), one warn per pass.fence_liveness.rs(receipt ahead of provider finishes on the catch-up path and on the rebuild path; late publication inside the cap is honoured on both). Search lib: 44 passed.--gap-saves N,--search-only. Candidate with gap stage: 3/3 warm runs pass on compute02 (the stall was 2/2 before).dcb50cda8(macos-01): fmt, clippy pass; search lib 44 passed; workspace 1,614 passed, 0 failed, 22 ignored. The first Mac run atb4cb96a7ehad onekill_nine_while_people_type_loses_and_doubles_nothingfailure; ten reruns atdcb50cda8gave 8 pass, 2 FAIL (13.6 s, the same failure shape). It is the known flaky test (see the 11-08 comment: dev also failed 1 of 20 on Linux), but I have not shown it is unrelated on macOS, so I am not calling it clean.Not mergeable:
search.queryabout 2x slower (5 of 5 blocks)p50 dev 1.5-1.7 ms vs candidate 3.0-3.7 ms; p95 3.9 vs 7.4 ms. Also
open.notep50 +0.5 ms (p95 outliers of ~100 ms in 7 of 10 candidate arms),search.cold_reconcile+2.8% (spread 2.3%). Better:search.first_query(-39%); flat:search.catch_up(but the poll was too coarse to see it).What I narrowed down (compute02, loaded, so ratios only)
Search::queryandindex.searchare the same code).search.queryp50 5.1 / 3.6 ms vs dev 7.5 / 8.2 ms). So the slowdown comes from the save-log catch-up path leaving the index in a state that queries more slowly, not from the cold build or the query code.Plan (needs a decision: this is round 2 of "new problem found", so no blind patch)
ae2383a7d), gap on, and readPRAGMA wal_checkpointand WAL size at query time for both arms (one front-of-line turn, 30 min).wal_autocheckpoint = 0): checkpoint (passive) once after catch-up finishes, in the indexer, with a test that WAL size is bounded after a catch-up of N pages.ftssegment structure andANALYZE/stat tables between arms, then thegeneration/vocab cache invalidation counts (candidate bumpsgenerationonce per page).search.queryas the gate. Nothing merges untilsearch.query,open.noteandcold_reconcileare flat against dev.Cleanup done: compute02 repro data and targets deleted (59 MB of scripts left), perf-01 run data deleted, no ticket, lock or unit held on either perf VM.
P1b round 2:
search.queryregression explained (harness artifact); ABAB x5 re-run; two gates still openPR head
3d5999c7a775725b842ae85fbec24d08f139d78f(candidate server = the PR's search code; the commits sincedcb50cda8are harness-only: 50 ms catch-up poll, populated-baseline gap stage).Why
search.querylooked 2x slowerfrecencyrows, the candidate-built one 593. The candidate's catch-up replays the save "uses" (correct); dev's legacy-cursor path deliberately skips them.read.rs::frecent()returns at once whenfrecencyis empty; otherwise it reads the 64 most recent used notes for every query. A fixed cost that saturates at 64 rows, which every Home in use pays on dev too.3d5999c7a): the gap stage opens 100 notes before the gap so both arms start with a used-notes table (checked: dev arm 100 rows, candidate 593).ABAB x5, 100k, gap stage, populated baseline (perf-01/02, 2026-11-10 06:36-~07:30 CET), 20 arms, 0 failures
arms: 20 failures: []
| metric | stat | dev median | cand median | delta | dev-vs-dev spread (median |a1-a3|) | cand/dev blocks worse | verdict |
|---|---|---:|---:|---:|---:|---:|---|
| search.catch_up | p50 | 1.27 | 0.85 | -33.0% | 3.0% | 0/5 | better |
| search.cold_reconcile | p50 | 45.6 | 47.3 | +3.5% | 1.2% | 5/5 | SLOWER beyond noise |
| search.query | p50 | 3.34 | 3.23 | -3.4% | 7.3% | 2/5 | better |
| search.query | p95 | 7.39 | 7.48 | +1.2% | 8.4% | 3/5 | flat/better |
| search.first_query | p50 | 5.62 | 7.43 | +32.2% | 11.0% | 5/5 | SLOWER beyond noise |
| startup.healthz | p50 | 5.55 | 5.05 | -9.1% | 47.2% | 0/5 | better |
| open.api_get | p50 | 0.851 | 0.819 | -3.8% | 6.8% | 3/5 | better |
| open.api_get | p95 | 3.89 | 4.61 | +18.5% | 238.4% | 4/5 | flat/better |
| open.note | p50 | 0.947 | 0.851 | -10.1% | 16.3% | 2/5 | better |
| open.note | p95 | 36.7 | 4.86 | -86.8% | 16.1% | 0/5 | better |
Gate "search.query, open.note, cold_reconcile all <= dev"
search.queryp50 3.34 -> 3.23 ms, p95 7.39 -> 7.48 ms: flat (inside the 7-8% spread). Pass.open.notep50 -10%, p95 36.7 -> 4.9 ms: pass (dev p95 had a ~90 ms outlier in 3 of 10 arms).search.catch_up1.27 -> 0.85 s (-33%, 0/5 blocks worse): better.search.cold_reconcile45.6 -> 47.3 s (+3.5%, 5 of 5 blocks worse, spread 1.2%): FAIL, a steady ~+2 s. Likely the receipt-fence scan the rebuild now does before listing providers (stage_source_fences, then fence joins incompare_page); not yet profiled.search.first_queryp50 5.6 -> 7.4 ms (+32%, 5 of 5 blocks): mostly 4 of 10 candidate arms at 9.9-17.7 ms; the other arms are 5-7 ms like dev. Small in absolute terms but not flat: FAIL by the no-regression rule until explained.macOS flake
kill_nine_while_people_type_loses_and_doubles_nothing(macos-01, same command, 20 runs each)9438918f3: 20/20 pass.... never converged, the rejoined-tab text check atnotes_crash_typing.rs:413). Earlier PR runs: 1 of 1 and 2 of 10 failed. Pooled that is 5 failures in ~31 PR runs vs 0 in 20 on dev.room/save.rs,room/mod.rslistednow keyed by version,meta.rs live_metagetssource_version). Not diagnosed.State
Liveness fix, tests and gap stage are pushed. Not mergeable:
cold_reconcileandfirst_queryregress, and the Mac flake looks PR-specific. Two follow-ups for a decision (the 3-round rule applies, so I am stopping here):cold_reconcile+2 s), then either speed it up or accept it as the cost of fenced receipts, with kayg's call since the rule is "no regression".Also worth its own issue:
search.querypays a fixedfrecent()cost for any Home with 64+ used notes, on dev too.Cleanup: perf-01/perf-02 locks and units released, compute02 and perf-01 run data removed.
P1b handoff to Codex (Claude usage limit), PR head
015546c00f9e3896a2088c89bc937ab5b36b5dcbPushed: everything is on the PR branch. Last commits:
015546c00(crash-test fix),2949f1e26(first_query breakdown metrics),3d5999c7a(gap stage opens 100 notes),ae2383a7d(50 ms catch-up poll). No perf lock, ticket or Mac lock held; my compute02 units are stopped.1. macOS flake
kill_nine_while_people_type…: found and fixed (test-only)Mac runs, 20 each (re-run once at 11:13-11:16Z with no window announced in infraternal#260):
93d9a321a93d9a321a015546c00)notes.savebefore the firstnotes.get(as the original test did). The exact mechanism is likely the read racing the room's post-restart replay, not proven; the comment in the test says only what is established.2.
search.cold_reconcile+2 s (45.6 -> 47.3 s, 5/5 blocks): NOT fixed, profile not finishedstage_source_fences(crates/search/src/indexer/sync.rs) scans the whole retained save-log (~200k entries at 100k notes) before listing; dev does no such scan.warn!linesPROF fence_scan,fence_scan_detailwith journal-read vsrecord_fencesms,list_and_compare,attempt_total) from3d5999c7aon compute02 (diff is uncommitted in~/Developer/Worktrees/mail-1011-fix; it is 24 lines insync.rs). Its first run was just launched and stopped, so there are no numbers yet.item.clone()for entries with no fence (created entries), batchrecord_fences, or pipeline the scan with provider listing. A scan avoided only for a brand-new empty index would change semantics, so no.3.
search.first_queryoutliers: not explained yetCandidate arms 4/10 at 9.9-17.7 ms vs 5-7 ms otherwise (dev 4.6-6.5). Queries use a pooled reader, so the first one opens a fresh connection (cold SQLite cache).
2949f1e26addssearch.first_query_sql(servertook_ms),search.second_queryandsearch.third_queryto the harness, so the next ABAB separates SQL time from open/queue time. I did not test the cold-page-cache idea itself.4.
frecent()fixed cost: issue #1053Evidence and the proposed top-64 in-memory cache are there. It explains the earlier 2x
search.query(emptyfrecencytable in the dev arm); harness fixed,search.queryis now flat.Next ABAB
Use plain
perf-turn; the harness needscalternal-perf-finerebuilt at2949f1e26. Perf-02 handshake note: ssh from perf-01 to perf-02 fails inside systemd units, and my poller must not delete the runner's ownreadyfile (it did, once).View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.