Memory: a cold open holds less at once (#398) #467

Closed
kayg wants to merge 0 commits from train/2026-10-16p into dev
Owner

Part of #398: the cold-open peak goes under 150 MB. Replaces #459, rebased onto current dev (train m, #444).

Prod has #429 without this PR, so cold indexing on a rotational disk is about 30% slower there right now. This PR wins that time back.

What

A cold open of 100,000 notes held three big things at once. Each is fixed here:

  • Parsed links: each note's list reused the buffer of the found links, three times the size a (target, line) pair needs. That is how into_iter().map().collect() behaves: it keeps the source allocation. At the peak this was 57 MB of heap (heaptrack). The pairs now get a list of their own, sized to fit.
  • Scan announcements: until the id index was on disk, the scan held a full event, JSON included, for every note it found. It now keeps only the kind, id and path, and builds each thousand events as they're recorded. This is the change from the closed #388, which didn't move the peak back when the 64 MiB cache dominated it.
  • Reads at once: the scan, the note list and search each read files on threads of their own, about 75 threads in total. They now share one limit (reads.rs) of 16 reads at a time. A permit is taken before a thread is asked for, so waiting costs no thread.

Numbers, perf VM, 100k notes

SSD (sda). 3 runs each, every column with #427, #428 and #429:

cold.mem_peak MB cold.index s
without this PR 185 (177–190) 25.3 (21.3–26.2)
links + events, no read limit 137 23.7
+ read limit 32 129 24.8
+ read limit 16 (this PR) 123 (121–124) 26.6 (19.0–29.8); 18.0 and 19.3 in a later pair of runs
+ read limit 8 118 23.2

Rotational disk (sdb1, ext4, ROTA=1). The Home and work folder are on the HDD; 3 to 6 runs each:

cold.index s cold.mem_peak MB
dev, 2 heaps (6) 41.8 (40.0–51.9) 313–328
#417 alone (3) 41.6 314
#429 alone (3) 55.2 (47.5–56.6) 210
all #398 PRs without this one (3) 52.8 187
all #398 PRs with this one (6) 44.8 (41.2–59.6) 123

The rotational-disk cost with this PR is +7% against dev, inside the 10% budget. A bigger cache only for making the indexes (32 or 64 MiB) didn't index faster on either disk and raised the peak to 175 and 230 MB, so it isn't used.

Checks: cargo fmt, clippy -D warnings and the workspace tests are clean (Rust 1.99.0).

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

(Replaces #462, merged with current dev. Review on #462.)

Part of #398: the cold-open peak goes under 150 MB. **Replaces #459**, rebased onto current dev (train m, #444). **Prod has #429 without this PR,** so cold indexing on a rotational disk is about 30% slower there right now. This PR wins that time back. ## What A cold open of 100,000 notes held three big things at once. Each is fixed here: - **Parsed links:** each note's list reused the buffer of the found links, three times the size a (target, line) pair needs. That is how `into_iter().map().collect()` behaves: it keeps the source allocation. At the peak this was 57 MB of heap (heaptrack). The pairs now get a list of their own, sized to fit. - **Scan announcements:** until the id index was on disk, the scan held a full event, JSON included, for every note it found. It now keeps only the kind, id and path, and builds each thousand events as they're recorded. This is the change from the closed #388, which didn't move the peak back when the 64 MiB cache dominated it. - **Reads at once:** the scan, the note list and search each read files on threads of their own, about 75 threads in total. They now share one limit (`reads.rs`) of 16 reads at a time. A permit is taken before a thread is asked for, so waiting costs no thread. ## Numbers, perf VM, 100k notes **SSD (sda).** 3 runs each, every column with #427, #428 and #429: | | cold.mem_peak MB | cold.index s | |---|---|---| | without this PR | 185 (177–190) | 25.3 (21.3–26.2) | | links + events, no read limit | 137 | 23.7 | | + read limit 32 | 129 | 24.8 | | **+ read limit 16 (this PR)** | **123** (121–124) | 26.6 (19.0–29.8); 18.0 and 19.3 in a later pair of runs | | + read limit 8 | 118 | 23.2 | **Rotational disk** (sdb1, ext4, ROTA=1). The Home and work folder are on the HDD; 3 to 6 runs each: | | cold.index s | cold.mem_peak MB | |---|---|---| | dev, 2 heaps (6) | 41.8 (40.0–51.9) | 313–328 | | #417 alone (3) | 41.6 | 314 | | #429 alone (3) | **55.2** (47.5–56.6) | 210 | | all #398 PRs without this one (3) | 52.8 | 187 | | **all #398 PRs with this one (6)** | **44.8** (41.2–59.6) | **123** | The rotational-disk cost with this PR is +7% against dev, inside the 10% budget. A bigger cache only for making the indexes (32 or 64 MiB) didn't index faster on either disk and raised the peak to 175 and 230 MB, so it isn't used. Checks: `cargo fmt`, `clippy -D warnings` and the workspace tests are clean (Rust 1.99.0). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T (Replaces #462, merged with current dev. Review on #462.)
kayg added 10 commits 2026-10-16 03:17:15 +00:00
Toast wraps only between words (#434); give slow WebKit specs a time budget (#442)
Some checks failed
CI / server (pull_request) Successful in 21s
CI / rust (pull_request) Successful in 1m52s
CI / web (pull_request) Failing after 47m3s
27f2185bc2
Each toast letter is an inline-block, so a line could break after any letter. Letters are
now grouped in nowrap word spans. opacity.spec and width.spec got a longer time budget; no
assertion changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Toast capsule keeps the 16 px side gutter on phones (#434)
Some checks failed
CI / server (pull_request) Successful in 22s
CI / rust (pull_request) Successful in 1m49s
CI / web (pull_request) Failing after 47m24s
86340a9c1e
Sonner's column on a phone is the window's width pushed in by its side offset, so it ran 16 px
past the right edge and the capsule touched it. The column is now 100vw - 32px wide. The new
spec checks the gutter at 320, 360 and 375.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Memory: a cold open holds less at once (#398)
Some checks failed
CI / server (pull_request) Successful in 26s
CI / rust (pull_request) Successful in 2m34s
CI / web (pull_request) Failing after 37m29s
245fbab59f
Three things a cold open of 100,000 notes kept at the same time:

- Each note's parsed links reused the found links' buffer, three times
  the size a (target, line) pair needs (`into_iter().map().collect()`
  keeps the allocation): 57 MB of the heap peak. They now get a list of
  their own, sized to fit.
- The scan announced every note it found with a full event (JSON
  included) held until the id index was on disk: it now keeps just the
  kind, id and path, and makes each thousand events as they're recorded
  (from #388).
- The scan, the note list and search each read files on a thread of
  their own, about 75 threads at once. They now share a limit of 16
  reads at a time; waiting costs no thread.

Perf VM, 100k notes, 3 runs each, with #427-#429: cold.mem_peak 185 ->
123 MB, cold.index 25.3 -> 18-27 s (same). On a rotational disk the
read limit also wins back what #429's smaller cache cost: cold.index
41.8 s on dev, 55.2 s with #429 alone, 44.8 s with this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
`names::nfc` and `names::fold` normalised every name, though most are
plain ASCII, where NFC changes nothing and lowercasing is ASCII's.
Reading the id index of 100,000 notes back took 155-170 ms, now
45 ms (staging, release build); scans and link parsing fold every
name too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Memory: an idle Home lets go of its id index (#398)
Some checks failed
CI / server (pull_request) Successful in 23s
CI / rust (pull_request) Successful in 2m23s
CI / web (pull_request) Failing after 36m18s
d8ebacdfc3
The id index (10 MB at 100,000 notes) was most of an idle server's
heap. A Home unused for `Options::unload_after` (10 minutes by default;
`CALTERNAL_NOTES_UNLOAD_SECS`, 0 keeps it for good) drops it, once no
client of that person is connected, no note is open and no scan or job
runs. Changes queued for `ids.jsonl` are written first; the next lookup
reads the file again, as a start does (45 ms at 100,000 notes).

Nothing polls an idle server: each lookup notes the time, and one
short-lived thread waits for the quiet stretch to end, unloads, trims
the heap and exits. While someone is connected it looks again once a
period.

`EventRecorder::connected` (default false) lets a plugin ask whether a
person has a live view open; the engine answers from its live
listeners.

Perf VM, 100k notes: idle RSS 19-25 MB unloaded (34-46 before), the
first note opened after unloading 125-265 ms with the page cache
dropped (145-243 on a rotational disk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Build: fat LTO and one codegen unit for release (#398)
Some checks failed
CI / server (pull_request) Successful in 24s
CI / rust (pull_request) Successful in 1m58s
CI / web (pull_request) Failing after 43m30s
3a6cb70b75
Perf VM, 100k notes, 4-5 interleaved runs each, opt-level 3 against
opt-level 3 + fat LTO + codegen-units 1 (p50 of runs):

- binary 24.8 -> 17.6 MB on disk; its resident code 11.0 -> 9.4 MB,
  file-backed RSS 14.7 -> 13.2 MB
- search.query p50 5.3 -> 4.3 ms, p95 19.2 -> 17.2 ms
- open.note p95 2.9 -> 3.5 ms, cold.index 22.5 -> 23.4 s (both noise)
- storm 2,556 -> 3,435 edits/s, save ack p95 8.0 -> 5.8 ms
- release build 103 -> 157 s on staging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Merge remote-tracking branch 'origin/issue/434-442-small-2' into train/2026-10-16p
Some checks failed
CI / server (pull_request) Successful in 28s
CI / rust (pull_request) Successful in 3m3s
CI / web (pull_request) Failing after 40m41s
7c0e956ab2
Author
Owner

This PR's commits are on dev (landed). Forgejo kept it open and its merge endpoint answers 405, so it is closed by hand.

This PR's commits are on dev (landed). Forgejo kept it open and its merge endpoint answers 405, so it is closed by hand.
kayg closed this pull request 2026-10-21 11:33:00 +00:00
Some checks failed
CI / server (pull_request) Successful in 28s
CI / rust (pull_request) Successful in 3m3s
CI / web (pull_request) Failing after 40m41s

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!467
No description provided.