Memory: compact id index, SQLite caches let go (#398) #428

Closed
kayg wants to merge 0 commits from issue/398-floor into dev
Owner

Part of #398, lowering the idle floor. Based on #417. It doesn't depend on the allocator PR; the numbers below include it.

What

  1. A compact id index. The index holds every note, so it was most of an idle server's live heap at 100k notes.
    • Its two lookups were maps from a hash to a bucket, 40 bytes a note each. They are now hash tables of slot numbers only, 4 bytes a note, found by hashing what the slot holds.
    • Each note's id and path now share one allocation, where they took two.
    • A path that two notes claim still answers with the newest note, and with the older one again once the newer one goes. A new test checks this, and answers_like_plain_maps still passes.
    • New dependency: hashbrown 0.16, already in the lock file, with no default features.
  2. SQLite page caches let go after walks over a whole table. Each of these kept up to 2 MiB of pages no later work needs:
    • the search writer after a catch-up: PRAGMA shrink_memory now runs with its quiet-moment checkpoint, so it costs no new wake-up;
    • the reader that scans the whole index for the typo vocabulary;
    • the list store's writer after it reads every stamp at start.

Numbers

Heap profile of an idle server at 100k notes (staging, heaptrack, same steps; live heap when it was killed while idle):

before compact index + caches let go
live heap MB 28.7 21.3 11.3
id index MB 17.5 10.0 10.0
SQLite MB 10.7 10.7 0.7

Perf VM, 100k notes, 3 runs or more each, dropped caches. Values are p50 / p95. Every column has two heaps, trim and no THP, as in the allocator PR. The last column adds this PR.

metric target dev (3) 2 heaps + trim + no THP (4) + this PR (3)
idle.rss MB < 50 183 66.8 39.8
mem.peak MB < 120 136 / 196 110 / 121 98.2 / 109
mem.after_search MB 128 91.7 80.9
search.query ms p95 < 50 4.88 / 20.0 5.37 / 23.3 4.98 / 19.7
open.note ms p95 < 5 1.67 / 3.14 1.63 / 4.94 1.62 / 3.91
home.first_page ms p95 < 50 15.8 / 72.4 23.4 / 49.2 29.4 / 40.2
sync.save_ack ms p95 < 15 7.13 / 13.1 8.45 / 16.6 8.99 / 12.8
idle CPU, wake-ups/s 0 0, 0 0, 0 0, 0

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

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Part of #398, lowering the idle floor. **Based on #417.** It doesn't depend on the allocator PR; the numbers below include it. ## What 1. **A compact id index.** The index holds every note, so it was most of an idle server's live heap at 100k notes. - Its two lookups were maps from a hash to a bucket, 40 bytes a note each. They are now hash tables of slot numbers only, 4 bytes a note, found by hashing what the slot holds. - Each note's id and path now share one allocation, where they took two. - A path that two notes claim still answers with the newest note, and with the older one again once the newer one goes. A new test checks this, and `answers_like_plain_maps` still passes. - New dependency: `hashbrown` 0.16, already in the lock file, with no default features. 2. **SQLite page caches let go after walks over a whole table.** Each of these kept up to 2 MiB of pages no later work needs: - the search writer after a catch-up: `PRAGMA shrink_memory` now runs with its quiet-moment checkpoint, so it costs no new wake-up; - the reader that scans the whole index for the typo vocabulary; - the list store's writer after it reads every stamp at start. ## Numbers Heap profile of an idle server at 100k notes (staging, heaptrack, same steps; live heap when it was killed while idle): | | before | compact index | + caches let go | |---|---|---|---| | live heap MB | 28.7 | 21.3 | **11.3** | | id index MB | 17.5 | 10.0 | 10.0 | | SQLite MB | 10.7 | 10.7 | **0.7** | Perf VM, 100k notes, 3 runs or more each, dropped caches. Values are p50 / p95. Every column has two heaps, trim and no THP, as in the allocator PR. The last column adds this PR. | metric | target | dev (3) | 2 heaps + trim + no THP (4) | **+ this PR (3)** | |---|---|---|---|---| | idle.rss MB | < 50 | 183 | 66.8 | **39.8** | | mem.peak MB | < 120 | 136 / 196 | 110 / 121 | **98.2 / 109** | | mem.after_search MB | | 128 | 91.7 | 80.9 | | search.query ms | p95 < 50 | 4.88 / 20.0 | 5.37 / 23.3 | 4.98 / 19.7 | | open.note ms | p95 < 5 | 1.67 / 3.14 | 1.63 / 4.94 | 1.62 / 3.91 | | home.first_page ms | p95 < 50 | 15.8 / 72.4 | 23.4 / 49.2 | 29.4 / 40.2 | | sync.save_ack ms | p95 < 15 | 7.13 / 13.1 | 8.45 / 16.6 | 8.99 / 12.8 | | idle CPU, wake-ups/s | 0 | 0, 0 | 0, 0 | 0, 0 | Checks: `cargo fmt`, `clippy -D warnings` and the workspace tests are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Memory: remove the whole-Home notes.list (#398)
Some checks failed
CI / server (pull_request) Successful in 33s
CI / rust (pull_request) Successful in 2m35s
CI / web (pull_request) Failing after 44m28s
44306033b1
`notes.list` without input built every note of the Home into one answer
(100,000 objects at 100k notes, hundreds of megabytes at once). The app
only asks for pages, and the form was deprecated "for one release".
Without input it now returns the first page of 50, like `{}`.

The perf harness no longer calls it: `home.first_list`, `home.list` and
`mem.after_list` are gone (`mem.after_first_page` instead), and the cold
run opens the Home with a first page (`cold.first_page`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
The id index holds every note, so it is most of what an idle server
keeps at 100,000 notes. Its two lookups were maps from a hash to a slot
bucket (40 bytes a note each), and each note's id and path were two
allocations. Now the lookups are hash tables of slot numbers only (4
bytes a note), found by hashing what the slot holds, and id and path
share one allocation. A path that two notes claim still answers with
the newest one, and with the older one again once the newer one goes.

A heap profile of an idle server at 100,000 notes (staging, heaptrack,
same steps): live heap 28.7 MB -> 21.3 MB; the index 17.5 MB -> 10 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Memory: let go of SQLite page caches after whole-table walks (#398)
Some checks failed
CI / server (pull_request) Successful in 25s
CI / rust (pull_request) Successful in 2m11s
CI / web (pull_request) Failing after 42m45s
f076915206
Three connections kept full page caches on an idle server, filled by
work that never needs those pages again: the search writer after a
catch-up (it now runs `PRAGMA shrink_memory` with its quiet-moment
checkpoint), the reader that scans the whole index for the typo
vocabulary, and the list store's writer after it reads every stamp at
start.

A heap profile of an idle server at 100,000 notes (staging, heaptrack,
same steps, on top of the compact id index): live heap 21.3 MB ->
11.3 MB; SQLite 10.7 MB -> 0.7 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Landed in dev via train m.

Landed in dev via train m.
kayg closed this pull request 2026-10-15 16:19:49 +00:00
Some checks failed
CI / server (pull_request) Successful in 25s
CI / rust (pull_request) Successful in 2m11s
CI / web (pull_request) Failing after 42m45s

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