Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/hooks/useNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {
withCreatedStamp,
withIcon,
withNoteAppearance,
withTrashedAppearance,
withPinToggled,
withRemoved,
withRenamed,
withReprefixed,
withSortMode,
withTrashEmptied,
withTrashed,
withTrashedAppearance,
withoutTrashEntry,
} from '../storage/metadata';
import {dirname, previewFromContent, titleFromFileName} from '../storage/noteText';
Expand Down
76 changes: 51 additions & 25 deletions src/search.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import type {NoteMeta} from './storage/types';
* Stress / micro-benchmark for the full-text search hot path on a large folder. `useNoteSearch`
* scores the whole corpus on every keystroke, so the cost that matters is per-keystroke work over
* thousands of notes. This guards the precomputed-lowercase index (`searchNotes`'s `lowerById`):
* the indexed path must never be slower than re-lowercasing every body on the fly, and a realistic
* keystroke burst must stay well within budget. It also prints the before/after timing so the win
* is visible when the suite runs.
* the indexed path must not be meaningfully slower than re-lowercasing every body on the fly, and a
* realistic keystroke burst must stay well within budget. Timings are best-of-N with generous slack
* because the suite runs in parallel workers — a single sample can be preempted mid-burst and
* invert the comparison. It also prints the before/after timing so the win is visible when the
* suite runs.
*/

const NOTE_COUNT = 4000;
Expand Down Expand Up @@ -84,18 +86,28 @@ describe('searchNotes — large-corpus stress', () => {
expect(withIndex).toEqual(without);
});

it(`keeps a ${NOTE_COUNT}-note keystroke burst responsive (indexed never slower)`, () => {
// On-the-fly first, so the indexed run can't be flattered by a cold JIT.
const onTheFly = runBurst(false);
const indexed = runBurst(true);
it(`keeps a ${NOTE_COUNT}-note keystroke burst responsive (indexed within noise of on-the-fly)`, () => {
// A single sample of each path is a race against the other vitest workers: one preemption
// mid-burst inverts the comparison. Interleave several runs and keep the fastest of each —
// the min discards preempted samples. On-the-fly first in each pair, so the indexed run
// can't be flattered by a cold JIT.
const RUNS = 5;
let onTheFly = Infinity;
let indexed = Infinity;
for (let i = 0; i < RUNS; i++) {
onTheFly = Math.min(onTheFly, runBurst(false));
indexed = Math.min(indexed, runBurst(true));
}
// eslint-disable-next-line no-console
console.log(
`[stress] ${NOTE_COUNT} notes × ${keystrokes.length} keystrokes — ` +
`[stress] ${NOTE_COUNT} notes × ${keystrokes.length} keystrokes (best of ${RUNS}) — ` +
`on-the-fly ${onTheFly.toFixed(1)}ms vs indexed ${indexed.toFixed(1)}ms ` +
`(${(onTheFly / Math.max(indexed, 0.01)).toFixed(2)}× faster)`,
);
// The index removes per-keystroke re-lowercasing, so it must not be slower; small slack for noise.
expect(indexed).toBeLessThanOrEqual(onTheFly * 1.1 + 5);
// The index removes per-keystroke re-lowercasing, so it should win; the 3× slack tolerates
// residual scheduler noise while still failing loudly if the indexed path regresses by an
// order of magnitude (the 2026-06 perf-audit guard).
expect(indexed).toBeLessThanOrEqual(onTheFly * 3 + 10);
// And the whole burst must stay comfortably interactive even at this scale.
expect(indexed).toBeLessThan(1500);
});
Expand All @@ -121,30 +133,44 @@ describe('searchNotes — large-corpus stress', () => {

// Reference: a deliberately UNcapped body-count pass over the same corpus (the work the cap
// removes). Only the body walk differs from the real scorer.
const tUncap = performance.now();
for (const note of dense) {
const b = denseLower.get(note.id) ?? body;
let from = 0;
for (;;) {
const i = b.indexOf(TERM, from);
if (i === -1) break;
from = i + TERM.length;
function uncappedPass(): number {
const t = performance.now();
for (const note of dense) {
const b = denseLower.get(note.id) ?? body;
let from = 0;
for (;;) {
const i = b.indexOf(TERM, from);
if (i === -1) break;
from = i + TERM.length;
}
}
return performance.now() - t;
}
const uncappedMs = performance.now() - tUncap;

const tCap = performance.now();
const res = searchNotes(dense, denseContent, TERM, denseLower);
const cappedMs = performance.now() - tCap;
// Best-of-N with the min, for the same reason as the burst test above: under parallel vitest
// workers a single timed sample can be preempted mid-pass and invert the comparison. Interleave
// several runs and keep the fastest of each — uncapped first in each pair so the capped run
// can't be flattered by a cold JIT.
const RUNS = 5;
let uncappedMs = Infinity;
let cappedMs = Infinity;
let matched = 0;
for (let i = 0; i < RUNS; i++) {
uncappedMs = Math.min(uncappedMs, uncappedPass());
const tCap = performance.now();
const res = searchNotes(dense, denseContent, TERM, denseLower);
cappedMs = Math.min(cappedMs, performance.now() - tCap);
matched = res.length;
}

// eslint-disable-next-line no-console
console.log(
`[stress] repeat-term count — uncapped ${uncappedMs.toFixed(1)}ms vs capped ` +
`${cappedMs.toFixed(1)}ms (${(uncappedMs / Math.max(cappedMs, 0.01)).toFixed(2)}× faster)`,
`[stress] repeat-term count (best of ${RUNS}) — uncapped ${uncappedMs.toFixed(1)}ms vs ` +
`capped ${cappedMs.toFixed(1)}ms (${(uncappedMs / Math.max(cappedMs, 0.01)).toFixed(2)}× faster)`,
);
// Every note matches (the term is throughout each body); ranking correctness is covered by
// search.test.ts — here we guard that the cap keeps the pass cheap despite N × REPEATS hits.
expect(res).toHaveLength(N);
expect(matched).toBe(N);
expect(cappedMs).toBeLessThan(uncappedMs);
// Absolute budget: an uncapped tally of N×REPEATS occurrences blows past this; the capped walk
// (≈ BODY_FREQ_CAP+1 advances per note) stays well under it.
Expand Down
Loading