From 68a0fb61f9ae262385607ba239bc324894e6cc28 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Tue, 26 May 2026 22:03:06 -0700 Subject: [PATCH] bench(hashline): added recovery session-chain replay throughput bench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the hot path PR #1422 hardened: Recovery.tryRecover → applyEditsToSnapshot (3-way merge fails) → replaySessionChainOnCurrent → verifyAnchorContent. Two regimes (accept/reject) × three file sizes (50/500/5000 lines) × two anchor counts (1/8). Sanity-probes each case once before timing so the numbers measure the right branch. Surfaces the four-split-per-replay cost in the gated path; dedup is an obvious follow-up at large file sizes but out of scope here. --- .../hashline/bench/recovery-session-chain.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/hashline/bench/recovery-session-chain.ts diff --git a/packages/hashline/bench/recovery-session-chain.ts b/packages/hashline/bench/recovery-session-chain.ts new file mode 100644 index 000000000..b6f33bd15 --- /dev/null +++ b/packages/hashline/bench/recovery-session-chain.ts @@ -0,0 +1,133 @@ +/** + * Recovery hot-path benchmark. + * + * Pin throughput of the session-chain replay path that PR #1422 hardened. + * After the fix, every replay call walks each edit's anchors and splits both + * `previousText` and `currentText` to compare per-line content. This bench + * exists so future optimisation of that walk has a baseline, and so reviewers + * can confirm the gate is cheap relative to the diff + applyPatch work that + * dominates the path. + * + * Two regimes: + * - `accept` — anchor lands on a line unchanged across the prior in-session + * edit. 3-way merge on the snapshot still fails (patch context spans the + * rewritten neighbour), the new gate passes, and replay onto current + * succeeds. End-to-end this exercises diff + applyPatch + applyEdits + + * verifyAnchorContent. + * - `reject` — anchor lands on the line the prior edit rewrote. 3-way merge + * fails, the new gate refuses, and `tryRecover` returns null without + * touching `applyEdits`. This is the corruption window PR #1422 closed + * and the path users hit when re-targeting stale lines. + * + * Sizes (50/500/5000 lines) × edit batch (1/8 anchors) so the O(N) split cost + * in `verifyAnchorContent` and the O(K) anchor walk both surface. + */ +import { computeFileHash, InMemorySnapshotStore, parsePatch, Recovery } from "../src"; + +const ITERATIONS = Number(Bun.env.HASHLINE_BENCH_ITERATIONS ?? "5000"); +const PATH = "/tmp/__hashline-recovery-bench__.ts"; + +interface Fixture { + store: InMemorySnapshotStore; + v1Text: string; + h0: string; +} + +/** + * Seed two snapshots: v0 → v1 where v1 differs only at `rewrittenLine`. The + * recovery driver will fetch v0 by hash and replay onto v1. + */ +function seed(lines: number, rewrittenLine: number): Fixture { + const v0Lines = Array.from({ length: lines }, (_, i) => `line ${i + 1} content`); + const v1Lines = [...v0Lines]; + v1Lines[rewrittenLine - 1] = `line ${rewrittenLine} REWRITTEN`; + const v0Text = `${v0Lines.join("\n")}\n`; + const v1Text = `${v1Lines.join("\n")}\n`; + const h0 = computeFileHash(v0Text); + const h1 = computeFileHash(v1Text); + const store = new InMemorySnapshotStore(); + store.recordContiguous(PATH, 1, v0Text.split("\n"), { fullText: v0Text, fileHash: h0 }); + store.recordContiguous(PATH, 1, v1Text.split("\n"), { fullText: v1Text, fileHash: h1 }); + return { store, v1Text, h0 }; +} + +/** Build an N-anchor edit batch whose anchor lines are distinct rows. */ +function batchPatch(anchors: readonly number[]): string { + return anchors.map(line => `${line}-${line}:\n+line ${line} MODEL`).join("\n"); +} + +interface Case { + name: string; + lines: number; + anchors: number[]; + rewrittenLine: number; +} + +const cases: Case[] = []; +for (const size of [50, 500, 5000] as const) { + const rewritten = Math.floor(size / 2); + // Anchors spread across the file so verifyAnchorContent must walk real + // distances, not just hammer the same cache line. + const sparse = [Math.max(1, Math.floor(size / 8))]; + const dense = [ + Math.max(1, Math.floor(size / 9)), + Math.max(1, Math.floor(size / 8)), + Math.max(1, Math.floor(size / 7)), + Math.max(1, Math.floor(size / 6)), + Math.max(1, Math.floor(size / 5)), + Math.max(1, Math.floor(size / 4)), + Math.max(1, Math.floor(size / 3)), + Math.max(2, Math.floor((size * 2) / 3)), + ]; + cases.push( + { name: `accept ${size}L ×1 anchor`, lines: size, anchors: sparse, rewrittenLine: rewritten }, + { name: `accept ${size}L ×8 anchors`, lines: size, anchors: dense, rewrittenLine: rewritten }, + { name: `reject ${size}L ×1 anchor`, lines: size, anchors: [rewritten], rewrittenLine: rewritten }, + ); +} + +function bench(name: string, fn: () => void): { totalMs: number; perOpUs: number } { + // Warm: JIT + InMemorySnapshotStore LRU touches. + for (let i = 0; i < Math.min(50, ITERATIONS); i++) fn(); + const start = Bun.nanoseconds(); + for (let i = 0; i < ITERATIONS; i++) fn(); + const elapsed = Bun.nanoseconds() - start; + const totalMs = elapsed / 1e6; + const perOpUs = elapsed / ITERATIONS / 1e3; + console.log(` ${name.padEnd(28)} ${totalMs.toFixed(2).padStart(8)}ms ${perOpUs.toFixed(3).padStart(8)}µs/op`); + return { totalMs, perOpUs }; +} + +console.log(`Recovery session-chain replay (${ITERATIONS} iterations per case)`); +console.log("Path: Recovery.tryRecover → applyEditsToSnapshot (3-way merge fails)"); +console.log(" → replaySessionChainOnCurrent → verifyAnchorContent (new gate)\n"); + +let expectedNonNullVerified = 0; +let expectedNullVerified = 0; + +for (const c of cases) { + const isReject = c.name.startsWith("reject"); + const { store, v1Text, h0 } = seed(c.lines, c.rewrittenLine); + const recovery = new Recovery(store); + const { edits } = parsePatch(batchPatch(c.anchors)); + const args = { path: PATH, currentText: v1Text, fileHash: h0, edits }; + + // Sanity: every iteration must hit the expected branch. Otherwise the + // numbers measure the wrong path. + const probe = recovery.tryRecover(args); + if (isReject) { + if (probe !== null) throw new Error(`expected null for ${c.name}, got recovery`); + expectedNullVerified++; + } else { + if (probe === null) throw new Error(`expected recovery for ${c.name}, got null`); + expectedNonNullVerified++; + } + + bench(c.name, () => { + recovery.tryRecover(args); + }); +} + +console.log( + `\nSanity: ${expectedNonNullVerified} accept-path + ${expectedNullVerified} reject-path branches verified before timing.`, +);