perf: round 11 — StoragePath joined-only, classifier fusion, memoized bodies, query-shape pack, SPA fine-grained stars
Backend (each change benchmark-gated with BEFORE replicas + equivalence gates; see examples/bench_round11_micro.rs, bench_round11_queries.rs, bench_log_writer.rs and benches/ROUND11.md — final numbers land in the follow-up doc commit): - StoragePath re-representation: single canonical joined String, segments derived on demand; File/Folder drop the duplicated path_string field (4000→1000 allocs per 500-row listing page) - Display classifier fusion: classify_display shares one stack-lowered extension across the three decision trees; call sites in FileDto, folder/favorites/recent handlers, trash, path-resolver (+ interning where Arc::from was still used) - /status.php and /openapi.json memoized into OnceLock<Bytes> (openapi rebuilt a 171 KiB spec per request: 2.8 ms → 18 ns) - NC upload-session PROPFIND: write! + pre-sized body + stack RFC2822 dates (2.3-2.6x, 2582→772 allocs at 256 chunks) - REST download: dead FileDto clone removed (capture mime/size + move) - CalendarEventDto/TrashedItem into_parts moves (11 KiB ical_data memcpy gone per CalDAV row); CardDAV getlastmodified stack render - 4xx path: borrowed ErrorResponse serialize, ErrorKind::as_str, not_found/already_exists clone kill - vCard emit via write!; search page moved out with into_iter skip/take; content-hit UUIDs parsed once; group last-user check via HashSet - RateLimiter: lock-free get + insert (and_upsert_with variant REJECTED by benchmark); CSRF token borrow-compare + borrowed cookie extraction - Thumbnail/preview ETags built from as_str (Debug-identical bytes) - Encrypted backend: encrypt_in_place_detached single-buffer write path, chunk-sized reserve in collect_stream; retry labels made lazy - PG: deferred upload registration 3→1 round-trips (persist_file CTE template); direct_grant_cache for Calendar/AddressBook/Playlist authz (single-flight + set_role/clear_role invalidation); expand_user tokio::join!; geo clusters min(uuid)::text; recluster face assignment batched into one UNNEST update - People recluster cosine: norms precomputed once (bit-identical gate) - NC capabilities poll logs demoted to debug; tracing-appender dep added for the log-writer benchmark Frontend: - ResourceList.selectedEntries O(N)-per-toggle → id-index projection O(k log k); favorites/recent consume the batchToolbar snippet param and drop their duplicate filter + dead selectedIds mirror - Recent: star state via new favoriteIds prop — a star click no longer rebuilds all N entries - admin timeAgo >30d fallback uses the cached Intl.DateTimeFormat - vitest gates in src/lib/components/round11.bench.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABhTEHuGujvwoodh67Kga7
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gates for the round-11 SPA items (see benches/ROUND11.md):
|
||||
*
|
||||
* [1] `ResourceList.selectedEntries` re-filtered the ENTIRE items array on
|
||||
* every selection change once the batch toolbar was mounted — and the
|
||||
* favorites/recent hosts ignored the snippet param and recomputed their
|
||||
* own `entries.filter(...)` shadow, so each toggle ran TWO full O(N)
|
||||
* scans (O(N²)-ish across a shift-range gesture). The shipped shape
|
||||
* derives an id→index Map (rebuilt only when `items` changes) and
|
||||
* projects the selection in O(k · log k), preserving item order; hosts
|
||||
* now consume the snippet param.
|
||||
*
|
||||
* [2] The Recent page mapper baked `favoriteIds.has(id)` into every entry,
|
||||
* subscribing the whole O(N) map to the SvelteSet — one star click
|
||||
* rebuilt all N entries and re-rendered every visible row. The shipped
|
||||
* shape reads membership in the star widget via ResourceList's new
|
||||
* `favoriteIds` prop, so the mapper no longer depends on the set.
|
||||
*
|
||||
* [3] The admin "time ago" >30-day fallback called `toLocaleDateString()`
|
||||
* (a fresh Intl.DateTimeFormat per call) instead of the cached
|
||||
* `dateTimeFormatFor` the rest of the app uses.
|
||||
*
|
||||
* All modeled as pure replicas of the derive bodies with instrumentation
|
||||
* counters (the listDerives.bench.test.ts convention).
|
||||
*/
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const buildItems = (n: number): Entry[] =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `it-${i}`, name: `Item ${i}` }));
|
||||
|
||||
/** BEFORE — component derive + host shadow, each a full O(N) scan. */
|
||||
function selectedBefore(
|
||||
items: Entry[],
|
||||
selected: Set<string>,
|
||||
counter: { comparisons: number }
|
||||
): { component: Entry[]; host: Entry[] } {
|
||||
const component = items.filter((i) => {
|
||||
counter.comparisons++;
|
||||
return selected.has(i.id);
|
||||
});
|
||||
const host = items.filter((i) => {
|
||||
counter.comparisons++;
|
||||
return selected.has(i.id);
|
||||
});
|
||||
return { component, host };
|
||||
}
|
||||
|
||||
/** AFTER — id→index Map projection, index rebuilt only on items change. */
|
||||
function makeAfterProjector(items: Entry[]) {
|
||||
const indexById = new Map(items.map((i, idx) => [i.id, idx]));
|
||||
return (selected: Set<string>, counter: { comparisons: number }): Entry[] => {
|
||||
const picked: { idx: number; item: Entry }[] = [];
|
||||
for (const id of selected) {
|
||||
counter.comparisons++;
|
||||
const idx = indexById.get(id);
|
||||
if (idx !== undefined) picked.push({ idx, item: items[idx] });
|
||||
}
|
||||
picked.sort((a, b) => a.idx - b.idx);
|
||||
return picked.map((p) => p.item);
|
||||
};
|
||||
}
|
||||
|
||||
describe('ResourceList selectedEntries projection (benchmark gate)', () => {
|
||||
it('identical output (order + membership) and O(k) vs O(2N) comparisons per toggle', () => {
|
||||
const N = 2000;
|
||||
const items = buildItems(N);
|
||||
const project = makeAfterProjector(items);
|
||||
|
||||
// Model a 50-item shift-range selection built one id at a time,
|
||||
// re-deriving after each toggle (what the reactive graph does).
|
||||
const selected = new Set<string>();
|
||||
const beforeCounter = { comparisons: 0 };
|
||||
const afterCounter = { comparisons: 0 };
|
||||
// Insert in REVERSE order so selection order ≠ item order — the
|
||||
// order-preservation gate below must still hold. Each toggle
|
||||
// re-derives both shapes (what the reactive graph does).
|
||||
for (let i = 149; i >= 100; i--) {
|
||||
selected.add(`it-${i}`);
|
||||
selectedBefore(items, selected, beforeCounter);
|
||||
project(selected, afterCounter);
|
||||
}
|
||||
// Stale ids (deleted rows) must be dropped by both shapes.
|
||||
selected.add('it-ghost');
|
||||
const lastBefore = selectedBefore(items, selected, beforeCounter).component;
|
||||
const lastAfter = project(selected, afterCounter);
|
||||
|
||||
expect(lastAfter).toEqual(lastBefore); // same entries, same (item) order
|
||||
// BEFORE: 2 scans × N per toggle. AFTER: k probes per toggle.
|
||||
expect(beforeCounter.comparisons).toBe(51 * 2 * N);
|
||||
expect(afterCounter.comparisons).toBeLessThan(51 * 51 + 1);
|
||||
});
|
||||
|
||||
it('wall clock: 500-toggle sweep on a 5k list is faster with the projection', () => {
|
||||
const N = 5000;
|
||||
const items = buildItems(N);
|
||||
const project = makeAfterProjector(items);
|
||||
const selected = new Set<string>();
|
||||
const nul = { comparisons: 0 };
|
||||
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
selected.add(`it-${i}`);
|
||||
selectedBefore(items, selected, nul);
|
||||
}
|
||||
const tBefore = performance.now() - t0;
|
||||
|
||||
selected.clear();
|
||||
const t1 = performance.now();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
selected.add(`it-${i}`);
|
||||
project(selected, nul);
|
||||
}
|
||||
const tAfter = performance.now() - t1;
|
||||
|
||||
// Generous bound to keep CI stable; locally ~10-40x.
|
||||
expect(tAfter).toBeLessThan(tBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── [2] Recent favorite-star dependency ────────────────────────────────────
|
||||
|
||||
interface RawItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** BEFORE — mapper reads the favorite set: every toggle re-maps ALL rows. */
|
||||
function entriesBefore(
|
||||
raw: RawItem[],
|
||||
favoriteIds: Set<string>,
|
||||
counter: { mapperRows: number }
|
||||
): { id: string; isFavorite: boolean }[] {
|
||||
return raw.map((it) => {
|
||||
counter.mapperRows++;
|
||||
return { id: it.id, isFavorite: favoriteIds.has(it.id) };
|
||||
});
|
||||
}
|
||||
|
||||
/** AFTER — mapper is set-independent; the star widget reads membership. */
|
||||
function entriesAfter(raw: RawItem[], counter: { mapperRows: number }): { id: string }[] {
|
||||
return raw.map((it) => {
|
||||
counter.mapperRows++;
|
||||
return { id: it.id };
|
||||
});
|
||||
}
|
||||
function starStateAfter(favoriteIds: Set<string>, id: string): boolean {
|
||||
return favoriteIds.has(id);
|
||||
}
|
||||
|
||||
describe('Recent favorite-star fine-grained dependency (benchmark gate)', () => {
|
||||
it('a star toggle re-maps 0 rows (was N) and renders the same star states', () => {
|
||||
const N = 400;
|
||||
const raw: RawItem[] = Array.from({ length: N }, (_, i) => ({
|
||||
id: `r-${i}`,
|
||||
name: `File ${i}`
|
||||
}));
|
||||
const favoriteIds = new Set<string>(['r-3']);
|
||||
|
||||
const beforeCounter = { mapperRows: 0 };
|
||||
const afterCounter = { mapperRows: 0 };
|
||||
|
||||
// Initial render: both shapes map all rows once.
|
||||
let entriesB = entriesBefore(raw, favoriteIds, beforeCounter);
|
||||
const entriesA = entriesAfter(raw, afterCounter);
|
||||
expect(beforeCounter.mapperRows).toBe(N);
|
||||
expect(afterCounter.mapperRows).toBe(N);
|
||||
|
||||
// 10 star toggles. BEFORE: the mapper depends on the set → full
|
||||
// re-map each time. AFTER: the mapper doesn't run at all.
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const id = `r-${k * 7}`;
|
||||
if (favoriteIds.has(id)) favoriteIds.delete(id);
|
||||
else favoriteIds.add(id);
|
||||
entriesB = entriesBefore(raw, favoriteIds, beforeCounter); // reactive re-run
|
||||
// AFTER: no mapper re-run; only the affected star re-reads.
|
||||
starStateAfter(favoriteIds, id);
|
||||
}
|
||||
|
||||
expect(beforeCounter.mapperRows).toBe(N + 10 * N);
|
||||
expect(afterCounter.mapperRows).toBe(N); // unchanged since initial render
|
||||
|
||||
// Gate: identical star state for every row under the AFTER shape.
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(starStateAfter(favoriteIds, entriesA[i].id)).toBe(entriesB[i].isFavorite);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── [3] admin timeAgo date fallback ────────────────────────────────────────
|
||||
|
||||
describe('admin timeAgo >30d fallback formatter cache (benchmark gate)', () => {
|
||||
it('cached formatter output is identical to toLocaleDateString()', async () => {
|
||||
const { dateTimeFormatFor } = await import('../utils/display');
|
||||
const dates = [
|
||||
new Date('2025-01-15T10:30:00Z'),
|
||||
new Date('2024-12-31T23:59:59Z'),
|
||||
new Date('2020-06-01T00:00:00Z'),
|
||||
new Date('1999-02-28T12:00:00Z')
|
||||
];
|
||||
for (const d of dates) {
|
||||
expect(dateTimeFormatFor(undefined).format(d)).toBe(d.toLocaleDateString());
|
||||
}
|
||||
});
|
||||
|
||||
it('1000 formats construct ≤1 Intl.DateTimeFormat (was 1000)', async () => {
|
||||
const { dateTimeFormatFor } = await import('../utils/display');
|
||||
const RealDTF = Intl.DateTimeFormat;
|
||||
let constructed = 0;
|
||||
// Count constructions through both paths.
|
||||
const Counting = new Proxy(RealDTF, {
|
||||
construct(target, args: [string?, Intl.DateTimeFormatOptions?]) {
|
||||
constructed++;
|
||||
return new target(...args);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Intl as any).DateTimeFormat = Counting;
|
||||
try {
|
||||
const d = new Date('2020-06-01T00:00:00Z');
|
||||
constructed = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
d.toLocaleDateString();
|
||||
}
|
||||
// jsdom implements toLocaleDateString via Intl internally in some
|
||||
// versions; count only if observable. The load-bearing assertion
|
||||
// is the cached path below.
|
||||
const beforeConstructed = constructed;
|
||||
|
||||
constructed = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
dateTimeFormatFor(undefined).format(d);
|
||||
}
|
||||
expect(constructed).toBeLessThanOrEqual(1);
|
||||
// When the environment exposes per-call constructions, require
|
||||
// the cached path to be strictly cheaper.
|
||||
if (beforeConstructed > 1) {
|
||||
expect(constructed).toBeLessThan(beforeConstructed);
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Intl as any).DateTimeFormat = RealDTF;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user