Skip to content

Commit a4e2d5c

Browse files
authored
🤖 perf: keep the config snapshot while it still matches the file (#5750)
## Summary An edit no longer throws away a config snapshot that still matches `config.json`. With a reader on every event-loop turn (the startup tombstone heal sweep does this), one edit now parses `config.json` 2 times instead of 4. At 4,712 workspaces, that edit is 66 ms faster (205 ms to 138 ms, 95% CI -40.1% to -23.9%). In the startup scenario at 4,700 workspaces, the heal sweep now does 2 parses per save instead of 4. Refs #5727 ## Background The edit's own fresh read (`readConfigOrDefault` inside `enqueueConfigEditEffect`) cleared the shared snapshot, and the save cleared it again after the rename. Each clear forced the next reader to parse the same bytes again. So with a reader on every turn, one edit cost 4 parses: 1. The edit's own read under the lock. 2. A reader during the save, because the edit's read had cleared the snapshot. 3. A reader between the rename and the end of the save. 4. The first reader after the save, because the save cleared the snapshot that parse 3 stored. This change removes parses 2 and 4. The floor is 2: the edit's fresh read, and one read that publishes the saved file. ## Implementation All in `src/node/config/index.ts` (9 changed code lines plus comments): 1. `enqueueConfigEditEffect`: the transform reads with `readConfigOrDefault({ keepSnapshot: true })`. 2. `readConfigOrDefault`: with `keepSnapshot`, it clears the snapshot as before, then gives it back only after a successful, cacheable read. A failed, missing or lenient read leaves it cleared. Otherwise the edit gate's `loadConfigOrDefault` would hit the old snapshot, never retry the read that clears a recorded load failure, and refuse every edit until a restart. 3. `saveConfigEffect`: after the write, it keeps a snapshot only if the snapshot's stat key equals the current stat key of `config.json`. That snapshot came from a reader that parsed the real bytes on disk, whoever wrote them. The cost is one `statSync` per save. The bench file `src/node/config/configScale.bench.ts` adds live-shaped synthetic rows (no real data). It is the file from the unmerged bench branch, unchanged. ## Invariants and the tests that guard them All tests are in `src/node/config/snapshot.test.ts`. The existing cases are unchanged. | Invariant | Test | |---|---| | With a reader on every event-loop turn, one edit reads `config.json` exactly 2 times (main: 4) | `parses config.json twice per edit while a reader runs on every event-loop turn` (new) | | A reader during an edit gets the committed pre-edit snapshot, never the transform's object | `serves the committed snapshot, never the transform's object, to readers during an edit` (new) | | Only a successful read gives the snapshot back: after a transient `EIO` in the edit's read, the next edit succeeds without a file change or restart | `accepts the next edit after the edit's own read fails once on a warm snapshot` (new) | | A failed save clears the snapshot, so readers never see an unsaved in-memory change | `drops the snapshot when a save fails` (new) | | A snapshot is served only for the file it was parsed from, and another writer's rename between our rename and the save's end wins | `invalidates on atomic external replacement, including same-size bytes and mtime`, `isolates edits and reloads the saved snapshot once`, `sees an external atomic replacement between our rename and save completion` (existing, unchanged) | | Corrupt and lenient files behave as before | `does not reuse a lenient structurally invalid load for a strict read`, `does not cache structurally invalid entries after an unrelated save`, `treats stat failures as cache misses`, `does not reuse a cached snapshot after the file disappears` (existing, unchanged) | Mutation check, run locally against `snapshot.test.ts` (each mutant fails only the expected test): | Mutation of `index.ts` | Failing test | |---|---| | Both changes reverted (main) | reader-per-turn test (reads 4 times) and committed-snapshot test | | The transform does not keep the snapshot | reader-per-turn test (3 reads) and committed-snapshot test | | The save always clears the snapshot | reader-per-turn test (3 reads) | | The kept snapshot comes back even after a failed read | `EIO` recovery test | | No clear on a failed save | failed-save test (reader sees `"Unsaved"`) | | The transform's object is stored as the snapshot | committed-snapshot test | ## Measurements Host: shared Coder host (AMD EPYC 9454P), Node 22.19, `make bench-compare ROUNDS=10` (interleaved base and head processes, paired 95% CI on per-round log ratio). - Base `0c5e35d81638e85ed420c4b7c943d86ed502a2f7` (origin/main merge-base), head `4fa387b2e78bf32f810d7c21bc1f488bd33ce265`. ### A/A noise floor: `make bench-compare BENCH=config BASE=HEAD ROUNDS=10` Both sides are the same commit (`712637f68e`, the product code of this PR). Result: 0 `faster` or `slower` verdicts out of 42 rows. ``` runtime node | rounds 10 | base 712637f | head 712637f loadavg start 18.98 30.03 35.74 | end 13.57 17.40 22.90 ``` <details> <summary>A/A table (42 rows)</summary> ``` benchmark base head delta 95% CI verdict config-a28db80a: loadConfigOrDefault (fresh Config, 4500 workspaces) 11.82 ms 12.02 ms +1.4% [-1.6%, +4.6%] ~ config-a28db80a: loadConfigOrDefault (snapshot hit) 3.29 µs 3.27 µs -2.1% [-5.8%, +1.7%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (50) 510.54 µs 510.23 µs +13.1% [-14.4%, +49.4%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (500) 3.11 ms 3.13 ms +5.1% [-5.0%, +16.4%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (5000) 32.37 ms 32.28 ms -0.2% [-2.2%, +1.7%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (4712) 30.26 ms 30.70 ms +1.0% [-1.9%, +4.0%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (50) 3.01 µs 3.02 µs +0.7% [-0.4%, +1.8%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (500) 3.03 µs 3.06 µs +0.9% [-1.2%, +3.0%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (5000) 3.03 µs 3.04 µs +0.4% [-1.2%, +2.0%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (4712) 3.02 µs 3.04 µs +0.6% [-1.2%, +2.5%] ~ configScale-334200bf: findWorkspace(last id) (50) 3.12 µs 3.13 µs -0.7% [-2.5%, +1.1%] ~ configScale-334200bf: findWorkspace(last id) (500) 3.14 µs 3.12 µs -0.5% [-1.6%, +0.6%] ~ configScale-334200bf: findWorkspace(last id) (5000) 3.27 µs 3.30 µs +0.2% [-2.9%, +3.3%] ~ configScale-334200bf: findWorkspace(last id) (4712) 3.32 µs 3.33 µs +0.8% [-1.3%, +2.9%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (50) 1.36 ms 1.34 ms -2.5% [-6.9%, +2.1%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (500) 12.77 ms 12.67 ms -1.5% [-6.9%, +4.3%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (5000) 136.93 ms 134.83 ms -5.4% [-14.0%, +4.0%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (4712) 128.04 ms 126.63 ms -2.5% [-7.7%, +3.0%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (50) 671.20 µs 670.10 µs +1.2% [-3.8%, +6.4%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (500) 6.52 ms 6.65 ms +2.6% [-1.7%, +7.0%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (5000) 69.80 ms 71.89 ms -6.0% [-23.8%, +16.0%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (4712) 66.47 ms 68.29 ms +2.4% [-2.9%, +7.9%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (50) 3.54 µs 3.52 µs -1.9% [-5.3%, +1.5%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (500) 3.90 µs 3.84 µs -0.9% [-3.0%, +1.3%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (5000) 5.67 µs 5.52 µs -1.0% [-3.9%, +1.9%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (4712) 5.51 µs 5.49 µs +0.4% [-2.4%, +3.2%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (50) 78.58 µs 79.11 µs +0.9% [-3.2%, +5.3%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (500) 76.70 µs 74.88 µs -4.6% [-10.9%, +2.1%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (5000) 72.46 µs 73.24 µs -16.1% [-45.2%, +28.3%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (4712) 70.61 µs 72.19 µs -5.1% [-20.4%, +13.1%] ~ configScale-334200bf: editConfig, same-value edit (50) 5.47 ms 5.69 ms +11.5% [-2.0%, +26.9%] ~ configScale-334200bf: editConfig, same-value edit (500) 17.49 ms 17.66 ms +4.1% [-1.6%, +10.1%] ~ configScale-334200bf: editConfig, same-value edit (5000) 151.67 ms 153.64 ms +1.8% [-14.1%, +20.6%] ~ configScale-334200bf: editConfig, same-value edit (4712) 139.34 ms 140.59 ms +13.7% [-12.5%, +47.7%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (50) 5.63 ms 5.58 ms -0.6% [-6.1%, +5.3%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (500) 17.75 ms 17.35 ms -0.5% [-3.1%, +2.1%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (5000) 148.56 ms 145.67 ms -1.4% [-7.5%, +5.1%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (4712) 138.72 ms 138.13 ms -6.7% [-17.6%, +5.8%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (50) 5.41 ms 5.61 ms +16.7% [-15.6%, +61.2%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (500) 17.34 ms 17.41 ms +0.0% [-20.5%, +25.8%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (5000) 151.02 ms 148.86 ms -7.3% [-26.1%, +16.2%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (4712) 137.19 ms 139.04 ms -2.9% [-12.8%, +8.1%] ~ ``` </details> ### Base vs head: `make bench-compare BENCH=config ROUNDS=10` Result: 4 `faster` verdicts, all on the reader-per-turn row. 0 `slower` verdicts. At the live-shaped size (4,712 workspaces) the reader-per-turn edit takes 66 ms less (budget: at least 35 ms). Plain edits without a concurrent reader do not change, as expected. ``` runtime node | rounds 10 | base 0c5e35d | head 4fa387b loadavg start 14.26 17.36 22.80 | end 17.83 31.33 29.39 ``` ``` benchmark base head delta 95% CI verdict configScale-334200bf: loadConfigOrDefault, fresh Config (4712) 31.70 ms 31.62 ms -8.9% [-25.5%, +11.5%] ~ configScale-334200bf: editConfig, same-value edit (4712) 139.47 ms 139.37 ms -0.6% [-6.8%, +5.9%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (4712) 142.68 ms 137.67 ms -13.0% [-24.7%, +0.5%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (50) 7.02 ms 5.63 ms -23.0% [-34.2%, -9.9%] faster configScale-334200bf: editConfig, reader on every event-loop turn (500) 23.62 ms 17.14 ms -39.0% [-54.9%, -17.6%] faster configScale-334200bf: editConfig, reader on every event-loop turn (5000) 220.55 ms 149.48 ms -34.4% [-38.5%, -30.0%] faster configScale-334200bf: editConfig, reader on every event-loop turn (4712) 204.65 ms 138.40 ms -32.5% [-40.1%, -23.9%] faster ``` <details> <summary>Full base-vs-head table (42 rows)</summary> ``` benchmark base head delta 95% CI verdict config-a28db80a: loadConfigOrDefault (fresh Config, 4500 workspaces) 12.59 ms 12.90 ms +0.9% [-2.9%, +5.0%] ~ config-a28db80a: loadConfigOrDefault (snapshot hit) 3.37 µs 3.36 µs -0.5% [-2.2%, +1.4%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (50) 532.11 µs 536.67 µs +0.7% [-2.7%, +4.2%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (500) 3.20 ms 3.14 ms -0.4% [-4.8%, +4.2%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (5000) 33.11 ms 31.97 ms -2.5% [-6.2%, +1.3%] ~ configScale-334200bf: loadConfigOrDefault, fresh Config (4712) 31.70 ms 31.62 ms -8.9% [-25.5%, +11.5%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (50) 3.11 µs 3.09 µs -0.1% [-2.4%, +2.2%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (500) 3.11 µs 3.10 µs -0.7% [-3.9%, +2.5%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (5000) 3.11 µs 3.04 µs -1.6% [-4.5%, +1.3%] ~ configScale-334200bf: loadConfigOrDefault, snapshot hit (4712) 3.11 µs 3.09 µs -3.2% [-7.1%, +0.9%] ~ configScale-334200bf: findWorkspace(last id) (50) 3.22 µs 3.19 µs -1.5% [-5.4%, +2.5%] ~ configScale-334200bf: findWorkspace(last id) (500) 3.21 µs 3.22 µs +0.9% [-3.0%, +5.0%] ~ configScale-334200bf: findWorkspace(last id) (5000) 3.33 µs 3.38 µs -7.0% [-22.7%, +11.8%] ~ configScale-334200bf: findWorkspace(last id) (4712) 3.41 µs 3.36 µs +4.7% [-10.0%, +21.9%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (50) 1.37 ms 1.32 ms +17.7% [-26.0%, +87.3%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (500) 13.17 ms 12.28 ms -8.7% [-32.9%, +24.2%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (5000) 138.94 ms 135.98 ms +1.1% [-9.6%, +13.0%] ~ configScale-334200bf: getAllWorkspaceMetadata, full build (4712) 128.80 ms 130.51 ms -2.9% [-10.9%, +5.8%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (50) 663.84 µs 659.88 µs -0.5% [-3.9%, +3.2%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (500) 6.59 ms 6.56 ms -1.4% [-4.4%, +1.6%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (5000) 69.84 ms 72.30 ms -0.3% [-4.6%, +4.3%] ~ configScale-334200bf: getAllWorkspaceMetadata, last-known probes (4712) 68.58 ms 67.88 ms -1.2% [-5.5%, +3.4%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (50) 3.61 µs 3.60 µs -1.5% [-6.1%, +3.3%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (500) 4.03 µs 3.87 µs -3.3% [-6.9%, +0.5%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (5000) 6.12 µs 5.85 µs -9.0% [-28.4%, +15.7%] ~ configScale-334200bf: getAllWorkspaceMetadata, registry memo (4712) 6.00 µs 5.73 µs -8.9% [-26.0%, +12.3%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (50) 80.81 µs 78.16 µs -5.7% [-16.9%, +6.9%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (500) 77.07 µs 77.58 µs -5.8% [-18.4%, +8.7%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (5000) 75.45 µs 74.15 µs -5.8% [-13.1%, +2.0%] ~ configScale-334200bf: getWorkspaceMetadataById(last id) (4712) 76.16 µs 73.88 µs -4.5% [-11.0%, +2.3%] ~ configScale-334200bf: editConfig, same-value edit (50) 6.17 ms 5.76 ms -17.9% [-43.1%, +18.5%] ~ configScale-334200bf: editConfig, same-value edit (500) 18.02 ms 17.55 ms -19.0% [-40.1%, +9.5%] ~ configScale-334200bf: editConfig, same-value edit (5000) 151.06 ms 151.14 ms -2.6% [-14.7%, +11.2%] ~ configScale-334200bf: editConfig, same-value edit (4712) 139.47 ms 139.37 ms -0.6% [-6.8%, +5.9%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (50) 5.86 ms 5.75 ms -9.0% [-21.2%, +5.1%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (500) 17.65 ms 17.19 ms -2.6% [-7.3%, +2.4%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (5000) 149.01 ms 147.88 ms -2.9% [-7.2%, +1.6%] ~ configScale-334200bf: editConfig, then loadConfigOrDefault (4712) 142.68 ms 137.67 ms -13.0% [-24.7%, +0.5%] ~ configScale-334200bf: editConfig, reader on every event-loop turn (50) 7.02 ms 5.63 ms -23.0% [-34.2%, -9.9%] faster configScale-334200bf: editConfig, reader on every event-loop turn (500) 23.62 ms 17.14 ms -39.0% [-54.9%, -17.6%] faster configScale-334200bf: editConfig, reader on every event-loop turn (5000) 220.55 ms 149.48 ms -34.4% [-38.5%, -30.0%] faster configScale-334200bf: editConfig, reader on every event-loop turn (4712) 204.65 ms 138.40 ms -32.5% [-40.1%, -23.9%] faster ``` </details> ### Parses per save in the startup heal-sweep scenario (counting harness, 4,700 workspaces) Setup: a synthetic fixture from `scripts/perf/workspace-scale/generate-fixture.ts` (4,700 workspaces, 19 projects, 57% archived, realistic profile, 8.5 MB `config.json`). Before start, the driver writes 2,734 one-day-old tombstones for unregistered ids, 2 one-day-old tombstones for registered ids, and 1 fresh tombstone for a registered id. It starts `node --require counter.cjs dist/cli/index.js server --no-auth` with an isolated `XUM_ROOT` and `XUM_MOCK_AI=1`. As soon as the port opens, it sends 20 `workspace.updateTitle` calls. The preload counts `readConfigOrDefault` and `saveConfig` calls while `healRemovalTombstonesForRegisteredWorkspaces` runs. Counts are exact. Timings come from single runs on a shared host (load average 11-32) and are noisy. Run 1 used a build of `712637f68e`; runs 2, 3 and the control used the head build. Both have the same product code (the later commit changes only a test). | Run | Build | Parses during sweep | Saves during sweep | Parses per save | Sweep ms | Start to `housekeeping settled` (s) | |---|---|---|---|---|---|---| | 1 | base | 85 | 21 | 4.05 | 17619 | 31.49 | | 1 | head | 43 | 21 | 2.05 | 13499 | 26.54 | | 2 | base | 85 | 21 | 4.05 | 17271 | 30.74 | | 2 | head | 43 | 21 | 2.05 | 13872 | 27.71 | | 3 | base | 85 | 21 | 4.05 | 17458 | 31.53 | | 3 | head | 43 | 21 | 2.05 | 15617 | 29.87 | | control, 0 edits | base | 5 | 1 | - | 5475 | 16.61 | | control, 0 edits | head | 3 | 1 | - | 5335 | 17.11 | - Per save, after taking away the one cold read that the 0-edit control shows: base (85 - 1) / 21 = 4.0, head (43 - 1) / 21 = 2.0. The plan projected 4.4 on base from live captures; this harness measures 4.0. - On both builds, the sweep healed exactly the 2 old registered tombstones ("Healed a removal tombstone" logged 2 times), and the other 2,735 tombstones stayed. ## Dogfood (head `4fa387b2e78bf32f810d7c21bc1f488bd33ce265`, synthetic 4,700-workspace root, no real data) The same driver ran a cross-process check after each startup run, on base and head: 1. Rename a workspace through `workspace.updateTitle`: `workspace.getInfo` returns `API title`. 2. A script writes a changed title into `config.json` through a temp file and a rename, as another backend does: `workspace.getInfo` returns `External atomic title`. 3. A script edits `config.json` in place with a different length: `workspace.getInfo` returns `In-place edit, longer title`. All 3 steps passed on base and head in all 3 runs. The harness source and raw results are in a PR comment. ## Risks Low. The change only stops clearing a snapshot whose stat key still matches the file, and every snapshot read compares keys first, as before. The remaining risk is the existing stat-key limit (an in-place rewrite that keeps inode, mtime and size), which this PR does not change. A save still keeps no snapshot of the file it replaced, and a failed save still clears it. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high --> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 5e011d0 commit a4e2d5c

3 files changed

Lines changed: 388 additions & 4 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* Config reads, edits and metadata builds at live-shaped sizes. Run: make bench BENCH=config
3+
*
4+
* The fixture is synthetic, shaped like a large real config (about 1.66 KB per workspace on disk,
5+
* 19 projects, 57% archived, 54% sub-agent tasks under earlier root rows, 99% worktree runtime,
6+
* 76% of checkout paths missing, one non-ASCII character per ~23 KB). Never point it at real data.
7+
* Every row uses the public Config API so `make bench-compare` can run it on base and head.
8+
* Keep helpers in this file: bench-compare resolves imports from each side's own tree.
9+
*/
10+
import * as fs from "node:fs";
11+
import * as path from "node:path";
12+
import { bench, do_not_optimize } from "mitata";
13+
import type { ProjectConfig, Workspace } from "@/common/types/project";
14+
import { Config } from "@/node/config";
15+
import { DisposableTempDir } from "@/node/services/tempDir";
16+
17+
/** 4,712 is the live-shaped size; the others show how each row scales. */
18+
const SIZES = [50, 500, 5000, 4712];
19+
const PROJECT_COUNT = 19;
20+
21+
/** mitata passes this to generator benchmarks; get() returns the current .args() value. */
22+
interface BenchState {
23+
get(name: string): unknown;
24+
}
25+
26+
const tempDir = new DisposableTempDir("xum-bench-config-scale");
27+
process.on("exit", () => tempDir[Symbol.dispose]());
28+
29+
const TASK_PROMPT =
30+
"Implement the change described in the plan, keep the diff minimal, run the targeted tests " +
31+
"and the static checks, then report the result with the commit and the validation you ran. ";
32+
33+
/** Deterministic spread of `percent`% over row indexes without long runs. */
34+
function spread(index: number, salt: number, percent: number): boolean {
35+
return (index * salt + salt) % 100 < percent;
36+
}
37+
38+
function makeProjects(root: string, count: number): Map<string, ProjectConfig> {
39+
const projects = new Map<string, ProjectConfig>();
40+
const projectPaths: string[] = [];
41+
for (let p = 0; p < PROJECT_COUNT; p++) {
42+
const projectPath = path.join(root, "projects", `project-${p}`);
43+
projectPaths.push(projectPath);
44+
projects.set(projectPath, { workspaces: [] });
45+
}
46+
let lastRoot: { id: string; project: number } | undefined;
47+
let roots = 0;
48+
for (let i = 0; i < count; i++) {
49+
const isTask = lastRoot !== undefined && spread(i, 7, 54);
50+
const project = isTask ? lastRoot!.project : roots++ % PROJECT_COUNT;
51+
const id = `ws${i.toString(36).padStart(6, "0")}`;
52+
const name = `${isTask ? "task" : "feature"}-${i}`;
53+
const workspacePath = path.join(root, "src", `project-${project}`, name);
54+
const worktree = i % 100 !== 99;
55+
const workspace: Workspace = {
56+
id,
57+
name,
58+
// One non-ASCII character per ~14 rows (~23 KB of text).
59+
title: `${i % 14 === 0 ? "Café" : "Cafe"} feature ${i}: tighten the workspace sidebar flow`,
60+
path: workspacePath,
61+
createdAt: "2026-09-01T12:00:00.000Z",
62+
runtimeConfig: worktree
63+
? { type: "worktree", srcBaseDir: path.join(root, "src") }
64+
: { type: "local" },
65+
aiSettings: { model: "anthropic:claude-opus-5-5", thinkingLevel: "high" },
66+
aiSettingsByAgent: {
67+
exec: { model: "anthropic:claude-opus-5-5", thinkingLevel: "high" },
68+
plan: { model: "anthropic:claude-opus-5-5", thinkingLevel: "high" },
69+
explore: { model: "anthropic:claude-sonnet-5-5", thinkingLevel: "medium" },
70+
},
71+
};
72+
if (isTask) {
73+
workspace.parentWorkspaceId = lastRoot!.id;
74+
workspace.agentId = "exec";
75+
workspace.agentType = "exec";
76+
workspace.taskStatus = "reported";
77+
workspace.reportedAt = "2026-09-01T13:00:00.000Z";
78+
workspace.taskModelString = "anthropic:claude-opus-5-5";
79+
workspace.taskThinkingLevel = "high";
80+
workspace.taskPrompt = `Task ${i}: ${TASK_PROMPT}${TASK_PROMPT}`;
81+
workspace.taskTrunkBranch = "main";
82+
} else {
83+
lastRoot = { id, project };
84+
}
85+
if (spread(i, 37, 57)) workspace.archivedAt = "2026-09-15T12:00:00.000Z";
86+
if (!spread(i, 53, 76)) fs.mkdirSync(workspacePath, { recursive: true });
87+
projects.get(projectPaths[project])!.workspaces.push(workspace);
88+
}
89+
return projects;
90+
}
91+
92+
const fixtures = new Map<number, Promise<{ root: string; lastId: string }>>();
93+
94+
/**
95+
* One root per size, written once through editConfig so config.json is in its canonical,
96+
* already-migrated form: reads then never schedule a migration write while a row runs.
97+
*/
98+
function fixture(count: number): Promise<{ root: string; lastId: string }> {
99+
let entry = fixtures.get(count);
100+
if (entry === undefined) {
101+
entry = (async () => {
102+
const root = path.join(tempDir.path, `n${count}`);
103+
const projects = makeProjects(root, count);
104+
await new Config(root).editConfig((config) => {
105+
config.projects = projects;
106+
return config;
107+
});
108+
return { root, lastId: `ws${(count - 1).toString(36).padStart(6, "0")}` };
109+
})();
110+
fixtures.set(count, entry);
111+
}
112+
return entry;
113+
}
114+
115+
function statKey(root: string): string {
116+
const stat = fs.statSync(path.join(root, "config.json"));
117+
return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
118+
}
119+
120+
/** Registers a read-only row: it fails when config.json changes while it runs. */
121+
function readRow(
122+
name: string,
123+
setup: (config: Config, lastId: string) => Promise<void> | void,
124+
run: (config: Config, lastId: string) => unknown
125+
) {
126+
bench(`${name} ($workspaces)`, async function* (state: BenchState) {
127+
const f = await fixture(state.get("workspaces") as number);
128+
const config = new Config(f.root);
129+
await setup(config, f.lastId);
130+
const before = statKey(f.root);
131+
yield () => run(config, f.lastId);
132+
if (statKey(f.root) !== before) throw new Error(`${name}: config.json changed during the run`);
133+
}).args("workspaces", SIZES);
134+
}
135+
136+
readRow(
137+
"loadConfigOrDefault, fresh Config",
138+
() => undefined,
139+
// A fresh instance has no snapshot: every call reads, decodes, parses and normalizes.
140+
(config) => do_not_optimize(new Config(config.rootDir).loadConfigOrDefault())
141+
);
142+
readRow(
143+
"loadConfigOrDefault, snapshot hit",
144+
(config) => void config.loadConfigOrDefault(),
145+
(config) => do_not_optimize(config.loadConfigOrDefault())
146+
);
147+
readRow(
148+
"findWorkspace(last id)",
149+
(config) => void config.loadConfigOrDefault(),
150+
(config, id) => do_not_optimize(config.findWorkspace(id))
151+
);
152+
readRow(
153+
"getAllWorkspaceMetadata, full build",
154+
() => undefined,
155+
(config) => config.getAllWorkspaceMetadata()
156+
);
157+
readRow(
158+
"getAllWorkspaceMetadata, last-known probes",
159+
async (config) => void (await config.getAllWorkspaceMetadata()),
160+
(config) => config.getAllWorkspaceMetadata({ probeCheckouts: "last-known" })
161+
);
162+
readRow(
163+
"getAllWorkspaceMetadata, registry memo",
164+
async (config) => void (await config.getAllWorkspaceMetadata({ probeCheckouts: false })),
165+
(config) => config.getAllWorkspaceMetadata({ probeCheckouts: false })
166+
);
167+
readRow(
168+
"getWorkspaceMetadataById(last id)",
169+
() => undefined,
170+
(config, id) => config.getWorkspaceMetadataById(id)
171+
);
172+
173+
/** Same-value edit: the transform returns the config unchanged, so every round saves equal bytes. */
174+
function sameValueEdit(config: Config): Promise<void> {
175+
return config.editConfig((snapshot) => snapshot);
176+
}
177+
178+
bench("editConfig, same-value edit ($workspaces)", async function* (state: BenchState) {
179+
const f = await fixture(state.get("workspaces") as number);
180+
const config = new Config(f.root);
181+
config.loadConfigOrDefault();
182+
yield () => sameValueEdit(config);
183+
})
184+
.args("workspaces", SIZES)
185+
.gc("inner");
186+
187+
bench("editConfig, then loadConfigOrDefault ($workspaces)", async function* (state: BenchState) {
188+
const f = await fixture(state.get("workspaces") as number);
189+
const config = new Config(f.root);
190+
config.loadConfigOrDefault();
191+
yield async () => {
192+
await sameValueEdit(config);
193+
return do_not_optimize(config.loadConfigOrDefault());
194+
};
195+
})
196+
.args("workspaces", SIZES)
197+
.gc("inner");
198+
199+
bench(
200+
"editConfig, reader on every event-loop turn ($workspaces)",
201+
async function* (state: BenchState) {
202+
const f = await fixture(state.get("workspaces") as number);
203+
const config = new Config(f.root);
204+
config.loadConfigOrDefault();
205+
yield async () => {
206+
// A reader between every await of the edit, like the startup tombstone heal sweep.
207+
let reading = true;
208+
const read = () => {
209+
if (!reading) return;
210+
config.loadConfigOrDefault();
211+
setImmediate(read);
212+
};
213+
setImmediate(read);
214+
try {
215+
await sameValueEdit(config);
216+
} finally {
217+
reading = false;
218+
}
219+
};
220+
}
221+
)
222+
.args("workspaces", SIZES)
223+
.gc("inner");

‎src/node/config/index.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,7 +1636,7 @@ export class Config {
16361636
* workspace; with thousands of archived entries a heap walk found ten
16371637
* live copies of the same list. Snapshot identity is the natural change
16381638
* signal: loadConfigOrDefault hands out the same object until config.json's
1639-
* stat key changes, and saveConfig drops the snapshot, so both this
1639+
* stat key changes, and saveConfig keeps no snapshot of the replaced file, so both this
16401640
* process's edits and other backends' rewrites invalidate the memo. The
16411641
* WeakMap lets a superseded snapshot's memo die with it.
16421642
*/
@@ -1679,11 +1679,18 @@ export class Config {
16791679
return this.readConfigOrDefault(options, key);
16801680
}
16811681

1682-
private readConfigOrDefault(options?: { throwOnError?: boolean }, key?: string): ProjectsConfig {
1682+
private readConfigOrDefault(
1683+
options?: { throwOnError?: boolean; keepSnapshot?: boolean },
1684+
key?: string
1685+
): ProjectsConfig {
16831686
// Read as a Buffer and hand the same snapshot to the failure handler: backing up via a
16841687
// second read could preserve a concurrent writer's replacement instead of the bytes that
16851688
// actually failed parsing.
16861689
let rawBytes: Buffer | undefined;
1690+
// An edit's fresh read stores nothing, so the snapshot of the unchanged file stays valid
1691+
// for readers until the save's rename; dropping it made every reader during the edit parse
1692+
// the same bytes again. It is still key-guarded: a miss as soon as the file changes.
1693+
const kept = options?.keepSnapshot ? this.configSnapshot : undefined;
16871694
this.configSnapshot = undefined;
16881695
try {
16891696
try {
@@ -1718,6 +1725,11 @@ export class Config {
17181725
config,
17191726
writeId: typeof parsed.writeId === "string" ? parsed.writeId : "-",
17201727
};
1728+
} else if (cacheable && kept !== undefined) {
1729+
// Give it back only after a successful, cacheable read. A failed read leaves it
1730+
// cleared: otherwise later gate loads would hit it and never retry the read that
1731+
// clears a recorded load failure, refusing every edit until a restart.
1732+
this.configSnapshot = kept;
17211733
}
17221734
configLoadFailureStates.delete(this.configFile);
17231735
return config;
@@ -2647,7 +2659,10 @@ export class Config {
26472659
catch: (error) => error,
26482660
});
26492661
// A competing rename may already have replaced our write; only a fresh read can publish it.
2650-
self.configSnapshot = undefined;
2662+
// Keep a snapshot only if a reader already parsed the file that is on disk now (it read
2663+
// real disk bytes, whoever wrote them), so the first reader after the save does not parse
2664+
// the same bytes again.
2665+
if (self.configSnapshot?.key !== self.readConfigStatKey()) self.configSnapshot = undefined;
26512666
for (const workspaceId of self.legacyTaskVariantGroups.keys()) {
26522667
if (!persistedWorkspaceIds.has(workspaceId)) {
26532668
// A load-time settings migration can save before getAllWorkspaceMetadata's queued
@@ -3169,7 +3184,7 @@ export class Config {
31693184
* pure (callers run their own updaters and record results inside it): only after the
31703185
* edit holds the lock it will write under, so no invocation's result is discarded.
31713186
*/
3172-
const transform = (): ProjectsConfig => fn(self.readConfigOrDefault());
3187+
const transform = (): ProjectsConfig => fn(self.readConfigOrDefault({ keepSnapshot: true }));
31733188
const write = Effect.fn(function* (
31743189
newConfig: ProjectsConfig,
31753190
lock: ProjectRegistrationLockHandle

0 commit comments

Comments
 (0)