notes: paged identity projection and bounded child lookups (#761 C) #792

Closed
kayg wants to merge 16 commits from feat/tiering-761-c-index into feat/tiering-761-b-reader
Owner

SQLite temp-storage follow-up plan (2026-11-01)

Cause under investigation. A path-annotated C trace recorded 236,342 writes to five SQLite etilqs_* temporary files while the scan had staged 20,000 identities. The writes were 4-byte markers and 4 KiB pages; they came from the same identity-writer thread as ids.db WAL writes and identity-log fsyncs. The main Index::load connection already uses in-memory temp storage. The traced run then hit the existing 60-second writer acknowledgement timeout under instrumentation, so this is attribution evidence, not a completed 100k trace or timing result.

Change. Apply temp_store=MEMORY and temp.cache_size=-128 to the identity-writer and compaction-reader SQLite connections. Keep synchronous=FULL, the identity-log fsync, directory sync, and durable acknowledgements unchanged. The per-connection temporary cache remains 128 KiB; the existing projection lease continues to bound aggregate SQLite caches to 64 MiB across 128 Homes. Runtime and storage remain O(N) in indexed notes, with O(128 KiB) temp-cache budget per connection.

failure effect detection recovery test
A writer/compaction connection spills temp pages extra SQLite temp-file writes remain path-annotated strace still shows etilqs_* writes identify the remaining connection and keep its temp cache bounded writer and snapshot connection regression checks
projection acknowledgement is delayed scan remains incomplete; no success is reported existing scan timeout and incomplete-list state retry the scan after the writer recovers existing scan retry and durable batch tests
process stops during compaction committed identity log remains authoritative reopen/replay sees the old log or published snapshot remove scratch and rebuild projection existing compaction recovery and replay tests
durability setting regresses an identity may be acknowledged before the durable projection pragma assertion and crash tests restore FULL sync and replay from the log FULL-sync regression assertion and real-binary crash tests

Gates. On dev-01 through Nix and ~/bin/slot: full workspace tests, format, strict clippy, and release build. Build and SHA-256-check C/dev/perf binaries on dev-01; run three alternating cold pairs and one warm pair on perf-01 under ~/bin/perf-lock with RuntimeMaxSec >= 6h. Cold p95 must be at most 1.25x dev; open.note p95 must be at most 15 ms and idle.rss p95 at most 100 MB. No durability relaxation is allowed.


scan batch cache experiment: rejected

The 400-row experiment (df742b61) kept log fsync and FULL projection commits but did not reduce syscall writes: 1,876,854 pwrite64 calls versus 1,868,042 at 4,000 rows. Its untraced cold run took 47.447 s and wrote 1,306.8 MB versus the prior diagnostic’s 26.068 s / 983.0 MB (shared dev-01, so timing is diagnostic). Reverted the experiment; the retained implementation and durability protocol are identical to 92a28435. The fresh retained-binary comparison measured 25.500 s / 962.302 MB. Final head 558e78e93ae4fd5aa3d0400216573509f1ecbde5 passes targeted tests, crash tests, fmt, strict clippy and release build; isolated qualification remains blocked by both shared perf locks. No job of ours remains running.

Bounded discovery reader follow-up

The latest shared dev-01 pair still measures C 29.3 s / dev 16.2 s. Metadata description is 14.3 s; the reader performs repeated absolute-root checks for each file. Add a background-only reader group capped at 128 opens: validate the original root before and after the group, retain per-file plain-name/no-symlink/no-cross-mount opens, and return derived results only after final validation. Metadata still validates again before its transaction. Ordinary request reads keep their existing checks. Each worker describes one body at a time; no group retains 128 bodies. Root validation becomes O(groups), with O(128) derived results per worker and unchanged 64-worker admission.

Failure: root replacement during a group discards the entire result and fails discovery for retry; no note file is changed. Gate this with deterministic replacement and group-limit regressions, the existing retained-reader tests, notes and crash suites. Both perf VMs are currently occupied by the separate 759b matrix; isolated qualification is still required.

Resumed cold-index plan (2026-11-01)

The new dev-01 phase profile shows repeated exact SQL preparation and repeated absolute-root ancestor opens, in addition to the pending-overlay exclusion scan. Cache the two point-lookup statements and replace Linux root-validation component walks with one openat2 rooted at /, retaining no-symlink resolution and all before/after root mount/device/inode/owner checks. Root path parsing stays O(depth), descriptor resolution becomes O(1) syscalls; each validation holds at most two transient descriptors. No bodies are added to tier storage and no log sync or FULL projection commit is removed.

failure effect / detection recovery gate
ancestor becomes a symlink no descriptor returned; no-symlink resolution fails restore the path and restart existing ancestor-symlink reader regression
root is replaced or remounted existing identity comparison refuses the read offline repair and restart pinned-reader replacement regression
parent component supplied reject before resolution, preserving current input rules use a plain root path root-path regression
interrupted identity append / projection durable log and FULL commit rules unchanged existing replay and scan recovery notes suite and real-binary crash tests

Re-run fsio, notes, crash and fmt/clippy gates on dev-01; build there, verify SHA-256 through control and perf hosts, then collect three alternating cold pairs on perf-01 and one designated warm pair on perf-02 under the shared locks. Nothing merged.

Paged identity projection and bounded background I/O (#761 C)

Stacked on B #791. Identity, folded-path, child, and descendant lookups use SQLite B-trees. ids.jsonl remains the committed rollback/recovery log; its identity and length validate the projection on reopen.

Cold-index follow-up plan and dev-01 profile

Cause. On C head 9c8c2873, identify_many awaited a durable write after each 50-note batch. At 100k notes that made about 2,000 sequential writer transactions, preventing the writer's eight-command queue from grouping scan commits. A server-only dev-01 strace profile counted 6,569 fsync/fdatasync calls: 2,000 on ids.jsonl, 2,004 on its directory, 2,295 on ids.db-wal, 144 on ids.db, 98 on meta.db, 16 on history.db, and 12 other syncs. The perf harness reported 2,253 MB physically written for that traced cold run; the instrumented cold.index time was 920 s, so its elapsed time is diagnostic only. The final files were 8.8 MB ids.jsonl, 35.2 MB ids.db, and 4.5 MB ids.db-wal. The scan reads note bodies but its write paths are the identity log and SQLite index/metadata/history stores.

Fix. Cold discovery now uses a scan-only limit of 400 identities per durable append and synchronous=FULL projection transaction. Ordinary put_many remains capped at 50. Staging clears up to the same group size of stale overlay rows, so the disk-backed pending table remains O(400). At 100k notes this reduces scan groups from about 2,000 to 250; discovery and log replay remain O(N). The identity log sync, directory sync, and FULL projection commit are retained.

Failure point Effect Detection Recovery Regression evidence
Crash before the current group's log sync Up to 400 derived identities may be absent or partly appended; note files are untouched Startup scan finds source notes missing from the identity index; the loader skips incomplete JSONL records Append repairs a partial trailing line; the scan rebuilds missing identities from files r2_scan_recovers_after_restart_between_identity_groups
Crash after log sync but before projection commit The complete group is in ids.jsonl; SQLite projection may lag Projection length or identity does not match the log on open Replay the durable log into SQLite scan_batch_of_400_is_durable_and_replayed
Crash during projection commit SQLite WAL recovers; the durable log remains authoritative Projection metadata validation on open Rebuild the projection from ids.jsonl if needed scan_batch_of_400_is_durable_and_replayed
Repeated scan groups Stale pending rows could accumulate in TEMP storage Overlay row count exceeds one group Each scan staging transaction clears up to 400 stale rows; normal staging remains capped at 50 scan_batches_clear_the_previous_staged_group

The scan still reports incomplete until discovery finishes. No note file is modified. The event batch remains 1,000 records. The dev-01 trace is diagnostic, not a performance qualification.

Gates. Run the notes crate suite on dev-01. On perf VMs, alternate C/dev samples under ~/bin/perf-lock using the #issuecomment-72012 harness options; collect three cold and five warm 100k runs and report cold.index, open.note, and idle.rss. Require cold.index p95 at most 60 s and at most 1.25x dev. Report page-cache drop availability. Keep C idle RSS at or below the prior C p95 of 107.668 MB; the existing 100 MB RSS budget remains tracked separately.

Round-2 design and bounds

  • Requests reserve one of 64 semaphore permits with try_acquire_owned() before staging identities or changing create/rename/delete files and history. The transport cannot accumulate more than 64 admitted commands, including in-flight commands. Admission never waits. Saturation/closed writers return NotSavedYet (HTTP 503, retry).
  • A dedicated writer connection performs log append/fsync and projection commit before acknowledging. Callers await outside the Home index guard with a five-second deadline. Up to eight queued commands share a commit, with at most 50 rows per command. Reservations do not retain a channel sender, so a paused, unsent request cannot keep shutdown alive.
  • A same-ID create retry checks the pending sequence against the committed projection watermark. It returns 503 while the earlier identity is uncommitted, including after the first request timed out.
  • Compaction runs only on that background writer. A pinned read transaction streams 50 snapshot rows per step, then copies at most 64 KiB of the committed log tail per step. It serves at most eight queued commands between steps. Publication atomically replaces ids.jsonl; it never rebuilds the request connection's projection.
  • Cold discovery stages and acknowledges identities in groups of at most 400 through a scan-only API; ordinary identity writes remain at 50. A bounded pending page resolves duplicate embedded IDs before commit. The existing 10k task cold-fill test, r2_scan_batches_keep_duplicate_embedded_ids_distinct_across_pages, and the new restart regression cover completion, identity preservation, and interrupted discovery.
  • Descendant queries use keyset pages of at most 50 rows. Watcher removals and rename matching retain one page. Contact linking also pages immediate children and retains at most two candidates per requested name.
  • Scratch is never committed state: only ids.jsonl plus matching projection metadata is authoritative. Startup takes the exclusive index ownership lock, then removes reserved UUID scratch names, including interrupted legacy atomic-write scratch. Unrelated files remain untouched. A live owner prevents cleanup of active snapshots.
  • SQLite cache allowances remain capped at 128 Homes × (128 KiB reader + 128 KiB TEMP + 128 KiB writer + 128 KiB snapshot) = 64 MiB. Pending rows use a disk-backed TEMP overlay; only sequences newer than the committed watermark are visible. Cleanup is capped at 50 stale rows for ordinary staging and 400 for one cold-scan group.

Exact lookups are O(log n); descendant/child pages are O(log n + 50). Replay and complete compaction remain O(entries), with bounded compaction steps outside request locks. Initial projection replay occurs in the existing blocking Home-open worker.

Review findings and failure recovery

Finding / failure Result and recovery Regression evidence
P1: blocking full queue Immediate 503/retry before rejected identities or create files are staged; another Home saves on the same runtime r2_full_queue_never_waits_for_the_writer_or_stages_rejected_ids, r2_saturated_identity_queue_refuses_create_without_blocking_another_home
P1: inline compaction Background snapshot creation failure cannot fail a successfully admitted append; bounded steps preserve concurrent changes/deletions r2_compaction_failure_does_not_fail_an_admitted_append, r2_compaction_steps_keep_concurrent_updates_and_deletions_on_reopen (50k rows)
P2: unbounded descendants Pages contain at most 50 rows; watcher processes all pages r2_descendant_queries_never_materialize_a_wide_subtree (50k), r2_watcher_removes_every_descendant_across_pages, r2_contact_candidates_page_beyond_fifty_and_bound_ambiguous_names
P2: orphan snapshots Drop removes scratch; startup removes interrupted/replacement-failure orphans only after exclusive ownership r2_startup_removes_only_uncommitted_compaction_scratch, r2_replacement_error_orphans_are_recovered_only_after_owner_exits
Writer stalls / exits Async acknowledgement expires after five seconds or reports disconnect as 503/retry; fix disk and restart if writer exited r2_acknowledgement_has_a_deadline_and_another_writer_keeps_saving, existing failed-log-write regression
Create retries before an earlier identity commit 503 until the committed watermark advances; then the same identity succeeds r2_create_retry_cannot_acknowledge_an_uncommitted_identity failed before the guard, passed after
Shutdown with an unsent reservation Join buffered writer work without waiting for the paused request; late staging is refused r2_shutdown_does_not_wait_for_an_unsent_admission, a_create_that_outlives_shutdown_writes_nothing

The four original regressions (queue, compaction failure, 50k descendant cap, orphan startup cleanup) all failed on the pre-fix C head cb8ef0b0. The map functional model waits for actual disk acknowledgement rather than applying the request deadline. The anchor refusal fixture waits for save-time title normalization before its whole-note equality snapshot; the previous full run captured only that title property racing the snapshot, with unchanged block text. Both original assertions remain. Restart fixtures now release the old index owner without settling unsaved rooms; the crash-recovery assertions remain in place.

Validation

Head: 9c8c287357240912daf895c4166bf00f2212ce4a.

PASS at 9c8c287357240912daf895c4166bf00f2212ce4a: fmt, strict clippy, and the full workspace suite; 1,205 passed, zero failed, 20 ignored, exit 0. Run sequentially through Nix/slot on dev-01 with RUST_TEST_THREADS=2. Evidence: ~/tier-c-r2-final6.{log,exit}. D's exact-head gate also passed: 1,208 passed, zero failed, 20 ignored (~/tier-d-r2-final6.{log,exit}). The final-head release rebuild passed; runtime hashes match the prepared perf binaries.

cargo fmt --all --check
cargo clippy -j4 --workspace --all-targets --locked -- -D warnings
RUST_TEST_THREADS=2 cargo test -j4 --workspace --locked --no-fail-fast -- --nocapture

Final head 9c8c287357240912daf895c4166bf00f2212ce4a built successfully on dev-01. The final-head rebuild produced the same runtime binaries as 9e15ece7; the only later change is a test fixture barrier. SHA-256 matched on dev-01, control, and perf-01:

  • server: 0a541a0ba472631fe53872fbdce85f6e93663188144434c1b2e24f2bf15c7ea5
  • harness: 081e036a464cb95bc13f67732e87079437874155f3c5bf9933ebc7f66de5b297

Final-head performance qualification is blocked. Both perf VMs are occupied by another campaign using /var/lock/calternal-perf.lock, which claude cannot open. The attempted final-head run under the documented ~/bin/perf-lock was stopped after detecting the other campaign. No results from that attempt are treated as qualification. No other processes were stopped. Earlier 48ae327e measurements (3.218 ms open.note p95; 89.293 MB idle RSS) remain historical evidence, not isolated final-head qualification.

The outstanding command, on an idle perf VM under its exclusive measurement lock:

calternal-perf --bin "$PWD/bin/calternal" --work "$PWD/work" --sizes 100k --runs 3 --cold-runs 1 --idle-secs 60 --budgets "$PWD/budgets.json" --gate --json "$PWD/report.json" --md "$PWD/report.md"

The budget file contains only open.note@100000.p95=15 and idle.rss@100000.p95=100. Prepared binaries/fixture/run script: perf-01 ~/tier-c-perf-9e15/. Aborted measurement log: ~/tier-c-r2-perf-final4.log. The attempted shared-lock acquisition returned permission denied (~/tier-c-r2-perf-final5.{log,exit}, exit 66). No measurement remains running.

Remaining limits

A five-second timeout does not cancel an admitted disk write; the client retries through existing idempotent operations. A stopped writer requires disk repair/restart. Cache-lease exhaustion refuses a new Home; fair eviction is future work. These changes do not qualify FO1, online tier movement, history-heavy 100k-edit opens, or power-loss protocols.

Stack: A #790 → B #791 → C #792 → D #793. Nothing merged.

### SQLite temp-storage follow-up plan (2026-11-01) **Cause under investigation.** A path-annotated C trace recorded 236,342 writes to five SQLite `etilqs_*` temporary files while the scan had staged 20,000 identities. The writes were 4-byte markers and 4 KiB pages; they came from the same identity-writer thread as `ids.db` WAL writes and identity-log fsyncs. The main `Index::load` connection already uses in-memory temp storage. The traced run then hit the existing 60-second writer acknowledgement timeout under instrumentation, so this is attribution evidence, not a completed 100k trace or timing result. **Change.** Apply `temp_store=MEMORY` and `temp.cache_size=-128` to the identity-writer and compaction-reader SQLite connections. Keep `synchronous=FULL`, the identity-log fsync, directory sync, and durable acknowledgements unchanged. The per-connection temporary cache remains 128 KiB; the existing projection lease continues to bound aggregate SQLite caches to 64 MiB across 128 Homes. Runtime and storage remain O(N) in indexed notes, with O(128 KiB) temp-cache budget per connection. | failure | effect | detection | recovery | test | |---|---|---|---|---| | A writer/compaction connection spills temp pages | extra SQLite temp-file writes remain | path-annotated `strace` still shows `etilqs_*` writes | identify the remaining connection and keep its temp cache bounded | writer and snapshot connection regression checks | | projection acknowledgement is delayed | scan remains incomplete; no success is reported | existing scan timeout and incomplete-list state | retry the scan after the writer recovers | existing scan retry and durable batch tests | | process stops during compaction | committed identity log remains authoritative | reopen/replay sees the old log or published snapshot | remove scratch and rebuild projection | existing compaction recovery and replay tests | | durability setting regresses | an identity may be acknowledged before the durable projection | pragma assertion and crash tests | restore FULL sync and replay from the log | FULL-sync regression assertion and real-binary crash tests | **Gates.** On dev-01 through Nix and `~/bin/slot`: full workspace tests, format, strict clippy, and release build. Build and SHA-256-check C/dev/perf binaries on dev-01; run three alternating cold pairs and one warm pair on perf-01 under `~/bin/perf-lock` with `RuntimeMaxSec >= 6h`. Cold p95 must be at most 1.25x dev; open.note p95 must be at most 15 ms and idle.rss p95 at most 100 MB. No durability relaxation is allowed. --- ### scan batch cache experiment: rejected The 400-row experiment (`df742b61`) kept log fsync and FULL projection commits but did not reduce syscall writes: 1,876,854 pwrite64 calls versus 1,868,042 at 4,000 rows. Its untraced cold run took 47.447 s and wrote 1,306.8 MB versus the prior diagnostic’s 26.068 s / 983.0 MB (shared dev-01, so timing is diagnostic). Reverted the experiment; the retained implementation and durability protocol are identical to `92a28435`. The fresh retained-binary comparison measured 25.500 s / 962.302 MB. Final head `558e78e93ae4fd5aa3d0400216573509f1ecbde5` passes targeted tests, crash tests, fmt, strict clippy and release build; isolated qualification remains blocked by both shared perf locks. No job of ours remains running. ### Bounded discovery reader follow-up The latest shared dev-01 pair still measures C 29.3 s / dev 16.2 s. Metadata description is 14.3 s; the reader performs repeated absolute-root checks for each file. Add a background-only reader group capped at 128 opens: validate the original root before and after the group, retain per-file plain-name/no-symlink/no-cross-mount opens, and return derived results only after final validation. Metadata still validates again before its transaction. Ordinary request reads keep their existing checks. Each worker describes one body at a time; no group retains 128 bodies. Root validation becomes O(groups), with O(128) derived results per worker and unchanged 64-worker admission. Failure: root replacement during a group discards the entire result and fails discovery for retry; no note file is changed. Gate this with deterministic replacement and group-limit regressions, the existing retained-reader tests, notes and crash suites. Both perf VMs are currently occupied by the separate 759b matrix; isolated qualification is still required. ### Resumed cold-index plan (2026-11-01) The new dev-01 phase profile shows repeated exact SQL preparation and repeated absolute-root ancestor opens, in addition to the pending-overlay exclusion scan. Cache the two point-lookup statements and replace Linux root-validation component walks with one `openat2` rooted at `/`, retaining no-symlink resolution and all before/after root mount/device/inode/owner checks. Root path parsing stays O(depth), descriptor resolution becomes O(1) syscalls; each validation holds at most two transient descriptors. No bodies are added to tier storage and no log sync or FULL projection commit is removed. | failure | effect / detection | recovery | gate | |---|---|---|---| | ancestor becomes a symlink | no descriptor returned; no-symlink resolution fails | restore the path and restart | existing ancestor-symlink reader regression | | root is replaced or remounted | existing identity comparison refuses the read | offline repair and restart | pinned-reader replacement regression | | parent component supplied | reject before resolution, preserving current input rules | use a plain root path | root-path regression | | interrupted identity append / projection | durable log and FULL commit rules unchanged | existing replay and scan recovery | notes suite and real-binary crash tests | Re-run fsio, notes, crash and fmt/clippy gates on dev-01; build there, verify SHA-256 through control and perf hosts, then collect three alternating cold pairs on perf-01 and one designated warm pair on perf-02 under the shared locks. Nothing merged. ## Paged identity projection and bounded background I/O (#761 C) Stacked on B #791. Identity, folded-path, child, and descendant lookups use SQLite B-trees. `ids.jsonl` remains the committed rollback/recovery log; its identity and length validate the projection on reopen. ### Cold-index follow-up plan and dev-01 profile **Cause.** On C head `9c8c2873`, `identify_many` awaited a durable write after each 50-note batch. At 100k notes that made about 2,000 sequential writer transactions, preventing the writer's eight-command queue from grouping scan commits. A server-only dev-01 `strace` profile counted 6,569 `fsync`/`fdatasync` calls: 2,000 on `ids.jsonl`, 2,004 on its directory, 2,295 on `ids.db-wal`, 144 on `ids.db`, 98 on `meta.db`, 16 on `history.db`, and 12 other syncs. The perf harness reported 2,253 MB physically written for that traced cold run; the instrumented `cold.index` time was 920 s, so its elapsed time is diagnostic only. The final files were 8.8 MB `ids.jsonl`, 35.2 MB `ids.db`, and 4.5 MB `ids.db-wal`. The scan reads note bodies but its write paths are the identity log and SQLite index/metadata/history stores. **Fix.** Cold discovery now uses a scan-only limit of 400 identities per durable append and `synchronous=FULL` projection transaction. Ordinary `put_many` remains capped at 50. Staging clears up to the same group size of stale overlay rows, so the disk-backed pending table remains O(400). At 100k notes this reduces scan groups from about 2,000 to 250; discovery and log replay remain O(N). The identity log sync, directory sync, and FULL projection commit are retained. | Failure point | Effect | Detection | Recovery | Regression evidence | |---|---|---|---|---| | Crash before the current group's log sync | Up to 400 derived identities may be absent or partly appended; note files are untouched | Startup scan finds source notes missing from the identity index; the loader skips incomplete JSONL records | Append repairs a partial trailing line; the scan rebuilds missing identities from files | `r2_scan_recovers_after_restart_between_identity_groups` | | Crash after log sync but before projection commit | The complete group is in `ids.jsonl`; SQLite projection may lag | Projection length or identity does not match the log on open | Replay the durable log into SQLite | `scan_batch_of_400_is_durable_and_replayed` | | Crash during projection commit | SQLite WAL recovers; the durable log remains authoritative | Projection metadata validation on open | Rebuild the projection from `ids.jsonl` if needed | `scan_batch_of_400_is_durable_and_replayed` | | Repeated scan groups | Stale pending rows could accumulate in TEMP storage | Overlay row count exceeds one group | Each scan staging transaction clears up to 400 stale rows; normal staging remains capped at 50 | `scan_batches_clear_the_previous_staged_group` | The scan still reports incomplete until discovery finishes. No note file is modified. The event batch remains 1,000 records. The dev-01 trace is diagnostic, not a performance qualification. **Gates.** Run the notes crate suite on dev-01. On perf VMs, alternate C/dev samples under `~/bin/perf-lock` using the #issuecomment-72012 harness options; collect three cold and five warm 100k runs and report `cold.index`, `open.note`, and `idle.rss`. Require `cold.index` p95 at most 60 s and at most 1.25x dev. Report page-cache drop availability. Keep C idle RSS at or below the prior C p95 of 107.668 MB; the existing 100 MB RSS budget remains tracked separately. ### Round-2 design and bounds - Requests reserve one of 64 semaphore permits with `try_acquire_owned()` before staging identities or changing create/rename/delete files and history. The transport cannot accumulate more than 64 admitted commands, including in-flight commands. Admission never waits. Saturation/closed writers return `NotSavedYet` (HTTP 503, retry). - A dedicated writer connection performs log append/fsync and projection commit before acknowledging. Callers await outside the Home index guard with a five-second deadline. Up to eight queued commands share a commit, with at most 50 rows per command. Reservations do not retain a channel sender, so a paused, unsent request cannot keep shutdown alive. - A same-ID create retry checks the pending sequence against the committed projection watermark. It returns 503 while the earlier identity is uncommitted, including after the first request timed out. - Compaction runs only on that background writer. A pinned read transaction streams 50 snapshot rows per step, then copies at most 64 KiB of the committed log tail per step. It serves at most eight queued commands between steps. Publication atomically replaces `ids.jsonl`; it never rebuilds the request connection's projection. - Cold discovery stages and acknowledges identities in groups of at most 400 through a scan-only API; ordinary identity writes remain at 50. A bounded pending page resolves duplicate embedded IDs before commit. The existing 10k task cold-fill test, `r2_scan_batches_keep_duplicate_embedded_ids_distinct_across_pages`, and the new restart regression cover completion, identity preservation, and interrupted discovery. - Descendant queries use keyset pages of at most 50 rows. Watcher removals and rename matching retain one page. Contact linking also pages immediate children and retains at most two candidates per requested name. - Scratch is never committed state: only `ids.jsonl` plus matching projection metadata is authoritative. Startup takes the exclusive index ownership lock, then removes reserved UUID scratch names, including interrupted legacy atomic-write scratch. Unrelated files remain untouched. A live owner prevents cleanup of active snapshots. - SQLite cache allowances remain capped at 128 Homes × (128 KiB reader + 128 KiB TEMP + 128 KiB writer + 128 KiB snapshot) = 64 MiB. Pending rows use a disk-backed TEMP overlay; only sequences newer than the committed watermark are visible. Cleanup is capped at 50 stale rows for ordinary staging and 400 for one cold-scan group. Exact lookups are O(log n); descendant/child pages are O(log n + 50). Replay and complete compaction remain O(entries), with bounded compaction steps outside request locks. Initial projection replay occurs in the existing blocking Home-open worker. ### Review findings and failure recovery | Finding / failure | Result and recovery | Regression evidence | |---|---|---| | P1: blocking full queue | Immediate 503/retry before rejected identities or create files are staged; another Home saves on the same runtime | `r2_full_queue_never_waits_for_the_writer_or_stages_rejected_ids`, `r2_saturated_identity_queue_refuses_create_without_blocking_another_home` | | P1: inline compaction | Background snapshot creation failure cannot fail a successfully admitted append; bounded steps preserve concurrent changes/deletions | `r2_compaction_failure_does_not_fail_an_admitted_append`, `r2_compaction_steps_keep_concurrent_updates_and_deletions_on_reopen` (50k rows) | | P2: unbounded descendants | Pages contain at most 50 rows; watcher processes all pages | `r2_descendant_queries_never_materialize_a_wide_subtree` (50k), `r2_watcher_removes_every_descendant_across_pages`, `r2_contact_candidates_page_beyond_fifty_and_bound_ambiguous_names` | | P2: orphan snapshots | Drop removes scratch; startup removes interrupted/replacement-failure orphans only after exclusive ownership | `r2_startup_removes_only_uncommitted_compaction_scratch`, `r2_replacement_error_orphans_are_recovered_only_after_owner_exits` | | Writer stalls / exits | Async acknowledgement expires after five seconds or reports disconnect as 503/retry; fix disk and restart if writer exited | `r2_acknowledgement_has_a_deadline_and_another_writer_keeps_saving`, existing failed-log-write regression | | Create retries before an earlier identity commit | 503 until the committed watermark advances; then the same identity succeeds | `r2_create_retry_cannot_acknowledge_an_uncommitted_identity` failed before the guard, passed after | | Shutdown with an unsent reservation | Join buffered writer work without waiting for the paused request; late staging is refused | `r2_shutdown_does_not_wait_for_an_unsent_admission`, `a_create_that_outlives_shutdown_writes_nothing` | The four original regressions (queue, compaction failure, 50k descendant cap, orphan startup cleanup) all failed on the pre-fix C head `cb8ef0b0`. The map functional model waits for actual disk acknowledgement rather than applying the request deadline. The anchor refusal fixture waits for save-time title normalization before its whole-note equality snapshot; the previous full run captured only that title property racing the snapshot, with unchanged block text. Both original assertions remain. Restart fixtures now release the old index owner without settling unsaved rooms; the crash-recovery assertions remain in place. ### Validation Head: `9c8c287357240912daf895c4166bf00f2212ce4a`. **PASS at `9c8c287357240912daf895c4166bf00f2212ce4a`**: fmt, strict clippy, and the full workspace suite; **1,205 passed, zero failed, 20 ignored**, exit 0. Run sequentially through Nix/slot on dev-01 with `RUST_TEST_THREADS=2`. Evidence: `~/tier-c-r2-final6.{log,exit}`. D's exact-head gate also passed: 1,208 passed, zero failed, 20 ignored (`~/tier-d-r2-final6.{log,exit}`). The final-head release rebuild passed; runtime hashes match the prepared perf binaries. ```sh cargo fmt --all --check cargo clippy -j4 --workspace --all-targets --locked -- -D warnings RUST_TEST_THREADS=2 cargo test -j4 --workspace --locked --no-fail-fast -- --nocapture ``` Final head `9c8c287357240912daf895c4166bf00f2212ce4a` built successfully on dev-01. The final-head rebuild produced the same runtime binaries as `9e15ece7`; the only later change is a test fixture barrier. SHA-256 matched on dev-01, control, and perf-01: - server: `0a541a0ba472631fe53872fbdce85f6e93663188144434c1b2e24f2bf15c7ea5` - harness: `081e036a464cb95bc13f67732e87079437874155f3c5bf9933ebc7f66de5b297` **Final-head performance qualification is blocked.** Both perf VMs are occupied by another campaign using `/var/lock/calternal-perf.lock`, which `claude` cannot open. The attempted final-head run under the documented `~/bin/perf-lock` was stopped after detecting the other campaign. No results from that attempt are treated as qualification. No other processes were stopped. Earlier `48ae327e` measurements (3.218 ms open.note p95; 89.293 MB idle RSS) remain historical evidence, not isolated final-head qualification. The outstanding command, on an idle perf VM under its exclusive measurement lock: ```sh calternal-perf --bin "$PWD/bin/calternal" --work "$PWD/work" --sizes 100k --runs 3 --cold-runs 1 --idle-secs 60 --budgets "$PWD/budgets.json" --gate --json "$PWD/report.json" --md "$PWD/report.md" ``` The budget file contains only `open.note@100000.p95=15` and `idle.rss@100000.p95=100`. Prepared binaries/fixture/run script: perf-01 `~/tier-c-perf-9e15/`. Aborted measurement log: `~/tier-c-r2-perf-final4.log`. The attempted shared-lock acquisition returned permission denied (`~/tier-c-r2-perf-final5.{log,exit}`, exit 66). No measurement remains running. ### Remaining limits A five-second timeout does not cancel an admitted disk write; the client retries through existing idempotent operations. A stopped writer requires disk repair/restart. Cache-lease exhaustion refuses a new Home; fair eviction is future work. These changes do not qualify FO1, online tier movement, history-heavy 100k-edit opens, or power-loss protocols. Stack: [A #790](https://git.kayg.org/kayg/calternal/pulls/790) → [B #791](https://git.kayg.org/kayg/calternal/pulls/791) → [C #792](https://git.kayg.org/kayg/calternal/pulls/792) → [D #793](https://git.kayg.org/kayg/calternal/pulls/793). Nothing merged.
Author
Owner

Independent review, round 2

Verdict: fix first

P1 — synchronous queue backpressure and compaction block request threads

  • crates/notes/src/index.rs:426,430,444; crates/notes/src/home.rs:296
  • Scenario: Index::append calls snapshot() inline at the threshold, traversing and serializing the projection and fsyncing it before returning. When the 64-message sync_channel fills, send() also blocks. home.index() holds a std::sync::MutexGuard during this synchronous call, and put() is used by async room/action paths. A slow disk or large Home can occupy a Tokio worker and hold the Home index lock for an O(entries) interval. The tests assert structure and SQLite VM work, not runtime contention. This conflicts with ADR 0014's no-job-blocks-another rule; the existing open.note and 100k Home RSS budgets have no measurement here.
  • Fix: move snapshot, append, and fsync to bounded background I/O with async admission outside the index guard, while keeping acknowledgement after fsync and projection. Add a deterministic full-queue/slow-writer test proving unrelated saves progress, then run the established 100k Home perf gates (open.note p95 15 ms; idle.rss@100000 p95 100 MB).

P2 — descendant removal materializes every path

  • crates/notes/src/index.rs:244-255; used by crates/notes/src/watch.rs:303-318
  • Scenario: removing a folder with many notes makes under() collect every descendant into one Vec; reconciliation then builds gone_ids and another filtered Vec. A wide subtree can spike RSS even though children() is paginated.
  • Fix: add a cursor-bounded descendant API and reconcile in batches; cover a 50k or 100k subtree.

P2 — interrupted compaction leaves orphan snapshots

  • crates/notes/src/index.rs:402-418,547-580
  • Scenario: snapshot() durably writes ids.compact-<uuid> before sending Cmd::Compact. A process kill while that send is blocked, before the writer removes the file, or a replacement error that returns before line 580 leaves a full extra log. Index::load does not enumerate or clean these files, so repeated failures can exhaust state storage and prevent later writes.
  • Fix: manage compact snapshots as recoverable scratch files and clean orphans at startup under the index ownership lock. Test interruption and replacement failure between snapshot creation and removal.

No P0 or P3 findings.

## Independent review, round 2 **Verdict: fix first** ### P1 — synchronous queue backpressure and compaction block request threads - `crates/notes/src/index.rs:426,430,444`; `crates/notes/src/home.rs:296` - Scenario: `Index::append` calls `snapshot()` inline at the threshold, traversing and serializing the projection and fsyncing it before returning. When the 64-message `sync_channel` fills, `send()` also blocks. `home.index()` holds a `std::sync::MutexGuard` during this synchronous call, and `put()` is used by async room/action paths. A slow disk or large Home can occupy a Tokio worker and hold the Home index lock for an O(entries) interval. The tests assert structure and SQLite VM work, not runtime contention. This conflicts with ADR 0014's no-job-blocks-another rule; the existing `open.note` and 100k Home RSS budgets have no measurement here. - Fix: move snapshot, append, and fsync to bounded background I/O with async admission outside the index guard, while keeping acknowledgement after fsync and projection. Add a deterministic full-queue/slow-writer test proving unrelated saves progress, then run the established 100k Home perf gates (`open.note` p95 15 ms; `idle.rss@100000` p95 100 MB). ### P2 — descendant removal materializes every path - `crates/notes/src/index.rs:244-255`; used by `crates/notes/src/watch.rs:303-318` - Scenario: removing a folder with many notes makes `under()` collect every descendant into one `Vec`; reconciliation then builds `gone_ids` and another filtered `Vec`. A wide subtree can spike RSS even though `children()` is paginated. - Fix: add a cursor-bounded descendant API and reconcile in batches; cover a 50k or 100k subtree. ### P2 — interrupted compaction leaves orphan snapshots - `crates/notes/src/index.rs:402-418,547-580` - Scenario: `snapshot()` durably writes `ids.compact-<uuid>` before sending `Cmd::Compact`. A process kill while that send is blocked, before the writer removes the file, or a replacement error that returns before line 580 leaves a full extra log. `Index::load` does not enumerate or clean these files, so repeated failures can exhaust state storage and prevent later writes. - Fix: manage compact snapshots as recoverable scratch files and clean orphans at startup under the index ownership lock. Test interruption and replacement failure between snapshot creation and removal. No P0 or P3 findings.
Author
Owner

Final round-2 fix plan

Keep the existing SQLite projection and rollback log. Queue admission reserves one of 64 slots without blocking, before staging; acknowledgement waits asynchronously with a 5-second deadline and returns retryable NotSavedYet (503) on saturation, writer failure or timeout. No request builds a snapshot. Request mutations stay bounded; the writer uses its own SQLite connection so disk fsync never holds the Home index connection mutex.

Compaction runs on the writer in steps of at most 50 rows or 64 KiB, servicing queued writes between steps. A separate read transaction pins the committed projection; after writing those rows, stream the original log tail from the snapshot boundary, then fsync and rename the completed scratch log. Update projection identity/length only after publication; startup replays the authoritative log if metadata does not match. No O(entries) projection rebuild during compaction. Memory is O(50 rows + 64 KiB + 64 bounded commands); total work remains O(entries + concurrent log bytes).

Descendant enumeration uses 50-row keyset pages; watcher reconciliation consumes pages without accumulating subtree-sized path/id vectors. Startup takes an exclusive identity-log ownership lock before deleting only named compaction scratch files. This log format has no committed manifest referencing scratch paths: ids.jsonl is the sole committed log, and the projection stores its identity/length.

Failure Effect / detection Recovery Regression
Full queue / stalled writer Immediate admission refusal or bounded async wait; 503/retry, no successful durability ack Retry after writer catches up deterministic full queue plus unrelated-save progress
Compaction overlaps writes Stable snapshot plus streamed committed tail Continue bounded steps; restart rebuilds from committed log writes/reopen/deletions during incremental compaction
Large removed subtree At most 50 descendant rows per page Reconcile batches and yield 50k descendant pagination and watcher coverage
Kill/error after scratch creation or replacement Scratch is never authoritative; log identity detects incomplete projection update Ownership-locked startup deletes orphans and replays if needed interruption and replacement-error scratch recovery

Gates: fmt, clippy -D warnings and full workspace for C and rebased D; targeted red/green regressions. Build release tools on a build VM and attempt the established 100k open.note / idle.rss gates on a locked perf VM. No merge or production operation.

## Final round-2 fix plan Keep the existing SQLite projection and rollback log. Queue admission reserves one of 64 slots without blocking, before staging; acknowledgement waits asynchronously with a 5-second deadline and returns retryable NotSavedYet (503) on saturation, writer failure or timeout. No request builds a snapshot. Request mutations stay bounded; the writer uses its own SQLite connection so disk fsync never holds the Home index connection mutex. Compaction runs on the writer in steps of at most 50 rows or 64 KiB, servicing queued writes between steps. A separate read transaction pins the committed projection; after writing those rows, stream the original log tail from the snapshot boundary, then fsync and rename the completed scratch log. Update projection identity/length only after publication; startup replays the authoritative log if metadata does not match. No O(entries) projection rebuild during compaction. Memory is O(50 rows + 64 KiB + 64 bounded commands); total work remains O(entries + concurrent log bytes). Descendant enumeration uses 50-row keyset pages; watcher reconciliation consumes pages without accumulating subtree-sized path/id vectors. Startup takes an exclusive identity-log ownership lock before deleting only named compaction scratch files. This log format has no committed manifest referencing scratch paths: ids.jsonl is the sole committed log, and the projection stores its identity/length. | Failure | Effect / detection | Recovery | Regression | |---|---|---|---| | Full queue / stalled writer | Immediate admission refusal or bounded async wait; 503/retry, no successful durability ack | Retry after writer catches up | deterministic full queue plus unrelated-save progress | | Compaction overlaps writes | Stable snapshot plus streamed committed tail | Continue bounded steps; restart rebuilds from committed log | writes/reopen/deletions during incremental compaction | | Large removed subtree | At most 50 descendant rows per page | Reconcile batches and yield | 50k descendant pagination and watcher coverage | | Kill/error after scratch creation or replacement | Scratch is never authoritative; log identity detects incomplete projection update | Ownership-locked startup deletes orphans and replays if needed | interruption and replacement-error scratch recovery | Gates: fmt, clippy -D warnings and full workspace for C and rebased D; targeted red/green regressions. Build release tools on a build VM and attempt the established 100k open.note / idle.rss gates on a locked perf VM. No merge or production operation.
Author
Owner

Round-2 review fixes implemented and pushed. C 9c8c287357240912daf895c4166bf00f2212ce4a; D rebased unchanged at 659b0f4e420f6272e801ea78eba8fe10d66c1ca5. Final-head fmt, strict clippy and full workspace gates PASS on both. Final performance qualification remains blocked. Nothing merged.

Finding / gate Status Evidence
P1: request-thread queue backpressure Fixed: 64 bounded permits, immediate admission, async five-second acknowledgement, 503/retry full-queue, saturated-create, independent-writer and shutdown regressions PASS
P1: inline O(entries) compaction Fixed: background connection; 50 rows / 64 KiB per step; at most eight commands between steps failure isolation and concurrent 50k snapshot/reopen regressions PASS
P2: unbounded descendants Fixed: keyset pages at most 50, watcher batches and bounded contact candidates 50k traversal, multi-page deletion and contact paging regressions PASS
P2: orphan snapshots Fixed: exclusive ownership before reserved scratch cleanup interruption, legacy scratch and replacement-error recovery regressions PASS
Timed-out create retry Fixed: return 503 until the committed watermark covers the identity deterministic stalled-writer test failed before the guard, PASS after; same-ID retry succeeds after commit
Cold-fill batching Fixed: scan identities staged/acknowledged in pages of 50 10k task cold-fill, duplicate IDs across pages and mid-scan regressions PASS
fmt / clippy -D warnings PASS on both final heads Nix + slot, dev-01
cargo test --workspace C: 1,205 passed; D: 1,208 passed; zero failed, 20 ignored each ~/tier-{c,d}-r2-final6.{log,exit}, exit 0; two test threads, sequential suites
100k open.note p95 at most 15 ms / idle RSS at most 100 MB BLOCKED: final-head isolated run pending both perf hosts occupied by another campaign; its shared lock is inaccessible to claude; overlapping attempt stopped

The four original queue/compaction/descendant/orphan regressions failed on pre-fix C cb8ef0b0. Both final-head suites retain and pass them. The map functional model now waits for the actual disk acknowledgement; the separate deadline test still checks production 503 behavior. The anchor refusal fixture now waits for save-time title normalization before comparing the whole note, preserving the original equality assertion. Its captured failure was only the automatic title property racing the snapshot; all six anchor tests and both final suites pass after the save barrier.

The final release build passed. Prepared perf binaries match the final head by SHA-256; the fixture-only final commit does not change runtime code. Earlier performance numbers remain historical evidence, not isolated final-head qualification. See the PR body for exact commands, hashes, bounds and remaining limits. D's two patches are unchanged by range-diff. No measurement or PR watcher remains active.

Round-2 review fixes implemented and pushed. C `9c8c287357240912daf895c4166bf00f2212ce4a`; D rebased unchanged at `659b0f4e420f6272e801ea78eba8fe10d66c1ca5`. **Final-head fmt, strict clippy and full workspace gates PASS on both. Final performance qualification remains blocked.** Nothing merged. | Finding / gate | Status | Evidence | |---|---|---| | P1: request-thread queue backpressure | Fixed: 64 bounded permits, immediate admission, async five-second acknowledgement, 503/retry | full-queue, saturated-create, independent-writer and shutdown regressions PASS | | P1: inline O(entries) compaction | Fixed: background connection; 50 rows / 64 KiB per step; at most eight commands between steps | failure isolation and concurrent 50k snapshot/reopen regressions PASS | | P2: unbounded descendants | Fixed: keyset pages at most 50, watcher batches and bounded contact candidates | 50k traversal, multi-page deletion and contact paging regressions PASS | | P2: orphan snapshots | Fixed: exclusive ownership before reserved scratch cleanup | interruption, legacy scratch and replacement-error recovery regressions PASS | | Timed-out create retry | Fixed: return 503 until the committed watermark covers the identity | deterministic stalled-writer test failed before the guard, PASS after; same-ID retry succeeds after commit | | Cold-fill batching | Fixed: scan identities staged/acknowledged in pages of 50 | 10k task cold-fill, duplicate IDs across pages and mid-scan regressions PASS | | fmt / clippy `-D warnings` | PASS on both final heads | Nix + slot, dev-01 | | `cargo test --workspace` | C: **1,205 passed**; D: **1,208 passed**; zero failed, 20 ignored each | `~/tier-{c,d}-r2-final6.{log,exit}`, exit 0; two test threads, sequential suites | | 100k open.note p95 at most 15 ms / idle RSS at most 100 MB | **BLOCKED: final-head isolated run pending** | both perf hosts occupied by another campaign; its shared lock is inaccessible to `claude`; overlapping attempt stopped | The four original queue/compaction/descendant/orphan regressions failed on pre-fix C `cb8ef0b0`. Both final-head suites retain and pass them. The map functional model now waits for the actual disk acknowledgement; the separate deadline test still checks production 503 behavior. The anchor refusal fixture now waits for save-time title normalization before comparing the whole note, preserving the original equality assertion. Its captured failure was only the automatic title property racing the snapshot; all six anchor tests and both final suites pass after the save barrier. The final release build passed. Prepared perf binaries match the final head by SHA-256; the fixture-only final commit does not change runtime code. Earlier performance numbers remain historical evidence, not isolated final-head qualification. See the PR body for exact commands, hashes, bounds and remaining limits. D's two patches are unchanged by range-diff. No measurement or PR watcher remains active.
Author
Owner

Independent review, round 3 (final)

Reviewed C at 9c8c287357240912daf895c4166bf00f2212ce4a, round-2 comment 71074 and fix status 71522. Verdict: ready for the reviewed code scope; the final 100k performance gate remains open and withholds performance/cutover qualification.

Round-2 item Final status Does the test demonstrate it?
P1: synchronous queue backpressure Resolved; no regression found. Admission uses an immediate permit attempt before staging, with 64 permits retained through writer processing. Acknowledgement is asynchronous, with a five-second deadline and retryable 503. Yes. r2_full_queue_never_waits_for_the_writer_or_stages_rejected_ids stalls the writer, exceeds capacity and checks prompt return without rejected identities. r2_saturated_identity_queue_refuses_create_without_blocking_another_home checks refusal before file creation and progress in another Home. The deadline/independent-writer test also passes.
P1: O(entries) compaction on the request path Resolved; no regression found. Snapshot creation, log writes/fsync and compaction run on the writer's connections. Snapshot steps contain at most 50 rows or 64 KiB; the writer services at most eight commands between steps. No request builds the snapshot or rebuilds the committed projection during publication. Yes for the functional/bounded-step claim. r2_compaction_failure_does_not_fail_an_admitted_append isolates snapshot creation failure. r2_compaction_steps_keep_concurrent_updates_and_deletions_on_reopen uses 50k rows, requires at least 1,000 steps and checks updated/deleted/new identities after reopen. These tests do not establish the latency/RSS budgets.
P2: unbounded descendant query/removal Resolved; no regression found. Keyset pages contain at most 50 rows; watcher deletion/rename matching consumes pages. Contact candidate lookup pages children and retains at most two candidates per requested name. Yes. r2_descendant_queries_never_materialize_a_wide_subtree checks all 50k descendants with a per-page cap. r2_watcher_removes_every_descendant_across_pages verifies 125 removals and the unrelated survivor. The contact regression reaches beyond the first 50 children and checks bounded ambiguity.
P2: orphan compaction snapshots Resolved; no regression found. Startup takes exclusive ownership before removing reserved UUID scratch names. Scratch is not authoritative; the published log identity/length guards projection reuse. Yes. r2_startup_removes_only_uncommitted_compaction_scratch covers current/legacy scratch and preserves unrelated files. r2_replacement_error_orphans_are_recovered_only_after_owner_exits leaves a real replacement/removal-error orphan, refuses a second live owner, then cleans it on reopen while preserving committed state.
Inherited A/B findings Resolved, as assessed in the final #790/#791 reviews. Their nine r2_ regressions pass in the independent downstream run.

New findings, P0–P3: none. I checked writer publication/tail handling, the pending overlay/watermark, admission before create mutations, and the changed lifecycle call sites. The new same-ID create retry regression prevents success while identity is uncommitted; shutdown with an unsent reservation and duplicate embedded IDs across discovery pages also pass.

Independent verification on dev-01 at downstream D 659b0f4e, inside Nix through ~/bin/slot:

cargo test -j4 --workspace --locked r2_ -- --test-threads=2

22 passed, zero failed, including 13 C regressions across notes and contacts-actions. Evidence: ~/tier-r3-independent.{log,exit}, exit 0. I inspected C's exact-head ~/tier-c-r2-final6.log and exit 0. The full workspace suite was not independently rerun. No code pushed or production access.

Open gate, not a finding: final-head isolated 100k qualification remains pending because the perf VMs were occupied. Required results are open.note@100000 p95 ≤15 ms and idle.rss@100000 p95 ≤100 MB under the documented exclusive perf lock. Historical measurements and bounded structures do not satisfy this gate. Final-head macOS, real disk/power-cut and broader history/FO1 campaigns also remain unqualified as documented.

# Independent review, round 3 (final) Reviewed C at `9c8c287357240912daf895c4166bf00f2212ce4a`, round-2 comment 71074 and fix status 71522. **Verdict: ready** for the reviewed code scope; the final 100k performance gate remains open and withholds performance/cutover qualification. | Round-2 item | Final status | Does the test demonstrate it? | |---|---|---| | P1: synchronous queue backpressure | **Resolved; no regression found.** Admission uses an immediate permit attempt before staging, with 64 permits retained through writer processing. Acknowledgement is asynchronous, with a five-second deadline and retryable 503. | Yes. `r2_full_queue_never_waits_for_the_writer_or_stages_rejected_ids` stalls the writer, exceeds capacity and checks prompt return without rejected identities. `r2_saturated_identity_queue_refuses_create_without_blocking_another_home` checks refusal before file creation and progress in another Home. The deadline/independent-writer test also passes. | | P1: O(entries) compaction on the request path | **Resolved; no regression found.** Snapshot creation, log writes/fsync and compaction run on the writer's connections. Snapshot steps contain at most 50 rows or 64 KiB; the writer services at most eight commands between steps. No request builds the snapshot or rebuilds the committed projection during publication. | Yes for the functional/bounded-step claim. `r2_compaction_failure_does_not_fail_an_admitted_append` isolates snapshot creation failure. `r2_compaction_steps_keep_concurrent_updates_and_deletions_on_reopen` uses 50k rows, requires at least 1,000 steps and checks updated/deleted/new identities after reopen. These tests do not establish the latency/RSS budgets. | | P2: unbounded descendant query/removal | **Resolved; no regression found.** Keyset pages contain at most 50 rows; watcher deletion/rename matching consumes pages. Contact candidate lookup pages children and retains at most two candidates per requested name. | Yes. `r2_descendant_queries_never_materialize_a_wide_subtree` checks all 50k descendants with a per-page cap. `r2_watcher_removes_every_descendant_across_pages` verifies 125 removals and the unrelated survivor. The contact regression reaches beyond the first 50 children and checks bounded ambiguity. | | P2: orphan compaction snapshots | **Resolved; no regression found.** Startup takes exclusive ownership before removing reserved UUID scratch names. Scratch is not authoritative; the published log identity/length guards projection reuse. | Yes. `r2_startup_removes_only_uncommitted_compaction_scratch` covers current/legacy scratch and preserves unrelated files. `r2_replacement_error_orphans_are_recovered_only_after_owner_exits` leaves a real replacement/removal-error orphan, refuses a second live owner, then cleans it on reopen while preserving committed state. | | Inherited A/B findings | **Resolved**, as assessed in the final #790/#791 reviews. | Their nine `r2_` regressions pass in the independent downstream run. | New findings, P0–P3: **none**. I checked writer publication/tail handling, the pending overlay/watermark, admission before create mutations, and the changed lifecycle call sites. The new same-ID create retry regression prevents success while identity is uncommitted; shutdown with an unsent reservation and duplicate embedded IDs across discovery pages also pass. Independent verification on dev-01 at downstream D `659b0f4e`, inside Nix through `~/bin/slot`: ```sh cargo test -j4 --workspace --locked r2_ -- --test-threads=2 ``` **22 passed, zero failed**, including 13 C regressions across notes and contacts-actions. Evidence: `~/tier-r3-independent.{log,exit}`, exit 0. I inspected C's exact-head `~/tier-c-r2-final6.log` and exit 0. The full workspace suite was not independently rerun. No code pushed or production access. **Open gate, not a finding:** final-head isolated 100k qualification remains pending because the perf VMs were occupied. Required results are `open.note@100000` p95 ≤15 ms and `idle.rss@100000` p95 ≤100 MB under the documented exclusive perf lock. Historical measurements and bounded structures do not satisfy this gate. Final-head macOS, real disk/power-cut and broader history/FO1 campaigns also remain unqualified as documented.
Author
Owner

100k qualification results

C 9c8c287357240912daf895c4166bf00f2212ce4a was measured against dev baseline a55521f94ef7337f0793037ae2cb2c27266d5720 on perf-01 (x86_64, 4 CPUs). Release binaries were built on dev-01 through Nix and ~/bin/slot; SHA-256 manifests passed on dev-01, the control host, and perf-01.

The #792 gate protocol was run under the shared ~/bin/perf-lock: head-1, dev-1, head-2, dev-2, head-3, dev-3; seed 1; 60-second idle; one cold run in sample 1 and warm runs in samples 2–3. The per-sample harness invocation used --sizes 100k --runs 1 --cold-runs {1,0} --seed 1 --idle-secs 60 --budgets budgets.json.

Metric Budget Dev p50 / p95 C p50 / p95 Result
open.note@100000 p95 ≤ 15 ms 0.737 / 3.165 ms 1.078 / 4.029 ms Both pass
idle.rss@100000 p95 ≤ 100 MB 116.020 / 117.055 MB 104.539 / 107.668 MB Both fail

Aggregation follows crates/perf/src/stats.rs: open.note pools each run’s p50/p95 summaries; idle.rss is one value per run, so its p95 is the maximum of the three values. Noise was 207.5% for dev and 22.5% for C on open.note; the baseline exceeds the 25% noise band, so the relative latency delta is descriptive. RSS noise was 3.2% for dev and 6.8% for C.

The profiled pair was diagnostic, with no page-cache drop. At the recorded idle RSS point, smaps_rollup showed C at 103.37 MiB RSS (84.49 MiB Pss_Anon, 17.00 MiB Pss_File) and dev at 123.19 MiB (104.01 MiB Pss_Anon, 17.36 MiB Pss_File). After-search RSS was 108.44 MiB for C and 139.38 MiB for dev. Most of the measured RSS is anonymous memory; the exact allocation owner is not isolated.

The general #761 budget table also lists 100k cold.index p95 ≤ 60 s, though that metric is not in #792’s two-entry gate budget file. The gate run’s single cold observation was 68.256 s for C and 12.079 s for dev. A later diagnostic pair measured 48.063 s and 10.508 s, respectively. These samples do not establish a cold-index p95. The broader #761 three-cold/five-warm matrix was not run: cache drops were unavailable to the perf account, and the #792 gate command specifies one cold/three warm. strace/perf are not installed on perf-01.

The diagnostic /proc counters recorded 47.6 CPU-seconds and 1,747.8 MB written for C’s cold-index server, versus 12.5 CPU-seconds observed and 771.1 MB written for dev. The C scan awaits a durable identity write per 50-note batch, about 2,000 batches for 100k notes; each writer flush syncs the identity log and commits the synchronous=FULL SQLite projection. The baseline queues the scan’s identity writes for a combined flush. This is a likely contributor to the cold-index cost; no syscall breakdown was available.

No code was changed. The 50-row batch bound has durability and memory implications, so I left it for design review and re-qualification. The #792 RSS gate remains over budget on both binaries.

## 100k qualification results C `9c8c287357240912daf895c4166bf00f2212ce4a` was measured against dev baseline `a55521f94ef7337f0793037ae2cb2c27266d5720` on perf-01 (x86_64, 4 CPUs). Release binaries were built on dev-01 through Nix and `~/bin/slot`; SHA-256 manifests passed on dev-01, the control host, and perf-01. The #792 gate protocol was run under the shared `~/bin/perf-lock`: head-1, dev-1, head-2, dev-2, head-3, dev-3; seed 1; 60-second idle; one cold run in sample 1 and warm runs in samples 2–3. The per-sample harness invocation used `--sizes 100k --runs 1 --cold-runs {1,0} --seed 1 --idle-secs 60 --budgets budgets.json`. | Metric | Budget | Dev p50 / p95 | C p50 / p95 | Result | |---|---:|---:|---:|---| | `open.note@100000` | p95 ≤ 15 ms | 0.737 / 3.165 ms | 1.078 / 4.029 ms | Both pass | | `idle.rss@100000` | p95 ≤ 100 MB | 116.020 / 117.055 MB | 104.539 / 107.668 MB | Both fail | Aggregation follows `crates/perf/src/stats.rs`: `open.note` pools each run’s p50/p95 summaries; `idle.rss` is one value per run, so its p95 is the maximum of the three values. Noise was 207.5% for dev and 22.5% for C on `open.note`; the baseline exceeds the 25% noise band, so the relative latency delta is descriptive. RSS noise was 3.2% for dev and 6.8% for C. The profiled pair was diagnostic, with no page-cache drop. At the recorded idle RSS point, `smaps_rollup` showed C at 103.37 MiB RSS (84.49 MiB Pss_Anon, 17.00 MiB Pss_File) and dev at 123.19 MiB (104.01 MiB Pss_Anon, 17.36 MiB Pss_File). After-search RSS was 108.44 MiB for C and 139.38 MiB for dev. Most of the measured RSS is anonymous memory; the exact allocation owner is not isolated. The general #761 budget table also lists 100k `cold.index` p95 ≤ 60 s, though that metric is not in #792’s two-entry gate budget file. The gate run’s single cold observation was 68.256 s for C and 12.079 s for dev. A later diagnostic pair measured 48.063 s and 10.508 s, respectively. These samples do not establish a cold-index p95. The broader #761 three-cold/five-warm matrix was not run: cache drops were unavailable to the perf account, and the #792 gate command specifies one cold/three warm. `strace`/`perf` are not installed on perf-01. The diagnostic `/proc` counters recorded 47.6 CPU-seconds and 1,747.8 MB written for C’s cold-index server, versus 12.5 CPU-seconds observed and 771.1 MB written for dev. The C scan awaits a durable identity write per 50-note batch, about 2,000 batches for 100k notes; each writer flush syncs the identity log and commits the `synchronous=FULL` SQLite projection. The baseline queues the scan’s identity writes for a combined flush. This is a likely contributor to the cold-index cost; no syscall breakdown was available. No code was changed. The 50-row batch bound has durability and memory implications, so I left it for design review and re-qualification. The #792 RSS gate remains over budget on both binaries.
Author
Owner

100k qualification: fail on relative cold.index

C 02491f790009e88a97b372303baf7052c34084e5 versus dev a55521f94ef7337f0793037ae2cb2c27266d5720. completed the resumed 3 cold + 5 designated warm pairs, alternating C then dev, with each pair on the same VM. the eight existing reports were recovered and validated; eight remaining reports were collected. all 16 reports have zero scenario failures; both resumed runners exited 0.

metric / cohort dev p50 / p95 C p50 / p95 gate result
cold.index, 3 cold pairs, perf-01 11.305 / 11.452 s 40.185 / 41.254 s p95 ≤60 s and ≤1.25× dev (14.315 s) fail: 3.602× dev; absolute limit passes
open.note, 5 warm pairs, combined 0.743 / 2.989 ms 1.059 / 3.407 ms p95 ≤15 ms pass
idle.rss, 5 warm pairs, combined 99.129 / 103.914 MB 90.055 / 94.379 MB p95 no worse than dev pass; C 9.2% lower
open.note, perf-01, 3 warm pairs 0.743 / 2.989 ms 1.059 / 3.578 ms p95 ≤15 ms pass
idle.rss, perf-01, 3 warm pairs 100.848 / 103.914 MB 92.617 / 94.379 MB p95 no worse than dev pass
open.note, perf-02, 2 warm pairs 0.588 / 2.804 ms 1.017 / 3.332 ms p95 ≤15 ms pass
idle.rss, perf-02, 2 warm pairs 98.918 / 99.129 MB 85.867 / 86.707 MB p95 no worse than dev pass

aggregation follows crates/perf/src/stats.rs: nearest-rank p95 for single-value metrics (the maximum with 3 or 5 observations); median of per-run p95s for open.note, with nearest-rank/lower median for an even count. primary warm results use samples 4–8, matching the resumed runner's plan. cold samples 1–3 also execute warm scenarios as part of the referenced harness invocation; including those supplemental scenarios gives eight-run p95s of C/dev 3.332/3.002 ms for open.note and 94.652/108.227 MB for idle.rss, with the same verdict. C also passes the historical 100 MB RSS budget.

cold.index observations, C/dev in seconds: 40.154/11.452, 41.254/10.757, 40.185/11.305. cold noise was C 2.7%, dev 6.1%. combined warm open.note noise was C 39.5%, dev 58.1%, above the 25% band, so relative latency deltas are descriptive. every designated warm run's open.note p95 was below 15 ms (largest C 4.477 ms, dev 4.916 ms). RSS noise was C 9.5%, dev 6.2%.

execution and evidence

  • perf-01: retained cold samples 1–3 and warm sample 4, then completed warm samples 5–6; perf-02 completed warm samples 7–8. waited for the existing shared locks; resumed runners used ~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-resume/resume.sh ..., with RuntimeMaxSec=43200 (12 hours). both runners are now inactive and have released their locks.
  • binaries were built on dev-01 through Nix/slot. SHA-256 matches were verified against dev-01 release artifacts, through the control host, and on both perf VMs: C server 4ef83a60a21bbe1485c9d20f3e4f11699599ef44b736c0eda739fd45591f9696; dev server 90129ef4ec5838602eb781ea06f54c3077cbbe19f5a43e402366c3bd014a63b4; harness 2cf7df4da8fe381ae9d813512bfc10489f92835b9076f53e1d11018a147073fa.
  • page-cache drops remain unavailable. cold means first indexing of an unindexed Home, without an OS page-cache drop. both VMs report 4 CPUs, 7.7 GiB RAM and ext4 non-rotational disks; pairs stay on the same VM, and per-VM results are above.

exact per-sample command, preserving #issuecomment-72012 options:

"$PERF" --bin "$SERVER" --work "$Q/$label/work"   --sizes 100k --runs 1 --cold-runs "$cold" --seed 1   --idle-secs 60 --budgets "$Q/budgets.json"   --json "$Q/$label/sample-$sample.json"   --md "$Q/$label/sample-$sample.md"

cold=1 for samples 1–3; cold=0 for samples 4–8. budgets contain open.note@100000.p95=15 and idle.rss@100000.p95=100; the relative cold and RSS gates were evaluated from the table. perf-01 originals: ~/tier-c-qual-02491f79/; resumed evidence on each VM: ~/pr792-resume/{C,dev,logs,session.log,session.exit,SHA256SUMS}. control-host reports, aggregate JSON and pair CSV: /home/kayg/Developer/perf-results/pr792-02491f79/.

no code changed and nothing merged. qualification is withheld because cold.index exceeds the relative gate.

## 100k qualification: fail on relative cold.index C `02491f790009e88a97b372303baf7052c34084e5` versus dev `a55521f94ef7337f0793037ae2cb2c27266d5720`. completed the resumed **3 cold + 5 designated warm pairs**, alternating C then dev, with each pair on the same VM. the eight existing reports were recovered and validated; eight remaining reports were collected. all 16 reports have zero scenario failures; both resumed runners exited 0. | metric / cohort | dev p50 / p95 | C p50 / p95 | gate | result | |---|---:|---:|---|---| | cold.index, 3 cold pairs, perf-01 | 11.305 / 11.452 s | 40.185 / 41.254 s | p95 ≤60 s and ≤1.25× dev (14.315 s) | **fail: 3.602× dev**; absolute limit passes | | open.note, 5 warm pairs, combined | 0.743 / 2.989 ms | 1.059 / 3.407 ms | p95 ≤15 ms | pass | | idle.rss, 5 warm pairs, combined | 99.129 / 103.914 MB | 90.055 / 94.379 MB | p95 no worse than dev | pass; C 9.2% lower | | open.note, perf-01, 3 warm pairs | 0.743 / 2.989 ms | 1.059 / 3.578 ms | p95 ≤15 ms | pass | | idle.rss, perf-01, 3 warm pairs | 100.848 / 103.914 MB | 92.617 / 94.379 MB | p95 no worse than dev | pass | | open.note, perf-02, 2 warm pairs | 0.588 / 2.804 ms | 1.017 / 3.332 ms | p95 ≤15 ms | pass | | idle.rss, perf-02, 2 warm pairs | 98.918 / 99.129 MB | 85.867 / 86.707 MB | p95 no worse than dev | pass | aggregation follows `crates/perf/src/stats.rs`: nearest-rank p95 for single-value metrics (the maximum with 3 or 5 observations); median of per-run p95s for open.note, with nearest-rank/lower median for an even count. primary warm results use samples 4–8, matching the resumed runner's plan. cold samples 1–3 also execute warm scenarios as part of the referenced harness invocation; including those supplemental scenarios gives eight-run p95s of C/dev 3.332/3.002 ms for open.note and 94.652/108.227 MB for idle.rss, with the same verdict. C also passes the historical 100 MB RSS budget. cold.index observations, C/dev in seconds: 40.154/11.452, 41.254/10.757, 40.185/11.305. cold noise was C 2.7%, dev 6.1%. combined warm open.note noise was C 39.5%, dev 58.1%, above the 25% band, so relative latency deltas are descriptive. every designated warm run's open.note p95 was below 15 ms (largest C 4.477 ms, dev 4.916 ms). RSS noise was C 9.5%, dev 6.2%. ### execution and evidence - perf-01: retained cold samples 1–3 and warm sample 4, then completed warm samples 5–6; perf-02 completed warm samples 7–8. waited for the existing shared locks; resumed runners used `~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-resume/resume.sh ...`, with `RuntimeMaxSec=43200` (12 hours). both runners are now inactive and have released their locks. - binaries were built on dev-01 through Nix/slot. SHA-256 matches were verified against dev-01 release artifacts, through the control host, and on both perf VMs: C server `4ef83a60a21bbe1485c9d20f3e4f11699599ef44b736c0eda739fd45591f9696`; dev server `90129ef4ec5838602eb781ea06f54c3077cbbe19f5a43e402366c3bd014a63b4`; harness `2cf7df4da8fe381ae9d813512bfc10489f92835b9076f53e1d11018a147073fa`. - page-cache drops remain unavailable. cold means first indexing of an unindexed Home, without an OS page-cache drop. both VMs report 4 CPUs, 7.7 GiB RAM and ext4 non-rotational disks; pairs stay on the same VM, and per-VM results are above. exact per-sample command, preserving #issuecomment-72012 options: ```sh "$PERF" --bin "$SERVER" --work "$Q/$label/work" --sizes 100k --runs 1 --cold-runs "$cold" --seed 1 --idle-secs 60 --budgets "$Q/budgets.json" --json "$Q/$label/sample-$sample.json" --md "$Q/$label/sample-$sample.md" ``` `cold=1` for samples 1–3; `cold=0` for samples 4–8. budgets contain `open.note@100000.p95=15` and `idle.rss@100000.p95=100`; the relative cold and RSS gates were evaluated from the table. perf-01 originals: `~/tier-c-qual-02491f79/`; resumed evidence on each VM: `~/pr792-resume/{C,dev,logs,session.log,session.exit,SHA256SUMS}`. control-host reports, aggregate JSON and pair CSV: `/home/kayg/Developer/perf-results/pr792-02491f79/`. no code changed and nothing merged. qualification is withheld because cold.index exceeds the relative gate.
Author
Owner

cold-index resume: fixes checked, isolated qualification blocked

Head 558e78e93ae4fd5aa3d0400216573509f1ecbde5; baseline a55521f94ef7337f0793037ae2cb2c27266d5720. The final source tree is identical to 92a28435; the intervening 400-row experiment was measured and reverted. The ≤1.25× dev goal remains unqualified.

profile and retained fixes

  • A point lookup scanned the pending overlay: the regression recorded 32,033 SQLite VM steps. Correlated primary-key probes now keep both lookups below 200 steps. Reused prepared path/identity statements and a bounded scan-local ID map remove repeated preparation and linear duplicate searches.
  • Metadata discovery skips construction of an unused cleaned search body. Full search still constructs its own body.
  • Linux root validation uses one guarded openat2. Background stat/head/metadata reads use groups capped at 128 files, validate the root before and after each group, and return results only after final validation. File name, symlink and mount checks remain; request reads retain individual validation. Root replacement and batch-bound regressions pass.
  • Log fsync, durable acknowledgements and synchronous=FULL projection commits are retained. No crash-safety setting was relaxed.

Nix perf counters were denied by the host's perf_event_paranoid=3; no settings were changed. The original bounded strace sample recorded 172,523 openat and 198,639 close calls during repeated root walks. Full current-server strace diagnostics recorded 1,868,042 pwrite64 calls for C versus 304,202 for dev. These counts include the cold server's stores; file-level attribution remains open. Traced wall times are not qualification results.

A single-variable 400-row trial (df742b61) did not reduce write calls (1,876,854). It increased physical writes and took 47.447 s untraced / 332.665 s traced, versus the retained binary's fresh 25.500 s untraced / 222.424 s traced diagnostic. It was reverted in the final head. Both full trace reports and the trial/comparison reports have zero scenario failures.

Latest retained-binary dev-01 phase breakdown, seconds: stat 0.587, recovery 0, classify 0.657, read heads 0.804, identities 6.165, describe 12.531, events 0.701, sync metadata 2.609, save state 0.215. Identity projection and description remain the largest phases.

before / after

cohort C cold.index dev cold.index result
historical isolated 3-pair p95, 02491f79 (#issuecomment-72561) 41.254 s 11.452 s fail, 3.602×
inherited e0af7578, dev-01 single cold each 56.908 s 12.729 s diagnostic only
retained final source, latest dev-01 repeat 25.500 s not contemporaneously paired diagnostic only; 962.302 MB written
final isolated 3 cold pairs + 1 warm pair not collected not collected blocked by both shared perf locks

Dev-01's untraced dev observations ranged from 12.729 to 35.490 s under shared load. They cannot establish the relative gate. Current-head open.note and idle.rss warm qualification is also outstanding; historical warm results are not promoted to this head.

exact-head checks

Passed on dev-01 through Nix and ~/bin/slot, all exit 0:

cargo fmt --all --check
cargo test -j4 -p calternal-fsio -p calternal-notes --locked -- --test-threads=2
cargo test -j4 -p calternal --locked --test notes_crash -- --test-threads=1
cargo clippy -j4 -p calternal-fsio -p calternal-notes --all-targets --locked -- -D warnings
cargo build -j4 --release --locked -p calternal -p calternal-perf

43 fsio tests, 453 notes tests (6 ignored), and all 3 real-binary crash tests passed. The full workspace suite and a new independent review were not rerun.

qualification blocker and prepared inputs

Both /var/lock/calternal-perf.lock probes still timed out (124). The 759b matrix campaign holds the locks and advances through additional configurations without releasing them. Our queued runners collected no samples and were stopped; no other campaign's processes were stopped. No job or monitor of ours remains running. The throwaway dev-01 target/ and temporary comparison binary were removed after preserving reports and binaries.

Both perf VMs have the final inputs at ~/pr792-final-nov01/. Binaries were built on dev-01, streamed through the control host and SHA-256 checked at every hop; final rebuild hashes match the retained source's earlier binaries:

ddbfaaa6e276e298137e9650a18af997ac3979b6a394adcdf414a28b3acf63af  calternal-C
dea8a7f6935fdc2104d5c3d74e5de0380684879664a1b2faf3378b82daa19153  calternal-dev
3f1097517a13b57b047d5c4a4d9ed72069f9771ac2198b4dbdce8d0276742ca4  calternal-perf

The outstanding foreground command, on either free perf VM, runs all three alternating C/dev cold pairs and the designated warm pair on that same VM:

~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-final-nov01/run.sh 1 2 3 4

Each label/sample uses the #issuecomment-72561 invocation:

calternal-perf --bin "$bin" --work "$work" --sizes 100k --runs 1   --cold-runs "$cold" --seed 1 --idle-secs 60 --budgets budgets.json   --json "$json" --md "$md"

cold=1 for samples 1–3 and cold=0 for sample 4. Budgets remain open.note p95 ≤15 ms and idle.rss p95 ≤100 MB; compare RSS to dev as in the previous status. Cold p95 is the maximum of the three observations and must be ≤60 s and ≤1.25× dev. Cold means a fresh unindexed Home; OS page caches are not dropped.

Evidence on the control host: ~/Developer/perf-results/pr792-e0af7578/, including current-profile/, restored-evidence.tar.gz, exact-head gate logs, reports, binary manifests and provenance. No PR was merged.

## cold-index resume: fixes checked, isolated qualification blocked Head `558e78e93ae4fd5aa3d0400216573509f1ecbde5`; baseline `a55521f94ef7337f0793037ae2cb2c27266d5720`. The final source tree is identical to `92a28435`; the intervening 400-row experiment was measured and reverted. **The ≤1.25× dev goal remains unqualified.** ### profile and retained fixes - A point lookup scanned the pending overlay: the regression recorded 32,033 SQLite VM steps. Correlated primary-key probes now keep both lookups below 200 steps. Reused prepared path/identity statements and a bounded scan-local ID map remove repeated preparation and linear duplicate searches. - Metadata discovery skips construction of an unused cleaned search body. Full search still constructs its own body. - Linux root validation uses one guarded `openat2`. Background stat/head/metadata reads use groups capped at 128 files, validate the root before and after each group, and return results only after final validation. File name, symlink and mount checks remain; request reads retain individual validation. Root replacement and batch-bound regressions pass. - Log fsync, durable acknowledgements and `synchronous=FULL` projection commits are retained. No crash-safety setting was relaxed. Nix `perf` counters were denied by the host's `perf_event_paranoid=3`; no settings were changed. The original bounded strace sample recorded 172,523 `openat` and 198,639 `close` calls during repeated root walks. Full current-server strace diagnostics recorded **1,868,042 `pwrite64` calls for C versus 304,202 for dev**. These counts include the cold server's stores; file-level attribution remains open. Traced wall times are not qualification results. A single-variable 400-row trial (`df742b61`) did not reduce write calls (1,876,854). It increased physical writes and took 47.447 s untraced / 332.665 s traced, versus the retained binary's fresh 25.500 s untraced / 222.424 s traced diagnostic. It was reverted in the final head. Both full trace reports and the trial/comparison reports have zero scenario failures. Latest retained-binary dev-01 phase breakdown, seconds: stat **0.587**, recovery **0**, classify **0.657**, read heads **0.804**, identities **6.165**, describe **12.531**, events **0.701**, sync metadata **2.609**, save state **0.215**. Identity projection and description remain the largest phases. ### before / after | cohort | C cold.index | dev cold.index | result | |---|---:|---:|---| | historical isolated 3-pair p95, `02491f79` (#issuecomment-72561) | 41.254 s | 11.452 s | fail, 3.602× | | inherited `e0af7578`, dev-01 single cold each | 56.908 s | 12.729 s | diagnostic only | | retained final source, latest dev-01 repeat | 25.500 s | not contemporaneously paired | diagnostic only; 962.302 MB written | | final isolated 3 cold pairs + 1 warm pair | not collected | not collected | **blocked by both shared perf locks** | Dev-01's untraced dev observations ranged from 12.729 to 35.490 s under shared load. They cannot establish the relative gate. Current-head `open.note` and `idle.rss` warm qualification is also outstanding; historical warm results are not promoted to this head. ### exact-head checks Passed on dev-01 through Nix and `~/bin/slot`, all exit 0: ```sh cargo fmt --all --check cargo test -j4 -p calternal-fsio -p calternal-notes --locked -- --test-threads=2 cargo test -j4 -p calternal --locked --test notes_crash -- --test-threads=1 cargo clippy -j4 -p calternal-fsio -p calternal-notes --all-targets --locked -- -D warnings cargo build -j4 --release --locked -p calternal -p calternal-perf ``` 43 fsio tests, 453 notes tests (6 ignored), and all 3 real-binary crash tests passed. The full workspace suite and a new independent review were not rerun. ### qualification blocker and prepared inputs Both `/var/lock/calternal-perf.lock` probes still timed out (124). The `759b` matrix campaign holds the locks and advances through additional configurations without releasing them. Our queued runners collected no samples and were stopped; no other campaign's processes were stopped. **No job or monitor of ours remains running.** The throwaway dev-01 `target/` and temporary comparison binary were removed after preserving reports and binaries. Both perf VMs have the final inputs at `~/pr792-final-nov01/`. Binaries were built on dev-01, streamed through the control host and SHA-256 checked at every hop; final rebuild hashes match the retained source's earlier binaries: ```text ddbfaaa6e276e298137e9650a18af997ac3979b6a394adcdf414a28b3acf63af calternal-C dea8a7f6935fdc2104d5c3d74e5de0380684879664a1b2faf3378b82daa19153 calternal-dev 3f1097517a13b57b047d5c4a4d9ed72069f9771ac2198b4dbdce8d0276742ca4 calternal-perf ``` The outstanding foreground command, on either free perf VM, runs all three alternating C/dev cold pairs and the designated warm pair on that same VM: ```sh ~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-final-nov01/run.sh 1 2 3 4 ``` Each label/sample uses the #issuecomment-72561 invocation: ```sh calternal-perf --bin "$bin" --work "$work" --sizes 100k --runs 1 --cold-runs "$cold" --seed 1 --idle-secs 60 --budgets budgets.json --json "$json" --md "$md" ``` `cold=1` for samples 1–3 and `cold=0` for sample 4. Budgets remain open.note p95 ≤15 ms and idle.rss p95 ≤100 MB; compare RSS to dev as in the previous status. Cold p95 is the maximum of the three observations and must be ≤60 s and ≤1.25× dev. Cold means a fresh unindexed Home; OS page caches are not dropped. Evidence on the control host: `~/Developer/perf-results/pr792-e0af7578/`, including `current-profile/`, `restored-evidence.tar.gz`, exact-head gate logs, reports, binary manifests and provenance. No PR was merged.
Author
Owner

100k cold-index attribution and qualification — fab01191

C head: fab011917e9408f5301b839aab879f1e95a481aa; dev baseline: a55521f94ef7337f0793037ae2cb2c27266d5720.

Write attribution and fix

The completed pre-fix 100k diagnostic trace attributed the excess to SQLite temporary sorter files (/var/tmp/etilqs_*). In the file-attributed C trace, these 30 files took 1,492,369 pwrite64 calls; dev took 21,223, a difference of 1,471,146. C's calls were mostly 759,313 writes of 4,096 bytes and 732,978 writes of 4 bytes, plus 25 writes of 61,504 bytes and small tails. The full trace totals were 1,882,588 C / 308,790 dev, consistent with the earlier 1.87M / 304k signal.

The current exact-head trace is down to 369,170 C / 306,546 dev pwrite64 calls. It shows zero C sorter-temp writes; dev has 21,223. The fix already on this head (7a983946) sets PRAGMA temp_store=MEMORY for metadata cold-fill sorters. It does not change the metadata WAL sync setting; metadata_temp_tables_use_memory_without_changing_wal_sync asserts temp_store=2 and synchronous=1. Identity-log fsyncs, durable acknowledgements, and the identity projection's synchronous=FULL remain in place.

For describe, the scan reads bounded note heads to resolve IDs, then reads each full note once for list metadata. describe_list skips building the cleaned search body. This is a note-head read plus a full read, not two full-note parses; required title, tag and date extraction remains.

Perf qualification

Ran on perf-01, same VM for all pairs, C then dev, seed 1, no page-cache drops. Command was ~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-fab01191/run-perf.sh under RuntimeMaxSec=21600. Per sample the harness used --sizes 100k --runs 1 --cold-runs 1 for samples 1–3 and --cold-runs 0 for the designated warm sample 4, plus --seed 1 --idle-secs 60 --budgets budgets.json. Sample 4 includes the harness's unscored cold setup for the warm run. All eight reports have no scenario failures; runner exit 0; lock released. C, dev and harness binaries were built on dev-01 and SHA-256 checked through the control host and perf-01.

cold.index C (s) dev (s)
Pair 1 14.120 12.646
Pair 2 15.273 10.807
Pair 3 15.776 11.411
nearest-rank p95 (max of 3) 15.776 12.646

C/dev p95 is 1.248×, under the 1.25× limit (C ceiling 15.807 s) by 31 ms. Both pass the 60 s absolute limit. Three-pair range/median is C 10.8%, dev 16.1%.

sample 4 warm metric C dev result
open.note p95 5.036 ms 3.397 ms both under 15 ms
idle.rss 96.609 MB 104.543 MB C under 100 MB and below dev

Reports and the pair summary are on the control host at /home/kayg/Developer/perf-results/pr792-fab01191/. Exact-head dev-01 gates for fmt, the identity-cache regression, fsio/notes and notes crash tests also exited 0. No source change was needed in this pass: the sorter fix was already present on the current PR head.

## 100k cold-index attribution and qualification — `fab01191` C head: `fab011917e9408f5301b839aab879f1e95a481aa`; dev baseline: `a55521f94ef7337f0793037ae2cb2c27266d5720`. ### Write attribution and fix The completed pre-fix 100k diagnostic trace attributed the excess to SQLite temporary sorter files (`/var/tmp/etilqs_*`). In the file-attributed C trace, these 30 files took 1,492,369 `pwrite64` calls; dev took 21,223, a difference of 1,471,146. C's calls were mostly 759,313 writes of 4,096 bytes and 732,978 writes of 4 bytes, plus 25 writes of 61,504 bytes and small tails. The full trace totals were 1,882,588 C / 308,790 dev, consistent with the earlier 1.87M / 304k signal. The current exact-head trace is down to 369,170 C / 306,546 dev `pwrite64` calls. It shows zero C sorter-temp writes; dev has 21,223. The fix already on this head (`7a983946`) sets `PRAGMA temp_store=MEMORY` for metadata cold-fill sorters. It does not change the metadata WAL sync setting; `metadata_temp_tables_use_memory_without_changing_wal_sync` asserts `temp_store=2` and `synchronous=1`. Identity-log fsyncs, durable acknowledgements, and the identity projection's `synchronous=FULL` remain in place. For `describe`, the scan reads bounded note heads to resolve IDs, then reads each full note once for list metadata. `describe_list` skips building the cleaned search body. This is a note-head read plus a full read, not two full-note parses; required title, tag and date extraction remains. ### Perf qualification Ran on perf-01, same VM for all pairs, C then dev, seed 1, no page-cache drops. Command was `~/bin/perf-lock ~/bin/slot /bin/bash ~/pr792-fab01191/run-perf.sh` under `RuntimeMaxSec=21600`. Per sample the harness used `--sizes 100k --runs 1 --cold-runs 1` for samples 1–3 and `--cold-runs 0` for the designated warm sample 4, plus `--seed 1 --idle-secs 60 --budgets budgets.json`. Sample 4 includes the harness's unscored cold setup for the warm run. All eight reports have no scenario failures; runner exit 0; lock released. C, dev and harness binaries were built on dev-01 and SHA-256 checked through the control host and perf-01. | cold.index | C (s) | dev (s) | |---|---:|---:| | Pair 1 | 14.120 | 12.646 | | Pair 2 | 15.273 | 10.807 | | Pair 3 | 15.776 | 11.411 | | nearest-rank p95 (max of 3) | **15.776** | **12.646** | C/dev p95 is **1.248×**, under the 1.25× limit (C ceiling 15.807 s) by 31 ms. Both pass the 60 s absolute limit. Three-pair range/median is C 10.8%, dev 16.1%. | sample 4 warm metric | C | dev | result | |---|---:|---:|---| | open.note p95 | 5.036 ms | 3.397 ms | both under 15 ms | | idle.rss | 96.609 MB | 104.543 MB | C under 100 MB and below dev | Reports and the pair summary are on the control host at `/home/kayg/Developer/perf-results/pr792-fab01191/`. Exact-head dev-01 gates for fmt, the identity-cache regression, fsio/notes and notes crash tests also exited 0. No source change was needed in this pass: the sorter fix was already present on the current PR head.
Author
Owner

Independent review: cold-index perf fixes

Reviewed head: fab011917e9408f5301b839aab879f1e95a481aa.

Findings

  • P0: none.
  • P1: none.
  • P2: temp_store=MEMORY leaves the metadata sorter without an application-level memory budget. One 100k cold sample on dev-01 measured peak RSS of 175 MiB for C and 186 MiB for dev, so this sample shows no 100k RSS increase. If total C high-water scales linearly, 1M notes extrapolate to about 1.7 GiB; that is not a 1M measurement or sorter-only attribution. At sufficiently large sizes or concurrent cold fills, this can create RAM pressure where the old sorter spilled to temporary files. Define a large-Home memory budget or qualify a 1M fixture before relying on this setting at that scale.
  • P3: this head sets SCAN_IDENTITY_BATCH=4000; IDENTITY_LOOKUP_BATCH=400 is the SQL parameter chunk size. The 400-identity scan-group description does not match the reviewed head. I exercised the actual 4,000-row setting.

Checks

  • Crash recovery: three SIGKILLs landed with the durable identity log ahead of its projection: 4,000/0, 40,000/36,000, and 80,000/76,000 lines. After restart and a completed rescan, the index held 100,000 unique IDs and paths matching all 100,000 files. No file ID duplicated, no path had multiple IDs across the log history, and all frontmatter IDs matched their indexed paths. The fixture's 1,766 intentionally ID-less notes remained ID-less in frontmatter and each had one indexed identity.
  • Durability: log batches still call sync_all; projection commits retain synchronous=FULL; acknowledgements follow the commit. The first log creation still syncs its parent directory, and compaction syncs the directory after replacement.
  • Path safety: grouped opens use the retained root fd and openat2 with BENEATH | NO_SYMLINKS | NO_MAGICLINKS | NO_XDEV. A failed post-group root validation returns an error before current callers consume their accumulated results. I found no symlink or root-replacement path escape.
  • The reported 1.248× three-pair performance gate was not rerun here; the RSS captures were one run per binary.

Verdict

Approve for the stated 100k target, with a P2 follow-up for memory limits at larger Homes.

# Independent review: cold-index perf fixes Reviewed head: `fab011917e9408f5301b839aab879f1e95a481aa`. ## Findings - **P0:** none. - **P1:** none. - **P2:** `temp_store=MEMORY` leaves the metadata sorter without an application-level memory budget. One 100k cold sample on dev-01 measured peak RSS of **175 MiB for C** and **186 MiB for dev**, so this sample shows no 100k RSS increase. If total C high-water scales linearly, 1M notes extrapolate to about **1.7 GiB**; that is not a 1M measurement or sorter-only attribution. At sufficiently large sizes or concurrent cold fills, this can create RAM pressure where the old sorter spilled to temporary files. Define a large-Home memory budget or qualify a 1M fixture before relying on this setting at that scale. - **P3:** this head sets `SCAN_IDENTITY_BATCH=4000`; `IDENTITY_LOOKUP_BATCH=400` is the SQL parameter chunk size. The 400-identity scan-group description does not match the reviewed head. I exercised the actual 4,000-row setting. ## Checks - **Crash recovery:** three SIGKILLs landed with the durable identity log ahead of its projection: 4,000/0, 40,000/36,000, and 80,000/76,000 lines. After restart and a completed rescan, the index held 100,000 unique IDs and paths matching all 100,000 files. No file ID duplicated, no path had multiple IDs across the log history, and all frontmatter IDs matched their indexed paths. The fixture's 1,766 intentionally ID-less notes remained ID-less in frontmatter and each had one indexed identity. - **Durability:** log batches still call `sync_all`; projection commits retain `synchronous=FULL`; acknowledgements follow the commit. The first log creation still syncs its parent directory, and compaction syncs the directory after replacement. - **Path safety:** grouped opens use the retained root fd and `openat2` with `BENEATH | NO_SYMLINKS | NO_MAGICLINKS | NO_XDEV`. A failed post-group root validation returns an error before current callers consume their accumulated results. I found no symlink or root-replacement path escape. - The reported 1.248× three-pair performance gate was not rerun here; the RSS captures were one run per binary. ## Verdict Approve for the stated 100k target, with a P2 follow-up for memory limits at larger Homes.
Author
Owner

Rebase and conflict resolutions

Rebased the stack onto dev 41f2efa387b74175971a838f602535089d0d5fb4:

  • #790 A: 91c034ebb36ca42a19530c50996244e892e4acbe
  • #791 B: eeb9db4f5db70acf9e6e30a8ba9f4e617c567fd
  • #792 C: 69b06c65b05b4f9b6506393f2a85501c52f6fb7c
  • #793 D: 8f838ca3d22b9ec5542b732f540c064b98a5ecc1

The first C rebase tried to replay A and B again because their old ancestry had been rewritten. I aborted it and replayed only C's commits after its former base 58ffcc7182a8c7c1ff64ecc366d0e52ebe2eddf8 onto the new B head. D was transplanted from old C base 9c8c287357240912daf895c4166bf00f2212ce4a onto the new C head.

Resolution decisions:

  • A retained dev's cache fast path and layout::NOTES state classification, plus both sides' fsio coverage and the split instructions.
  • B combines the retained reader with dev's accepted-generation search and peek reads. Scan and watcher traversal stay on the pinned reader; FileStamp, metadata refresh executor checks, and watcher path/identity lifecycle guards remain.
  • C keeps the paged SQLite identity projection and bounded child/deletion traversal. Search, peek, and backlink source reads use accepted generations. Background scan reads use the retained reader in batches capped at 128 and persist dev's full FileStamp. Metadata updates retain the room metadata lock and executor identity check. Paged watcher deletion checks executor path and identity availability, confirms the ID still maps to the same path, then rechecks the identity after acquiring the lifecycle guard. Reserved index removal remains, with dev's delete-intent recovery behavior.
  • The metadata sorter uses temp_store=MEMORY; WAL sync settings, durable identity acknowledgements, and synchronous=FULL projection commits remain unchanged.
  • D retains both dev's capture_tests and its tiering model/fixture module.

The functional and performance gates are running separately; results will follow with exact commands and measurements.

## Rebase and conflict resolutions Rebased the stack onto dev `41f2efa387b74175971a838f602535089d0d5fb4`: - #790 A: `91c034ebb36ca42a19530c50996244e892e4acbe` - #791 B: `eeb9db4f5db70acf9e6e30a8ba9f4e617c567fd` - #792 C: `69b06c65b05b4f9b6506393f2a85501c52f6fb7c` - #793 D: `8f838ca3d22b9ec5542b732f540c064b98a5ecc1` The first C rebase tried to replay A and B again because their old ancestry had been rewritten. I aborted it and replayed only C's commits after its former base `58ffcc7182a8c7c1ff64ecc366d0e52ebe2eddf8` onto the new B head. D was transplanted from old C base `9c8c287357240912daf895c4166bf00f2212ce4a` onto the new C head. Resolution decisions: - A retained dev's cache fast path and `layout::NOTES` state classification, plus both sides' fsio coverage and the split instructions. - B combines the retained reader with dev's accepted-generation search and peek reads. Scan and watcher traversal stay on the pinned reader; `FileStamp`, metadata refresh executor checks, and watcher path/identity lifecycle guards remain. - C keeps the paged SQLite identity projection and bounded child/deletion traversal. Search, peek, and backlink source reads use accepted generations. Background scan reads use the retained reader in batches capped at 128 and persist dev's full `FileStamp`. Metadata updates retain the room metadata lock and executor identity check. Paged watcher deletion checks executor path and identity availability, confirms the ID still maps to the same path, then rechecks the identity after acquiring the lifecycle guard. Reserved index removal remains, with dev's delete-intent recovery behavior. - The metadata sorter uses `temp_store=MEMORY`; WAL sync settings, durable identity acknowledgements, and `synchronous=FULL` projection commits remain unchanged. - D retains both dev's `capture_tests` and its tiering model/fixture module. The functional and performance gates are running separately; results will follow with exact commands and measurements.
Author
Owner

C on dev 41f2efa3: restack, resolutions

New head 487e058908d3b2f24389627741c33907ee7beb25 (was 896babb4). Its commits after eeb9db4f were replayed onto B's new head efcaaaf7, plus one fmt commit. git diff 896babb4 487e0589 shows only the order of two #[cfg(test)] mod lines: cargo fmt --check failed on C alone, and the earlier fix (0200eb7a) had landed in D. The meta/executor hunks of 896babb4 now live in B, so C applies them as no-ops.

Resolutions (dev's side kept; the paged traversal goes through it)

  • Search, peek and backlink sources: read only pinned accepted generations (generation::Snapshot::pin, generation.source). The file-reading read_doc is now #[cfg(test)]. Scan's read_note is a working-file read in dev too; it now takes the pinned reader and builds no unused search body (describe_list).

  • Executor (#814): the only diff from dev in write_executor/ is home.rs, a type change. The paged index returns owned Strings, so .map(str::to_owned) goes and path_of(..).as_deref() is compared. Every refusal (unknown Home binding, changed binding, executor_path_available) is dev's. write_executor/recovery.rs is untouched.

  • #828 restart stamp validation: meta/restart.rs untouched. Background scans read through the pinned reader in groups of at most 128 and persist dev's full FileStamp. Metadata updates keep the room metadata lock and the executor identity check.

  • Watcher deletion (watch.rs::reconcile_vanished, one 50-row descendant page at a time, never a subtree-sized list). In order, skip when:

    1. executor_path_available(rel) fails;
    2. the file still opens through the pinned reader;
    3. id_at(rel) no longer maps to this id;
    4. executor_available(id) fails.

    Then take lifecycle(id) and recheck executor_available(id), file absence and path_of(id) == rel before trashing or removing. Renames/restores that finish while the watcher waits on a room are never trashed. Reserved index removal keeps dev's delete-intent recovery.

  • Durability: unchanged. Identity log sync_all, acknowledgement after commit, projection synchronous=FULL. Only the metadata sorter uses temp_store=MEMORY.

Gates: fmt and clippy -D warnings pass at 487e0589. cargo test --workspace 1,537 passed, 0 failed, 22 ignored, run at 896babb4 (the tree differs only by the mod order above). Cold-index gate 1.196× dev (limit 1.25×, was 1.248×); cold memory peak 227 vs 291 MB. 200-append −1.09% vs dev. Table in the gate comment below; dry run in #790.

Open item carried from the last review (P2): temp_store=MEMORY has no app-level budget for 1M-note Homes. Not addressed here; no new 1M measurement.

## C on dev `41f2efa3`: restack, resolutions New head **`487e058908d3b2f24389627741c33907ee7beb25`** (was `896babb4`). Its commits after `eeb9db4f` were replayed onto B's new head `efcaaaf7`, plus one fmt commit. `git diff 896babb4 487e0589` shows only the order of two `#[cfg(test)] mod` lines: `cargo fmt --check` failed on C alone, and the earlier fix (`0200eb7a`) had landed in D. The meta/executor hunks of `896babb4` now live in B, so C applies them as no-ops. ### Resolutions (dev's side kept; the paged traversal goes through it) - **Search, peek and backlink sources:** read only pinned accepted generations (`generation::Snapshot::pin`, `generation.source`). The file-reading `read_doc` is now `#[cfg(test)]`. Scan's `read_note` is a working-file read in dev too; it now takes the pinned reader and builds no unused search body (`describe_list`). - **Executor (#814):** the only diff from dev in `write_executor/` is `home.rs`, a type change. The paged index returns owned `String`s, so `.map(str::to_owned)` goes and `path_of(..).as_deref()` is compared. Every refusal (`unknown Home binding`, `changed binding`, `executor_path_available`) is dev's. `write_executor/recovery.rs` is untouched. - **#828 restart stamp validation:** `meta/restart.rs` untouched. Background scans read through the pinned reader in groups of at most 128 and persist dev's full `FileStamp`. Metadata updates keep the room metadata lock and the executor identity check. - **Watcher deletion** (`watch.rs::reconcile_vanished`, one 50-row descendant page at a time, never a subtree-sized list). In order, skip when: 1. `executor_path_available(rel)` fails; 2. the file still opens through the pinned reader; 3. `id_at(rel)` no longer maps to this id; 4. `executor_available(id)` fails. Then take `lifecycle(id)` and **recheck** `executor_available(id)`, file absence and `path_of(id) == rel` before trashing or removing. Renames/restores that finish while the watcher waits on a room are never trashed. Reserved index removal keeps dev's delete-intent recovery. - **Durability:** unchanged. Identity log `sync_all`, acknowledgement after commit, projection `synchronous=FULL`. Only the metadata sorter uses `temp_store=MEMORY`. Gates: fmt and clippy `-D warnings` pass at `487e0589`. `cargo test --workspace` **1,537 passed, 0 failed, 22 ignored**, run at `896babb4` (the tree differs only by the mod order above). Cold-index gate **1.196× dev** (limit 1.25×, was 1.248×); cold memory peak 227 vs 291 MB. 200-append −1.09% vs dev. Table in the gate comment below; dry run in #790. Open item carried from the last review (P2): `temp_store=MEMORY` has no app-level budget for 1M-note Homes. Not addressed here; no new 1M measurement.
Author
Owner

Gate table — stack on dev 41f2efa3 (takeover after the Codex usage limit)

Heads: #790 A 91c034eb → #791 B efcaaaf7 → #792 C 487e0589 → #793 D d422d0ca. Base: dev 41f2efa387b74175971a838f602535089d0d5fb4 (what prod runs). Nothing merged.

Gate (dev-01, Nix, ~/bin/slot) A 91c034eb B efcaaaf7 C 487e0589 D d422d0ca
cargo fmt --all --check pass pass pass pass
cargo clippy -j4 --workspace --all-targets --locked -- -D warnings pass pass pass pass ¹
cargo test -j4 --workspace --locked -- --test-threads=2 1,495 passed, 0 failed, 22 ignored 1,504 / 0 / 22 1,537 / 0 / 22 ² 1,540 / 0 / 22 ¹

¹ Run by the earlier agent at 89989e62; git diff 89989e62 d422d0ca is empty (same tree).
² Run at 896babb4; 487e0589 differs only by the order of two #[cfg(test)] mod lines (cargo fmt).

Perf VMs (binaries built on dev-01 in release at the D tree, relayed through the control host, SHA-256 checked on both perf VMs: C 2006ab8e…, dev 724535bd…, harness 980045d9…):

Gate Head (C/D) dev 41f2efa3 Limit Result
cold.index, 3 cold pairs, perf-01, p95 = max of 3 25.0 s (22.6 / 25.0 / 24.3) 20.9 s (20.9 / 18.6 / 20.2) ≤ 1.25× dev and ≤ 60 s pass, 1.196× (was 1.248×)
cold.mem_peak (harness), 3 cold runs, max 227 MB 291 MB not worse than dev pass, 0.78× dev (was 175 vs 186 MiB = 0.94×)
server VmHWM, sampled every 200 ms, max over 4 samples 227.5 MiB 291.5 MiB — head lower
open.note@100000 p95, warm pair 12.4 ms 10.6 ms ≤ 15 ms pass
idle.rss@100000, warm pair 60.6 MB 71.8 MB ≤ 100 MB pass
200 sequential notes.append, 30 alternating pairs, perf-02, median total 1.578 s 1.595 s within 1% of dev pass, −1.09% (pair-ratio median 0.992)

Protocol unchanged from the fab01191 run: ~/bin/perf-lock ~/bin/slot run-perf.sh, per sample calternal-perf --sizes 100k --runs 1 --cold-runs {1,1,1,0} --seed 1 --idle-secs 60 --budgets {"open.note@100000":{"p95":15},"idle.rss@100000":{"p95":100}}, C then dev, same VM, no page-cache drop; all 8 reports have no scenario failures. No limit was changed.

Notes:

  • Absolute cold times rose for both sides against the fab01191 run (dev a55521f9 12.6 s → dev 41f2efa3 20.9 s on the same harness and VM), so only the ratio is comparable. The absolute memory numbers are from a different source (harness cold.mem_peak on perf-01 vs one dev-01 sample earlier); the ratio to dev improved.
  • The append gate is noisy: single pairs range 0.82–1.48×. A first 5-pair run read +1.15%; with 30 pairs the median is −1.09%. A 1% gate needs ≥ 30 pairs.
  • Reports, logs and scripts: control host ~/Developer/perf-results/pr792-41f2efa3/. Perf VM binaries and work dirs deleted.
## Gate table — stack on dev `41f2efa3` (takeover after the Codex usage limit) Heads: #790 A `91c034eb` → #791 B `efcaaaf7` → #792 C `487e0589` → #793 D `d422d0ca`. Base: dev `41f2efa387b74175971a838f602535089d0d5fb4` (what prod runs). Nothing merged. | Gate (dev-01, Nix, `~/bin/slot`) | A `91c034eb` | B `efcaaaf7` | C `487e0589` | D `d422d0ca` | |---|---|---|---|---| | `cargo fmt --all --check` | pass | pass | pass | pass | | `cargo clippy -j4 --workspace --all-targets --locked -- -D warnings` | pass | pass | pass | pass ¹ | | `cargo test -j4 --workspace --locked -- --test-threads=2` | 1,495 passed, 0 failed, 22 ignored | 1,504 / 0 / 22 | 1,537 / 0 / 22 ² | 1,540 / 0 / 22 ¹ | ¹ Run by the earlier agent at `89989e62`; `git diff 89989e62 d422d0ca` is empty (same tree). ² Run at `896babb4`; `487e0589` differs only by the order of two `#[cfg(test)] mod` lines (`cargo fmt`). **Perf VMs** (binaries built on dev-01 in release at the D tree, relayed through the control host, SHA-256 checked on both perf VMs: C `2006ab8e…`, dev `724535bd…`, harness `980045d9…`): | Gate | Head (C/D) | dev `41f2efa3` | Limit | Result | |---|---:|---:|---|---| | cold.index, 3 cold pairs, perf-01, p95 = max of 3 | 25.0 s (22.6 / 25.0 / 24.3) | 20.9 s (20.9 / 18.6 / 20.2) | ≤ 1.25× dev and ≤ 60 s | **pass, 1.196×** (was 1.248×) | | cold.mem_peak (harness), 3 cold runs, max | 227 MB | 291 MB | not worse than dev | **pass**, 0.78× dev (was 175 vs 186 MiB = 0.94×) | | server VmHWM, sampled every 200 ms, max over 4 samples | 227.5 MiB | 291.5 MiB | — | head lower | | open.note@100000 p95, warm pair | 12.4 ms | 10.6 ms | ≤ 15 ms | pass | | idle.rss@100000, warm pair | 60.6 MB | 71.8 MB | ≤ 100 MB | pass | | 200 sequential `notes.append`, 30 alternating pairs, perf-02, median total | 1.578 s | 1.595 s | within 1% of dev | **pass, −1.09%** (pair-ratio median 0.992) | Protocol unchanged from the `fab01191` run: `~/bin/perf-lock ~/bin/slot run-perf.sh`, per sample `calternal-perf --sizes 100k --runs 1 --cold-runs {1,1,1,0} --seed 1 --idle-secs 60 --budgets {"open.note@100000":{"p95":15},"idle.rss@100000":{"p95":100}}`, C then dev, same VM, no page-cache drop; all 8 reports have no scenario failures. No limit was changed. Notes: - Absolute cold times rose for **both** sides against the `fab01191` run (dev a55521f9 12.6 s → dev 41f2efa3 20.9 s on the same harness and VM), so only the ratio is comparable. The absolute memory numbers are from a different source (harness `cold.mem_peak` on perf-01 vs one dev-01 sample earlier); the ratio to dev improved. - The append gate is noisy: single pairs range 0.82–1.48×. A first 5-pair run read +1.15%; with 30 pairs the median is −1.09%. A 1% gate needs ≥ 30 pairs. - Reports, logs and scripts: control host `~/Developer/perf-results/pr792-41f2efa3/`. Perf VM binaries and work dirs deleted.
Author
Owner

Perf pass on the stack: causes, fixes, and where it still regresses (not mergeable yet)

Branch perf/tiering-761-fix (head 3a65e27c), cut from #793's head d422d0ca; I did not touch the PR branches. Fixes below are for C's files (index.rs, scan.rs, meta.rs, write_executor/home.rs); they fold into #792 when the orchestrator decides.

Causes found (callgrind on a 10k cold start, same fixture, dev vs head)

Head ran 12.4 G instructions against dev's 9.8 G. The surplus was all in scan classify/identify, none in the metadata store (MetaDb::apply is identical, 2.19 G on both):

  1. Index::path_of / id_at ran the TEMP view (stored rows UNION ALL staged rows, correlated subquery, sort), one query per file, 2×100k times per scan.
  2. Index::all() re-prepared its SQL for every page of 50 rows (page_after 0.89 G for 10k notes).
  3. The Home index lock was held across the whole classify pass, so a request for a note waited for the pass (the 400–940 ms first open in the warm runs; dev's max was ~21 ms).
  4. Each identity group waited for its acknowledgement before the next heads were read.

Commits (all pushed, one change each)

95716b4e view-free path_of/id_at, 1000-row all() pages · 14e74b8b overlap identity groups with the next head reads · da957b87 skip the executor path lookup when nothing is blocked · 51ea0761 look scan identities up 400 at a time · f2375638 index lock per group, not per pass · 2473b998 batch claimed-id lookups, known_ids without the view · 4dae439b sorter budget (below) · 3a65e27c rustfmt.
Callgrind, 10k cold: Index::id_at 0.93 G → 0.49 G; page_after 0.89 G → 0.11 G; scan::run 2.83 G → 1.64 G (dev 0.33 G).

Measured (perf-01, calternal-perf --sizes 100k --runs 3 --cold-runs 1 --idle-secs 0, scenarios list,query,journal,open, alternating, same VM, no page-cache drop)

pair (time) dev 41f2efa3 first page / cold index / open.note p50 / p95 d422d0ca (before) fixed tree
15:03 rep 1 1.67 s / 19.7 s / 3.48 / 9.0 ms 5.73 s / 25.5 s / 8.4 / 22.2 h4 4.83 s / 23.6 s / 9.0 / 22.6
15:05 rep 2 1.73 / 20.3 / 7.98 / 14.7 6.08 / 26.2 / 9.1 / 44.6 h4 4.81 / 23.4 / 8.9 / 28.8
17:16 rep 1 1.92 / 20.4 / 4.71 / 12.2 6.36 / 25.2 / 4.68 / 16.3 h5 4.81 / 22.3 / 2.89 / 12.5
17:21 rep 2 1.88 / 19.4 / 5.20 / 15.2 6.34 / 25.4 / 4.71 / 19.2 h5 4.65 / 22.3 / 2.96 / 8.2
18:00 rep 1 (noisy, others on the VM) 2.33 / 26.6 / 6.19 / 17.7 7.39 / 32.4 / 3.46 / 19.7 h6 5.59 / 30.5 / 6.0 / 43.5
18:07 rep 2 2.04 / 21.5 / 2.78 / 9.9 5.99 / 26.3 / 3.88 / 16.1 h6 5.47 / 24.0 / 2.98 / 21.4

h6 = tree at 2473b998 (identical code to 3a65e27c bar formatting).

Reading it honestly:

  • open.note: the stack's p50 is now at or below dev in the clean pairs (2.9 vs 4.7–5.2 ms) and p95 is flat to better in two pairs (12.5 vs 12.2, 8.2 vs 15.2) but worse in the h6 pair (21.4 vs 9.9). The first-open stall fell from 640–940 ms to ~200 ms (max column) but is not gone (dev ~21 ms). Not yet flat.
  • Cold index: 23–24 s against 20–21 s, i.e. 1.09–1.15× dev (was 1.25×). Not flat.
  • First page: 2.4–2.9× dev (4.7–4.8 s against 1.7–1.9 s). The first page waits for the first metadata commit, which waits for the identity phase (3.0 s head vs ~0.7 s dev). Per-file identity work is now small; what is left is structural: the paged projection writes 100k rows into SQLite with four indexes (index::apply + writer, ~0.7 G per 10k notes, which dev's in-memory map plus append-only log does not pay) and a staging transaction per group.
  • A final 5-pair run with h6 was cut off: the perf locks on both VMs were held for hours by other jobs (one sleep 5400 holder), and my one clean h6 sample (cold 19.4 s, first page 4.98 s, open.note 1.17 / 5.4 ms) had no valid dev partner (its pair ran while a 1M-fixture copy was filling the disk, which I stopped and cleaned).

Options for the remaining gap (need a decision, not a patch)

  1. Fresh fill without the secondary indexes (like the metadata store's begin_fresh): drops the writer cost, but id_at then needs a covering lookup during the fill.
  2. Acknowledge the identity group after the log fsync and let the projection commit lag behind (the staged overlay already serves reads): changes the "acknowledge after projection commit" rule, so it needs review.
  3. Accept the regression: against kayg's 11-03 rule this is not allowed, so the stack is not mergeable as it stands.

Sorter memory budget (item 4)

4dae439b: the metadata store's cold-fill sorters stay in memory up to 250,000 notes (same speed as now; the 100k path is unchanged) and spill to temp files (temp_store=FILE) beyond that, so a 1M-note fill's sorter memory stays near the 8 MiB page cache. Test sorter_memory_is_bounded_and_wal_sync_is_unchanged. Peak RSS at 1M notes is not shown: the perf harness cannot cold-start a 1M Home on dev either (notes.list returns note discovery has not produced a validated page yet after its 60 s first-page wait, on dev 41f2efa3 too), and my 1M RSS script never got the perf lock. 100k reference: cold peak 243 MB (head) vs 284–291 MB (dev), idle unchanged.

Gates (dev-01, ~/bin/slot)

Gate Result
cargo fmt --all --check pass (3a65e27c)
cargo clippy -j4 --workspace --all-targets --locked -- -D warnings pass (at 2473b998)
cargo test -j4 --workspace --locked --no-fail-fast -- --test-threads=2 1,540 passed, 0 failed, 22 ignored (at 2473b998; 3a65e27c is rustfmt only)
## Perf pass on the stack: causes, fixes, and where it still regresses (not mergeable yet) Branch `perf/tiering-761-fix` (head `3a65e27c`), cut from #793's head `d422d0ca`; I did not touch the PR branches. Fixes below are for C's files (`index.rs`, `scan.rs`, `meta.rs`, `write_executor/home.rs`); they fold into #792 when the orchestrator decides. ### Causes found (callgrind on a 10k cold start, same fixture, dev vs head) Head ran 12.4 G instructions against dev's 9.8 G. The surplus was all in scan classify/identify, none in the metadata store (`MetaDb::apply` is identical, 2.19 G on both): 1. `Index::path_of` / `id_at` ran the TEMP view (stored rows UNION ALL staged rows, correlated subquery, sort), one query per file, 2×100k times per scan. 2. `Index::all()` re-prepared its SQL for every page of 50 rows (`page_after` 0.89 G for 10k notes). 3. The Home index lock was held across the whole classify pass, so a request for a note waited for the pass (the 400–940 ms first open in the warm runs; dev's max was ~21 ms). 4. Each identity group waited for its acknowledgement before the next heads were read. ### Commits (all pushed, one change each) `95716b4e` view-free `path_of`/`id_at`, 1000-row `all()` pages · `14e74b8b` overlap identity groups with the next head reads · `da957b87` skip the executor path lookup when nothing is blocked · `51ea0761` look scan identities up 400 at a time · `f2375638` index lock per group, not per pass · `2473b998` batch claimed-id lookups, `known_ids` without the view · `4dae439b` sorter budget (below) · `3a65e27c` rustfmt. Callgrind, 10k cold: `Index::id_at` 0.93 G → 0.49 G; `page_after` 0.89 G → 0.11 G; `scan::run` 2.83 G → 1.64 G (dev 0.33 G). ### Measured (perf-01, `calternal-perf --sizes 100k --runs 3 --cold-runs 1 --idle-secs 0`, scenarios list,query,journal,open, alternating, same VM, no page-cache drop) | pair (time) | dev `41f2efa3` first page / cold index / open.note p50 / p95 | `d422d0ca` (before) | fixed tree | |---|---|---|---| | 15:03 rep 1 | 1.67 s / 19.7 s / 3.48 / 9.0 ms | 5.73 s / 25.5 s / 8.4 / 22.2 | `h4` 4.83 s / 23.6 s / 9.0 / 22.6 | | 15:05 rep 2 | 1.73 / 20.3 / 7.98 / 14.7 | 6.08 / 26.2 / 9.1 / 44.6 | `h4` 4.81 / 23.4 / 8.9 / 28.8 | | 17:16 rep 1 | 1.92 / 20.4 / 4.71 / 12.2 | 6.36 / 25.2 / 4.68 / 16.3 | `h5` 4.81 / 22.3 / 2.89 / 12.5 | | 17:21 rep 2 | 1.88 / 19.4 / 5.20 / 15.2 | 6.34 / 25.4 / 4.71 / 19.2 | `h5` 4.65 / 22.3 / 2.96 / 8.2 | | 18:00 rep 1 (noisy, others on the VM) | 2.33 / 26.6 / 6.19 / 17.7 | 7.39 / 32.4 / 3.46 / 19.7 | `h6` 5.59 / 30.5 / 6.0 / 43.5 | | 18:07 rep 2 | 2.04 / 21.5 / 2.78 / 9.9 | 5.99 / 26.3 / 3.88 / 16.1 | `h6` 5.47 / 24.0 / 2.98 / 21.4 | `h6` = tree at `2473b998` (identical code to `3a65e27c` bar formatting). Reading it honestly: - **open.note**: the stack's p50 is now at or below dev in the clean pairs (2.9 vs 4.7–5.2 ms) and p95 is flat to better in two pairs (12.5 vs 12.2, 8.2 vs 15.2) but worse in the `h6` pair (21.4 vs 9.9). The first-open stall fell from 640–940 ms to ~200 ms (`max` column) but is **not gone** (dev ~21 ms). Not yet flat. - **Cold index**: 23–24 s against 20–21 s, i.e. **1.09–1.15× dev** (was 1.25×). Not flat. - **First page**: **2.4–2.9× dev** (4.7–4.8 s against 1.7–1.9 s). The first page waits for the first metadata commit, which waits for the identity phase (3.0 s head vs ~0.7 s dev). Per-file identity work is now small; what is left is structural: the paged projection writes 100k rows into SQLite with four indexes (`index::apply` + writer, ~0.7 G per 10k notes, which dev's in-memory map plus append-only log does not pay) and a staging transaction per group. - A final 5-pair run with `h6` was cut off: the perf locks on both VMs were held for hours by other jobs (one `sleep 5400` holder), and my one clean `h6` sample (cold 19.4 s, first page 4.98 s, open.note 1.17 / 5.4 ms) had no valid dev partner (its pair ran while a 1M-fixture copy was filling the disk, which I stopped and cleaned). ### Options for the remaining gap (need a decision, not a patch) 1. Fresh fill without the secondary indexes (like the metadata store's `begin_fresh`): drops the writer cost, but `id_at` then needs a covering lookup during the fill. 2. Acknowledge the identity group after the log fsync and let the projection commit lag behind (the staged overlay already serves reads): changes the "acknowledge after projection commit" rule, so it needs review. 3. Accept the regression: against kayg's 11-03 rule this is not allowed, so the stack is **not mergeable** as it stands. ### Sorter memory budget (item 4) `4dae439b`: the metadata store's cold-fill sorters stay in memory up to 250,000 notes (same speed as now; the 100k path is unchanged) and spill to temp files (`temp_store=FILE`) beyond that, so a 1M-note fill's sorter memory stays near the 8 MiB page cache. Test `sorter_memory_is_bounded_and_wal_sync_is_unchanged`. **Peak RSS at 1M notes is not shown**: the perf harness cannot cold-start a 1M Home on dev either (`notes.list` returns `note discovery has not produced a validated page yet` after its 60 s first-page wait, on dev `41f2efa3` too), and my 1M RSS script never got the perf lock. 100k reference: cold peak 243 MB (head) vs 284–291 MB (dev), idle unchanged. ### Gates (dev-01, `~/bin/slot`) | Gate | Result | |---|---| | `cargo fmt --all --check` | pass (`3a65e27c`) | | `cargo clippy -j4 --workspace --all-targets --locked -- -D warnings` | pass (at `2473b998`) | | `cargo test -j4 --workspace --locked --no-fail-fast -- --test-threads=2` | **1,540 passed, 0 failed, 22 ignored** (at `2473b998`; `3a65e27c` is rustfmt only) |
Author
Owner

Option A (secondary indexes after the first fill): implemented, target not met — first page 2.5× dev, cold index 1.16× dev

Branch perf/tiering-761-fix. New commits (production change is fe04455e; the rest are test/format fixes): fe04455e build the projection's secondary indexes after the first fill · 4a5e2dc6 rustfmt · 362e6d1b, c0b06fb1 clippy fixes in the new tests · 43d81bc1 rustfmt. Head 43d81bc1. Nothing merged.

What changed

  • A fresh projection starts with the id key and entries_fold only. entries_fold is what the scan's ids_at_folds/id_at read while filling (scan.rs classify and identify), so it stays. entries_child (children by name) and entries_descendants (watcher, under_after) are left out.
  • When the scan finishes (scan::run end → Index::fill_done), the writer thread builds each missing index with one CREATE INDEX IF NOT EXISTS, one per idle slot (only when no append is waiting). A restart finds what is missing and builds it again (index/deferred.rs).
  • A legacy log import builds all indexes in the same transaction as the import (one pass each). Every later write keeps all existing indexes current as before.
  • Queries are not switched: with an index missing SQLite scans and sorts, with the same rows (the planner, not new code, decides). Crash safety: each build is one transaction; a kill leaves the index absent and the database whole (PRAGMA integrity_check = ok in the test).

Tests (index/deferred_tests.rs, 7 new)

fresh fill keeps only the fill indexes · scan signal builds both, children/descendants answers identical before and after, later put/remove keep them current, EXPLAIN QUERY PLAN uses entries_child · populated projection without the signal builds on restart · empty projection does not build before its first fill · imported log builds all at once · kill mid-build: a child process stops between CREATE INDEX and COMMIT, the parent kills it, checks the index is absent and integrity_check ok, restarts and checks both indexes plus 3,000 children/descendants/path_of answers.

Perf-01, 100k Home, 6 reps, rotated order (head / dev / dev copy "devB" as control), no page-cache drop, both perf locks held

Binaries built on dev-01 (release): head tree 4a5e2dc6 (later commits change only #[cfg(test)] code and formatting) sha256 f6ac38e1…, dev 41f2efa3 01522f72… (devB is a byte copy), harness 352d448a…. PERF_ONLY=list,query,journal,open, --runs 3 --cold-runs 1 --seed 1 --idle-secs 0. Medians over the 6 reps.

metric head dev dev copy head / dev control (devB / dev)
cold.first_page (ms) 4048 1624 1619 2.49× 1.00
cold.index (s) 17.2 14.9 15.4 1.16× 1.04
open.note p50 / p95 (ms) 0.92 / 3.80 0.75 / 3.43 0.77 / 3.50 1.22× / 1.11× 1.03 / 1.02
open.api_get p50 / p95 (ms) 6.5 / 27.2 6.1 / 9.2 6.5 / 10.3 1.08× / 2.95× 1.07 / 1.12
home.first_page (ms) 6.18 3.44 3.26 1.80× 0.95
home.page / page_next / page_title (ms) 0.98 / 0.77 / 0.80 1.06 / 0.78 / 0.84 0.95 / 0.74 / 0.81 0.93 / 0.99 / 0.94
home.page_tag (ms) 70.4 52.2 53.1 1.35× 1.02
home.page_big (ms) 23.6 18.6 18.3 1.27× 0.98
journal.page (ms) 2.99 2.49 2.51 1.20× 1.00
search.* not in this harness output (see note)
cold.mem_peak (MB) 240 291 291 0.82× 1.00
server VmHWM, 200 ms sampler (MB, median / max of 6) 240 / 243 291 / 294 291 / 293 0.83×
200 sequential notes.append, 30 pairs (CALTERNAL_NOTES_SAVE_MS=15) 1.490 s 1.480 s +0.62% (pair-ratio spread 0.94–1.02) control 15 pairs dev vs devB: +0.22%

Per-rep cold.index / first page: head 17.2 s/3.9 s, 17.5/4.3, 18.7/5.5, 16.8/3.9, 17.1/3.9, 17.3/4.2; dev 14.9/1.63, 14.8/1.62, 18.8/1.69, 15.1/1.66, 14.8/1.59, 14.7/1.59; control within ±3% of dev on every rep except one (cold.index 18.8 s).

Note on search: the scenario filter PERF_ONLY=list,query,journal,open did not emit search.first_query / search.query rows in these reports; I did not run a separate search pass.

Reading

  • Leaving two of the four indexes out of the first fill moved the first page only from 4.7–5.5 s to 4.0 s (4.0–5.5 over reps); dev is 1.6 s. Cold index is 1.16× dev (the previous pass measured 1.09–1.15×, so no gain either). The index maintenance was not the main cost of the first page.
  • Everything that reads the finished projection is still 1.2–1.35× dev (tag page, big page, journal page, open.note p50, first page of the list) in the same session where the control reads 0.95–1.04×. These come from the stack's design (paged reads), not from this change, and are regressions against dev by the 11-03 rule. Memory is the one win (0.82×).
  • Appends: +0.62% (limit 1%, control noise ±0.2%).
  • Not tried, my guesses for where the first-page time is: id TEXT PRIMARY KEY random-order inserts (two B-trees per row), the totals trigger running an UPDATE per inserted row, and one synchronous=FULL commit per 4,000-row group. I have not measured any of them (callgrind on a 10k cold start would split it in one pass). Option 2 from the earlier comment (acknowledge after the log fsync) is still the other route.
  • The stack is not mergeable on this evidence.

Gates (dev-01, ~/bin/slot, tree 43d81bc1)

Gate Result
cargo fmt --all --check pass
cargo clippy -j4 --workspace --all-targets --locked -- -D warnings pass
cargo test -j4 --workspace --locked --no-fail-fast -- --test-threads=2 1,547 passed, 0 failed, 22 ignored (was 1,540; +7 new)
#793 fixtures (tiering-scale release: ids, inventory, aggregate, history, blocks, moves) not run yet — dev-01 build slots have been held back by train bm14e since 09:30 CEST; my job is queued (tpf-fix). Result follows in a new comment.

Perf VM locks on perf-01 and perf-02 released (tpf4-hold stopped on both). Reports and scripts on the control host: /tmp/tpf/ (JSON per run in /tmp/tpf/reports).

## Option A (secondary indexes after the first fill): implemented, **target not met** — first page 2.5× dev, cold index 1.16× dev Branch `perf/tiering-761-fix`. New commits (production change is `fe04455e`; the rest are test/format fixes): `fe04455e` build the projection's secondary indexes after the first fill · `4a5e2dc6` rustfmt · `362e6d1b`, `c0b06fb1` clippy fixes in the new tests · `43d81bc1` rustfmt. Head `43d81bc1`. Nothing merged. ### What changed - A fresh projection starts with the id key and `entries_fold` only. `entries_fold` is what the scan's `ids_at_folds`/`id_at` read while filling (`scan.rs` classify and identify), so it stays. `entries_child` (children by name) and `entries_descendants` (watcher, `under_after`) are left out. - When the scan finishes (`scan::run` end → `Index::fill_done`), the writer thread builds each missing index with one `CREATE INDEX IF NOT EXISTS`, one per idle slot (only when no append is waiting). A restart finds what is missing and builds it again (`index/deferred.rs`). - A legacy log import builds all indexes in the same transaction as the import (one pass each). Every later write keeps all existing indexes current as before. - Queries are not switched: with an index missing SQLite scans and sorts, with the same rows (the planner, not new code, decides). Crash safety: each build is one transaction; a kill leaves the index absent and the database whole (`PRAGMA integrity_check` = ok in the test). ### Tests (`index/deferred_tests.rs`, 7 new) fresh fill keeps only the fill indexes · scan signal builds both, children/descendants answers identical before and after, later `put`/`remove` keep them current, `EXPLAIN QUERY PLAN` uses `entries_child` · populated projection without the signal builds on restart · empty projection does not build before its first fill · imported log builds all at once · **kill mid-build**: a child process stops between `CREATE INDEX` and `COMMIT`, the parent kills it, checks the index is absent and `integrity_check` ok, restarts and checks both indexes plus 3,000 children/descendants/`path_of` answers. ### Perf-01, 100k Home, 6 reps, rotated order (head / dev / dev copy "devB" as control), no page-cache drop, both perf locks held Binaries built on dev-01 (release): head tree `4a5e2dc6` (later commits change only `#[cfg(test)]` code and formatting) sha256 `f6ac38e1…`, dev `41f2efa3` `01522f72…` (devB is a byte copy), harness `352d448a…`. `PERF_ONLY=list,query,journal,open`, `--runs 3 --cold-runs 1 --seed 1 --idle-secs 0`. Medians over the 6 reps. | metric | head | dev | dev copy | head / dev | control (devB / dev) | |---|---:|---:|---:|---:|---:| | **cold.first_page** (ms) | **4048** | **1624** | 1619 | **2.49×** | 1.00 | | **cold.index** (s) | **17.2** | **14.9** | 15.4 | **1.16×** | 1.04 | | open.note p50 / p95 (ms) | 0.92 / 3.80 | 0.75 / 3.43 | 0.77 / 3.50 | 1.22× / 1.11× | 1.03 / 1.02 | | open.api_get p50 / p95 (ms) | 6.5 / 27.2 | 6.1 / 9.2 | 6.5 / 10.3 | 1.08× / 2.95× | 1.07 / 1.12 | | home.first_page (ms) | 6.18 | 3.44 | 3.26 | 1.80× | 0.95 | | home.page / page_next / page_title (ms) | 0.98 / 0.77 / 0.80 | 1.06 / 0.78 / 0.84 | 0.95 / 0.74 / 0.81 | 0.93 / 0.99 / 0.94 | | | home.page_tag (ms) | 70.4 | 52.2 | 53.1 | 1.35× | 1.02 | | home.page_big (ms) | 23.6 | 18.6 | 18.3 | 1.27× | 0.98 | | journal.page (ms) | 2.99 | 2.49 | 2.51 | 1.20× | 1.00 | | search.* | not in this harness output (see note) | | | | | | cold.mem_peak (MB) | 240 | 291 | 291 | 0.82× | 1.00 | | server VmHWM, 200 ms sampler (MB, median / max of 6) | 240 / 243 | 291 / 294 | 291 / 293 | 0.83× | | | 200 sequential `notes.append`, 30 pairs (`CALTERNAL_NOTES_SAVE_MS=15`) | 1.490 s | 1.480 s | | **+0.62%** (pair-ratio spread 0.94–1.02) | control 15 pairs dev vs devB: +0.22% | Per-rep cold.index / first page: head 17.2 s/3.9 s, 17.5/4.3, 18.7/5.5, 16.8/3.9, 17.1/3.9, 17.3/4.2; dev 14.9/1.63, 14.8/1.62, 18.8/1.69, 15.1/1.66, 14.8/1.59, 14.7/1.59; control within ±3% of dev on every rep except one (cold.index 18.8 s). Note on search: the scenario filter `PERF_ONLY=list,query,journal,open` did not emit `search.first_query` / `search.query` rows in these reports; I did not run a separate search pass. ### Reading - Leaving two of the four indexes out of the first fill moved the first page only from 4.7–5.5 s to **4.0 s** (4.0–5.5 over reps); dev is 1.6 s. Cold index is **1.16×** dev (the previous pass measured 1.09–1.15×, so no gain either). The index maintenance was not the main cost of the first page. - Everything that reads the finished projection is still 1.2–1.35× dev (tag page, big page, journal page, open.note p50, first page of the list) in the same session where the control reads 0.95–1.04×. These come from the stack's design (paged reads), not from this change, and are **regressions against dev by the 11-03 rule**. Memory is the one win (0.82×). - Appends: +0.62% (limit 1%, control noise ±0.2%). - Not tried, my guesses for where the first-page time is: `id TEXT PRIMARY KEY` random-order inserts (two B-trees per row), the `totals` trigger running an `UPDATE` per inserted row, and one `synchronous=FULL` commit per 4,000-row group. I have not measured any of them (callgrind on a 10k cold start would split it in one pass). Option 2 from the earlier comment (acknowledge after the log fsync) is still the other route. - **The stack is not mergeable on this evidence.** ### Gates (dev-01, `~/bin/slot`, tree `43d81bc1`) | Gate | Result | |---|---| | `cargo fmt --all --check` | pass | | `cargo clippy -j4 --workspace --all-targets --locked -- -D warnings` | pass | | `cargo test -j4 --workspace --locked --no-fail-fast -- --test-threads=2` | **1,547 passed, 0 failed, 22 ignored** (was 1,540; +7 new) | | #793 fixtures (`tiering-scale` release: ids, inventory, aggregate, history, blocks, moves) | **not run yet** — dev-01 build slots have been held back by train `bm14e` since 09:30 CEST; my job is queued (`tpf-fix`). Result follows in a new comment. | Perf VM locks on perf-01 and perf-02 released (`tpf4-hold` stopped on both). Reports and scripts on the control host: `/tmp/tpf/` (JSON per run in `/tmp/tpf/reports`).
Author
Owner

Correction to the table above:

  • Search was not measured. The scenario filter I used (PERF_ONLY=list,query,journal,open) produced no search.* rows (checked in the JSON). The brief asked for search; that is still open. It needs another lock window with the scenario name the harness uses for search.
  • I wrote that the 1.2–1.35× deltas (tag page, big page, journal page, open.note p50, home first page) come "from the stack's design". I did not measure the cause. They are regressions against dev in this run, cause unknown.
Correction to the table above: - **Search was not measured.** The scenario filter I used (`PERF_ONLY=list,query,journal,open`) produced no `search.*` rows (checked in the JSON). The brief asked for search; that is still open. It needs another lock window with the scenario name the harness uses for search. - I wrote that the 1.2–1.35× deltas (tag page, big page, journal page, open.note p50, home first page) come "from the stack's design". I did not measure the cause. They are regressions against dev in this run, cause unknown.
Author
Owner

#793 fixtures at 43d81bc1: not run. Train bm14e held dev-01's build slots for 2.5 h+ (~/.train.lock), so my tiering-scale release build never started; I stopped my queued job (tpf-fix) rather than leave it running. The previous fixture results (tree 2473b998, comment above) are the latest. The change here touches index.rs/scan.rs only through the deferred index build; the full workspace suite passed (1,547).

#793 fixtures at `43d81bc1`: **not run**. Train `bm14e` held dev-01's build slots for 2.5 h+ (`~/.train.lock`), so my `tiering-scale` release build never started; I stopped my queued job (`tpf-fix`) rather than leave it running. The previous fixture results (tree `2473b998`, comment above) are the latest. The change here touches `index.rs`/`scan.rs` only through the deferred index build; the full workspace suite passed (1,547).
Author
Owner

Update on the #793 fixtures at 43d81bc1: still not run. My first fixture job (tpf-fix, started 09:21 CEST) ended without output and I do not know why; the second (tpf-fix2, 11:50) waited about 35 min with no ~/bin/slot slot while train bm14e held ~/.train.lock (it had run 2.5 h+), and I stopped it. Run tiering-scale DIR ids|inventory|aggregate|history|blocks|moves (release, --metadata-only where noted in #793) once the train ends; I did not do it, so the last fixture results are still those at 2473b998.

Update on the #793 fixtures at `43d81bc1`: still **not run**. My first fixture job (`tpf-fix`, started 09:21 CEST) ended without output and I do not know why; the second (`tpf-fix2`, 11:50) waited about 35 min with no `~/bin/slot` slot while train `bm14e` held `~/.train.lock` (it had run 2.5 h+), and I stopped it. Run `tiering-scale DIR ids|inventory|aggregate|history|blocks|moves` (release, `--metadata-only` where noted in #793) once the train ends; I did not do it, so the last fixture results are still those at `2473b998`.
Author
Owner

Rethink after repeated misses: where the time goes, and three designs

No code changed in this lane. These are measurements of perf/tiering-761-fix head 43d81bc1 (binary from tree 4a5e2dc6, sha256 f6ac38e1…; the later commits only change tests and formatting) against dev 41f2efa3 (01522f72…). The harness is 352d448a…. All three binaries were built on dev-01 and checked by sha256 on dev-01, the control host and perf-01.

What tiering slice 1 has to achieve

From the approved plan §2–3 and #761:

  • Replace the full ids.jsonl load/unload with paged SSD B-trees (ID → parent/name, child index). Use cursors of at most 50 entries and a global index cache of 64 MiB, so 100 Homes of 100k notes with ten active fit without holding every Home's id map in RAM.
  • Budgets: 100k index ≤ 60 s, first page ≤ 100 ms, open.note p95 ≤ 15 ms, idle RSS ≤ 100 MB.
  • kayg's 11-03 rule on top: flat or better than dev on every hot path it touches.

The steady-state win is real: idle RSS is about 9% lower than dev (#issuecomment-72561), and cold peak memory is 0.82× here. The misses are all on the cold fill, plus the window right after a warm start.

Measured (perf-01, 100k Home, seed 1, both perf locks held, three rotated reps head/dev/devB, no page-cache drop)

metric head dev dev copy head/dev control
cold.first_page (ms) 4025 1812 1763 2.22× 0.97
cold.index (s) 17.31 14.67 15.10 1.18× 1.03
cold.mem_peak (MB) 239 290 287 0.82× 0.99
cold.disk_written (MB) 1973 1918 1919 1.03× 1.00
home.page_tag p50 / p95 (ms) 68.3 / 74.2 52.4 / 56.4 52.3 / 54.7 1.30× / 1.31× 1.00 / 0.97
home.page_big p50 / p95 (ms) 22.4 / 26.0 18.4 / 20.9 19.1 / 20.7 1.22× / 1.25× 1.04 / 0.99
journal.page p50 / p95 (ms) 3.03 / 4.52 2.39 / 2.69 2.49 / 3.12 1.26× / 1.68× 1.04 / 1.16
open.note p50 / p95 (ms) 0.98 / 3.65 0.77 / 3.29 0.76 / 3.42 1.27× / 1.11× 0.99 / 1.04

Medians of 3. The command for each rep: PERF_ONLY=list,query,journal,open calternal-perf --bin <bin> --work <w> --sizes 100k --runs 1 --cold-runs 1 --seed 1 --idle-secs 0. This reproduces the Option A comment (2.49× / 1.16×).

Cost breakdown: cold first page and cold index

The first page waits for the first metadata commit, and that commit waits for the whole identity phase. Head's scan phase timers on dev-01 at 100k (shared host, diagnostic only): stat 0.73 s · classify 0.32 · read_heads 1.37 · identities 5.40 · describe 29.5 (in batches; the first page lands after its first batch). Dev doesn't log phases; the earlier dev-01 pass put its identity phase near 0.7 s. The identity phase is the gap.

Callgrind, 10k cold start, same fixture (instructions, inclusive):

piece head dev note
scan::identify_many 1,008 M 213 M +795 M
↳ flush_identities → Index::enqueue (stage into the TEMP overlay with 3 indexes + known_ids) 537 M – on the scan's critical path
↳ paths_of / ids_at_folds 112 M / 64 M (map)
identity writer thread (index::apply, own connection) 543 M ~0 dev only appends the log
classify extra (page_after + ids_at_folds) 200 M (map)
JSON/markdown description (meta::described) 3.35 G 3.34 G not a cause
reading heads (read_ids) 486 M 488 M not a cause (the fs walk and reader checks cost the same)
metadata store (MetaDb::apply / end_fresh) 2.19 G / 0.75 G 2.18 G / 0.63 G about flat

The writer pattern alone was replayed with Python + SQLite 3.53.3 at 100k identities on quiet perf-01. Durability was kept: log fsync per 4,000-row group, synchronous=FULL. Read the shares, not the absolute numbers (Python adds per-row overhead to every variant):

variant wall vs head
dev pattern: log append + fsync only 0.42 s
head pattern (TEXT PK + fold index + per-row totals trigger + TEMP staging + commit per group, 4 MiB cache) 7.44 s
− TEMP staging 6.84 s −0.6 s
− staging − per-row totals trigger (count once after) 4.86 s −2.0 s for the trigger
… + 64 MiB cache, still a commit per group 4.56 s
… + one commit per fill, 4 MiB cache 4.32 s
… + one commit per fill and 64 MiB fill cache 1.68 s −5.8 s (the page churn of rewriting the B-tree each group)
… same + indexes built after the fill ("best") 1.50 s
integer rowid key + unique id index (instead of TEXT PK) 5.11 s no gain (+0.25 s)
WITHOUT ROWID 13.08 s much worse
inserts in id order instead of path order 4.11 s −0.75 s
synchronous=NORMAL instead of FULL 7.42 s none

fsync count from a cold run under strace -f -T -e fsync,fdatasync on perf-01: head made 249 fsyncs (4.75 s of cumulative fsync wall time over the cold run), dev 157 (3.83 s). The extra +92 are all on ids.db-wal 65, ids.db 16 and ids.jsonl +7, for 0.97 s vs 0.12 s on the identity files, spread over the whole fill. fsyncs and FULL sync are not the main cost. The main cost is the B-tree work per group (TEXT keys, fold index, trigger rows) through a small cache, plus staging every row twice (TEMP overlay, then the projection).

Not measured: a usable strace -c syscall count for head at 100k on dev-01. Under strace -f the first page passed its own deadline ("note discovery has not produced a validated page yet") and the run aborted. The fsync-only trace above replaces it.

Steady-state regressions: query shape is unchanged, they overlap the warm start-up scan

  • The list and journal SQL are byte-identical between head and dev (meta.rs candidate_page and the store are unchanged), and so is the meta.db schema. EXPLAIN QUERY PLAN on a copy of each finished Home is identical:
    • tag page: SEARCH tags USING PRIMARY KEY + SEARCH n USING INDEX sqlite_autoindex_notes_1 + temp B-tree for ORDER BY
    • big page: SCAN n USING INDEX notes_updated
    • meta.db is 206,735 vs 206,710 pages.
  • The harness measures within about 2 s of the server start, while the warm start-up scan is still running. A side test started each binary on its own indexed Home and timed the same requests twice: right away, then again after 25 s.
median of 3 (ms) during scan: head / dev / devB head/dev after settle: head / dev / devB head/dev
page_tag 67.9 / 51.9 / 52.0 1.31× 50.6 / 50.0 / 50.5 1.01×
page_big 19.2 / 14.6 / 14.4 1.31× 14.6 / 14.4 / 14.4 1.01×
journal_page 2.15 / 1.69 / 1.68 1.27× 1.73 / 1.55 / 1.58 1.12× (0.18 ms, not attributed)
page 1.01 / 1.00 / 0.95 1.01× 0.66 / 0.71 / 0.61 0.93×

The first burst took 1.84–1.92 s on head vs 1.51–1.56 s on dev.

  • Why head's warm scan is heavier: on dev-01 at 100k, head's classify takes 1.8–3.6 s. Callgrind of a 10k warm start: scan::run 535 M vs 144 M instructions. Head runs 250 × 2 IN (…) lookups (ids_at_folds 171 M, paths_of 146 M) plus a separate page_after pass (94 M); dev does one hash lookup per file (id_at 33 M).
  • open.note p50 (1.27×) wasn't split by this test. It runs later in the harness and probably overlaps too, but that's unmeasured, so it needs the gate.

Three designs, simplest first

design what changes predicted at 100k on perf-01 (from the shares above) keeps the paging goal?
C keep dev's identity layout; slice 1 ships the reader/root seam and the state split only drop C's projection (revert index.rs/scan.rs to dev's map + log) every hot path = dev (it is dev's code) no: idle RSS stays dev's (~+10%) and ten active 100k Homes hold ten maps; the paging goal moves to a later slice
A trim the writer (kayg's option 2) ack a fill group after the log fsync; one projection transaction per fill with a 64 MiB fill-only cache; totals counted once at fill end (no per-row trigger); TEMP staging kept for reads during the fill writer cost −70 to −80% (7.44 → 1.68 s in the replay), but staging (537 M of the scan's +795 M) stays on the critical path: first page ≈ 2.6–3.0 s (≈1.45–1.65×), cold index ≈ 1.05–1.09×. Not flat. yes
B fill with dev's map, write the projection once During a Home's first fill, lookups use dev's in-memory map; groups are durable at the log fsync (the log already is the authority). After the scan reports complete, the writer bulk-loads the projection in one FULL transaction (sorted, indexes after, totals once), records the log identity/length, then drops the map. Warm starts never build the map. Warm classify becomes one ordered merge over entries_fold, not 500 IN queries + page_after. first page ≈ dev (the identity path is dev's); cold index ≈ dev (the ~1.5 s projection build runs after complete); cold.mem_peak ≈ dev (~290 MB, losing the 0.82× win); idle RSS = head's (paged); after-settle reads are already 1.01×, and the merge should bring the warm scan near dev, removing the 1.2–1.3× overlap yes, in steady state

Rejected by measurement:

  • integer rowid keys + a separate path index: no gain.
  • WITHOUT ROWID: 2.7× worse.
  • relaxing FULL sync: no gain.
  • deferring secondary indexes alone (Option A): −0.3 s in the replay (4.89 → 4.55 s); the first page moved 4.7 → 4.0 s in the earlier comment.

Recommendation for kayg: B. It's the only design predicted flat on every hot path that still delivers the paging goal (lower steady-state RSS, bounded caches across many Homes).

The trade-offs:

  1. Transient memory during the first fill. A Home's first fill holds dev's map, about +50 MB at 100k, as dev does all the time; it's released after the projection commits. Ten Homes filling at once would hold ten maps, so cap concurrent first fills (proposal: 2). Above a size cap (proposal: 250k notes, the same line as the metadata sorter) fall back to A's bounded path.
  2. More code than A. Index gets two lookup modes (fill map, projection) and a switch between them. A crash mid-fill or before the projection commit reuses the existing "projection does not match the log → replay" path.
  3. cold.disk_written stays +3% (the projection file). It isn't a latency path, but it's reported.

If kayg wants the least code and accepts "paging later", C is a revert: zero risk and zero perf delta. A alone doesn't meet the no-regression rule on this evidence.

Gate for whichever design is chosen: the same 3-rep rotated head/dev/dev-copy run plus the during/after warm split, and the #793 fixtures. Search is still unmeasured (see the correction above).

Artifacts are attached to this comment: perf01.tgz has the rep JSON/MD, warm split, replay output, fsync traces and EQP; dev01.tgz has the 10k/100k phase logs, replay output and harness reports. Work dirs were deleted on dev-01, perf-01 and perf-02. Both perf-lock holders (trr-hold) were stopped at about 15:40 CET (20:10 IST). Nothing merged.

## Rethink after repeated misses: where the time goes, and three designs No code changed in this lane. These are measurements of `perf/tiering-761-fix` head `43d81bc1` (binary from tree `4a5e2dc6`, sha256 `f6ac38e1…`; the later commits only change tests and formatting) against dev `41f2efa3` (`01522f72…`). The harness is `352d448a…`. All three binaries were built on dev-01 and checked by sha256 on dev-01, the control host and perf-01. ### What tiering slice 1 has to achieve From the [approved plan](https://git.kayg.org/kayg/calternal/src/commit/8b534793335ae98757a9c1f6766977c447751caf/docs/plans/0731-tiering.md) §2–3 and #761: - Replace the full `ids.jsonl` load/unload with paged SSD B-trees (ID → parent/name, child index). Use cursors of at most 50 entries and a global index cache of 64 MiB, so 100 Homes of 100k notes with ten active fit without holding every Home's id map in RAM. - Budgets: 100k index ≤ 60 s, first page ≤ 100 ms, open.note p95 ≤ 15 ms, idle RSS ≤ 100 MB. - kayg's 11-03 rule on top: **flat or better than dev on every hot path it touches.** The steady-state win is real: idle RSS is about 9% lower than dev (#issuecomment-72561), and cold peak memory is 0.82× here. The misses are all on the cold fill, plus the window right after a warm start. ### Measured (perf-01, 100k Home, seed 1, both perf locks held, three rotated reps head/dev/devB, no page-cache drop) | metric | head | dev | dev copy | head/dev | control | |---|---:|---:|---:|---:|---:| | cold.first_page (ms) | **4025** | 1812 | 1763 | **2.22×** | 0.97 | | cold.index (s) | **17.31** | 14.67 | 15.10 | **1.18×** | 1.03 | | cold.mem_peak (MB) | 239 | 290 | 287 | 0.82× | 0.99 | | cold.disk_written (MB) | 1973 | 1918 | 1919 | 1.03× | 1.00 | | home.page_tag p50 / p95 (ms) | 68.3 / 74.2 | 52.4 / 56.4 | 52.3 / 54.7 | 1.30× / 1.31× | 1.00 / 0.97 | | home.page_big p50 / p95 (ms) | 22.4 / 26.0 | 18.4 / 20.9 | 19.1 / 20.7 | 1.22× / 1.25× | 1.04 / 0.99 | | journal.page p50 / p95 (ms) | 3.03 / 4.52 | 2.39 / 2.69 | 2.49 / 3.12 | 1.26× / 1.68× | 1.04 / 1.16 | | open.note p50 / p95 (ms) | 0.98 / 3.65 | 0.77 / 3.29 | 0.76 / 3.42 | 1.27× / 1.11× | 0.99 / 1.04 | Medians of 3. The command for each rep: `PERF_ONLY=list,query,journal,open calternal-perf --bin <bin> --work <w> --sizes 100k --runs 1 --cold-runs 1 --seed 1 --idle-secs 0`. This reproduces the Option A comment (2.49× / 1.16×). ### Cost breakdown: cold first page and cold index The first page waits for the first metadata commit, and that commit waits for the whole identity phase. Head's scan phase timers on dev-01 at 100k (shared host, diagnostic only): stat 0.73 s · classify 0.32 · read_heads 1.37 · **identities 5.40** · describe 29.5 (in batches; the first page lands after its first batch). Dev doesn't log phases; the earlier dev-01 pass put its identity phase near 0.7 s. **The identity phase is the gap.** Callgrind, 10k cold start, same fixture (instructions, inclusive): | piece | head | dev | note | |---|---:|---:|---| | `scan::identify_many` | 1,008 M | 213 M | +795 M | | ↳ `flush_identities` → `Index::enqueue` (stage into the TEMP overlay with 3 indexes + `known_ids`) | 537 M | – | on the scan's critical path | | ↳ `paths_of` / `ids_at_folds` | 112 M / 64 M | (map) | | | identity writer thread (`index::apply`, own connection) | 543 M | ~0 | dev only appends the log | | classify extra (`page_after` + `ids_at_folds`) | 200 M | (map) | | | JSON/markdown description (`meta::described`) | 3.35 G | 3.34 G | **not a cause** | | reading heads (`read_ids`) | 486 M | 488 M | not a cause (the fs walk and reader checks cost the same) | | metadata store (`MetaDb::apply` / `end_fresh`) | 2.19 G / 0.75 G | 2.18 G / 0.63 G | about flat | The writer pattern alone was replayed with Python + SQLite 3.53.3 at 100k identities on quiet perf-01. Durability was kept: log fsync per 4,000-row group, `synchronous=FULL`. Read the shares, not the absolute numbers (Python adds per-row overhead to every variant): | variant | wall | vs head | |---|---:|---:| | dev pattern: log append + fsync only | 0.42 s | | | **head pattern** (TEXT PK + fold index + per-row totals trigger + TEMP staging + commit per group, 4 MiB cache) | **7.44 s** | | | − TEMP staging | 6.84 s | −0.6 s | | − staging − per-row totals trigger (count once after) | 4.86 s | **−2.0 s for the trigger** | | … + 64 MiB cache, still a commit per group | 4.56 s | | | … + one commit per fill, 4 MiB cache | 4.32 s | | | … + one commit per fill **and** 64 MiB fill cache | **1.68 s** | **−5.8 s** (the page churn of rewriting the B-tree each group) | | … same + indexes built after the fill ("best") | 1.50 s | | | integer rowid key + unique id index (instead of TEXT PK) | 5.11 s | **no gain** (+0.25 s) | | WITHOUT ROWID | 13.08 s | much worse | | inserts in id order instead of path order | 4.11 s | −0.75 s | | `synchronous=NORMAL` instead of FULL | 7.42 s | **none** | fsync count from a cold run under `strace -f -T -e fsync,fdatasync` on perf-01: head made 249 fsyncs (4.75 s of cumulative fsync wall time over the cold run), dev 157 (3.83 s). The extra +92 are all on `ids.db-wal` 65, `ids.db` 16 and `ids.jsonl` +7, for 0.97 s vs 0.12 s on the identity files, spread over the whole fill. **fsyncs and FULL sync are not the main cost.** The main cost is the B-tree work per group (TEXT keys, fold index, trigger rows) through a small cache, plus staging every row twice (TEMP overlay, then the projection). Not measured: a usable `strace -c` syscall count for head at 100k on dev-01. Under `strace -f` the first page passed its own deadline ("note discovery has not produced a validated page yet") and the run aborted. The fsync-only trace above replaces it. ### Steady-state regressions: query shape is unchanged, they overlap the warm start-up scan - The list and journal SQL are byte-identical between head and dev (`meta.rs` candidate_page and the store are unchanged), and so is the `meta.db` schema. `EXPLAIN QUERY PLAN` on a copy of each finished Home is identical: - tag page: `SEARCH tags USING PRIMARY KEY` + `SEARCH n USING INDEX sqlite_autoindex_notes_1` + temp B-tree for ORDER BY - big page: `SCAN n USING INDEX notes_updated` - `meta.db` is 206,735 vs 206,710 pages. - The harness measures within about 2 s of the server start, while the warm start-up scan is still running. A side test started each binary on its own indexed Home and timed the same requests twice: right away, then again after 25 s. | median of 3 (ms) | during scan: head / dev / devB | head/dev | after settle: head / dev / devB | head/dev | |---|---|---:|---|---:| | page_tag | 67.9 / 51.9 / 52.0 | 1.31× | 50.6 / 50.0 / 50.5 | **1.01×** | | page_big | 19.2 / 14.6 / 14.4 | 1.31× | 14.6 / 14.4 / 14.4 | **1.01×** | | journal_page | 2.15 / 1.69 / 1.68 | 1.27× | 1.73 / 1.55 / 1.58 | 1.12× (0.18 ms, not attributed) | | page | 1.01 / 1.00 / 0.95 | 1.01× | 0.66 / 0.71 / 0.61 | 0.93× | The first burst took 1.84–1.92 s on head vs 1.51–1.56 s on dev. - Why head's warm scan is heavier: on dev-01 at 100k, head's classify takes 1.8–3.6 s. Callgrind of a 10k warm start: `scan::run` 535 M vs 144 M instructions. Head runs 250 × 2 `IN (…)` lookups (`ids_at_folds` 171 M, `paths_of` 146 M) plus a separate `page_after` pass (94 M); dev does one hash lookup per file (`id_at` 33 M). - open.note p50 (1.27×) wasn't split by this test. It runs later in the harness and probably overlaps too, but that's unmeasured, so it needs the gate. ### Three designs, simplest first | | design | what changes | predicted at 100k on perf-01 (from the shares above) | keeps the paging goal? | |---|---|---|---|---| | **C** | keep dev's identity layout; slice 1 ships the reader/root seam and the state split only | drop C's projection (revert `index.rs`/`scan.rs` to dev's map + log) | every hot path = dev (it *is* dev's code) | **no**: idle RSS stays dev's (~+10%) and ten active 100k Homes hold ten maps; the paging goal moves to a later slice | | **A** | trim the writer (kayg's option 2) | ack a fill group after the log fsync; one projection transaction per fill with a 64 MiB fill-only cache; totals counted once at fill end (no per-row trigger); TEMP staging kept for reads during the fill | writer cost −70 to −80% (7.44 → 1.68 s in the replay), but staging (537 M of the scan's +795 M) stays on the critical path: **first page ≈ 2.6–3.0 s (≈1.45–1.65×)**, cold index ≈ 1.05–1.09×. **Not flat.** | yes | | **B** | fill with dev's map, write the projection once | During a Home's *first* fill, lookups use dev's in-memory map; groups are durable at the log fsync (the log already is the authority). After the scan reports complete, the writer bulk-loads the projection in one FULL transaction (sorted, indexes after, totals once), records the log identity/length, then drops the map. Warm starts never build the map. Warm classify becomes one ordered merge over `entries_fold`, not 500 `IN` queries + `page_after`. | **first page ≈ dev** (the identity path *is* dev's); **cold index ≈ dev** (the ~1.5 s projection build runs after complete); cold.mem_peak ≈ dev (~290 MB, losing the 0.82× win); idle RSS = head's (paged); after-settle reads are already 1.01×, and the merge should bring the warm scan near dev, removing the 1.2–1.3× overlap | yes, in steady state | Rejected by measurement: - integer rowid keys + a separate path index: no gain. - WITHOUT ROWID: 2.7× worse. - relaxing FULL sync: no gain. - deferring secondary indexes alone (Option A): −0.3 s in the replay (4.89 → 4.55 s); the first page moved 4.7 → 4.0 s in the earlier comment. **Recommendation for kayg: B.** It's the only design predicted flat on every hot path that still delivers the paging goal (lower steady-state RSS, bounded caches across many Homes). The trade-offs: 1. **Transient memory during the first fill.** A Home's first fill holds dev's map, about +50 MB at 100k, as dev does all the time; it's released after the projection commits. Ten Homes filling at once would hold ten maps, so cap concurrent first fills (proposal: 2). Above a size cap (proposal: 250k notes, the same line as the metadata sorter) fall back to A's bounded path. 2. **More code than A.** Index gets two lookup modes (fill map, projection) and a switch between them. A crash mid-fill or before the projection commit reuses the existing "projection does not match the log → replay" path. 3. **cold.disk_written stays +3%** (the projection file). It isn't a latency path, but it's reported. If kayg wants the least code and accepts "paging later", C is a revert: zero risk and zero perf delta. A alone doesn't meet the no-regression rule on this evidence. Gate for whichever design is chosen: the same 3-rep rotated head/dev/dev-copy run plus the during/after warm split, and the #793 fixtures. Search is still unmeasured (see the correction above). Artifacts are attached to this comment: `perf01.tgz` has the rep JSON/MD, warm split, replay output, fsync traces and EQP; `dev01.tgz` has the 10k/100k phase logs, replay output and harness reports. Work dirs were deleted on dev-01, perf-01 and perf-02. Both perf-lock holders (`trr-hold`) were stopped at about 15:40 CET (20:10 IST). Nothing merged.
Author
Owner

housekeeping #1009: needs-decision.

Old paged identity projection has unresolved cold-index/first-page regressions and A/B/C design choices, plus option-B WIP #953. Retain the measurements; do not revive the old tiering stack.

original head: 487e058908d3b2f24389627741c33907ee7beb25. against dev cd7f84432febfc63b788fbb9b9f9950452982d74, git cherry reports 68 unmatched and 0 equivalent patches. the landing audit lists each candidate missing commit and stable patch match in pr-792.md; unmatched patches can include superseded behavior.

left open, source branch unchanged. no rebase or new test claim for this decision lane.

housekeeping #1009: needs-decision. Old paged identity projection has unresolved cold-index/first-page regressions and A/B/C design choices, plus option-B WIP #953. Retain the measurements; do not revive the old tiering stack. original head: `487e058908d3b2f24389627741c33907ee7beb25`. against dev `cd7f84432febfc63b788fbb9b9f9950452982d74`, git cherry reports 68 unmatched and 0 equivalent patches. the [landing audit](https://git.kayg.org/attachments/fe3c09dc-dea3-47a0-92e2-b44a777083e5) lists each candidate missing commit and stable patch match in `pr-792.md`; unmatched patches can include superseded behavior. left open, source branch unchanged. no rebase or new test claim for this decision lane.
Author
Owner

Superseded by tiering v3 (#968/#970/#1008) and #953. The branch and original tips are archived under archive/ (see #1009), so nothing is lost; useful regression vectors are noted there for the active lanes. Closing.

Superseded by tiering v3 (#968/#970/#1008) and #953. The branch and original tips are archived under `archive/` (see #1009), so nothing is lost; useful regression vectors are noted there for the active lanes. Closing.
kayg closed this pull request 2026-11-07 21:21:13 +00:00
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
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!792
No description provided.