perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change gated by a before/after benchmark — an AFTER that did not beat its BEFORE was to be rolled back; none needed it. Equivalence gates assert identical row sequences / byte-identical output on every behavior-preserving rewrite): DB hot paths (local PG16, EXPLAIN-verified): - Web-UI listing (list_resources_paged): cursor pushed INSIDE the folders/files UNION-ALL branches as sargable row-value comparisons with per-branch ORDER/LIMIT + two partial expression indexes (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page (19.5x); other sort modes at parity or better. New migration 20260918000000. [benches/LISTING-KEYSET.md section in ROUND3] - Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N on the timeline index, joins moved above the top-N. 50k-photo library: 97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early" comment was refuted by EXPLAIN. - PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x). Concurrency: - Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB); now 1 (300 ms). Failed verifications remain uncached. - CachedBlobBackend per-hash single-flight + unique tmp names: 16 concurrent cold readers = 16 full remote downloads racing truncating writes on ONE deterministic .tmp (corruptible cache); now 1 download (16x less egress, 2.8x wall on a shared link) and torn files can never be renamed into the cache. I/O and allocations: - Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls); chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls). - S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk). - Entity->DTO mapping: Arc<str> interning of closed-set display fields + common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster). - CardDAV REPORT: deleted dead per-contact vCard pre-generation and the O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms, 9.8x); byte-identical XML asserted. - Search-results cache: byte weigher + 32 MiB budget (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let ~300 MiB of enriched rows sit in RSS; read latency parity. - Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph nodes, three SDK stacks gone from every build). tokio "process" is now an explicit feature (was enabled transitively by aws-config). Frontend: - Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and 4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate asserts output identity across locales and a 3x floor. Validation: cargo fmt + clippy --all-features --all-targets -D warnings clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login); frontend npm run check clean, new vitest gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { iconNameFromClass } from '$lib/utils/display';
|
||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
@@ -230,7 +230,7 @@
|
||||
const currentLang = $derived(LANGUAGES.find((l) => l.code === i18n.locale) ?? LANGUAGES[0]);
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return dateTimeFormatFor(undefined, { hour: '2-digit', minute: '2-digit' }).format(ms);
|
||||
}
|
||||
|
||||
function notifIcon(kind: string): string {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import { isVideo, photoTimestamp } from '$lib/utils/media';
|
||||
|
||||
interface Props {
|
||||
@@ -47,13 +48,13 @@
|
||||
});
|
||||
|
||||
function baseMeta(p: FileItem): string {
|
||||
const dateStr = new Date(photoTimestamp(p)).toLocaleDateString(undefined, {
|
||||
const dateStr = dateTimeFormatFor(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}).format(photoTimestamp(p));
|
||||
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,60 @@ export function fileIconKindClass(iconName: string): string {
|
||||
return `file-icon--${fileIconKind(iconName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-scope cache of `Intl.DateTimeFormat` instances, keyed by
|
||||
* `(locale, options signature)`. Constructing a formatter runs the full ICU
|
||||
* locale/pattern resolution (~50–200µs) while a `format()` call is ~1µs, and
|
||||
* {@link formatDate} runs roughly twice per row as large file lists render
|
||||
* and scroll — so a construct-per-call implementation (what
|
||||
* `toLocaleDateString(locale, options)` does under the hood) dominated list
|
||||
* fill. Entries are keyed by the locale actually requested — never frozen at
|
||||
* first use — so a runtime locale change just resolves a different entry.
|
||||
*/
|
||||
const dateTimeFormatCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
// Entries built with `locale === undefined` snapshot the environment default
|
||||
// locale at construction time. `toLocaleDateString(undefined, …)` re-reads the
|
||||
// default on every call, so drop the cache if the default changes to keep the
|
||||
// cached path behaviourally identical.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('languagechange', () => dateTimeFormatCache.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached equivalent of `new Intl.DateTimeFormat(locale, options)`.
|
||||
*
|
||||
* `date.toLocaleDateString(locale, options)` / `toLocaleTimeString(…)` are
|
||||
* specified (ECMA-402) as building exactly this formatter per call — and
|
||||
* their component defaulting is a no-op once `options` names any date/time
|
||||
* component — so `dateTimeFormatFor(locale, options).format(date)` is
|
||||
* output-identical while paying construction once per (locale, options).
|
||||
*
|
||||
* The options signature uses `JSON.stringify`, so pass options as a hoisted
|
||||
* const or an inline literal (stable key order per callsite); a differently
|
||||
* ordered but equal object would only create a redundant entry, never a wrong
|
||||
* result.
|
||||
*/
|
||||
export function dateTimeFormatFor(
|
||||
locale: string | undefined,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
const key = `${locale ?? ''}|${options ? JSON.stringify(options) : ''}`;
|
||||
let fmt = dateTimeFormatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale, options);
|
||||
dateTimeFormatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
/** Options for {@link formatDate}, hoisted so every call shares one cache key. */
|
||||
const FORMAT_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
|
||||
/** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */
|
||||
export function formatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
@@ -67,5 +121,5 @@ export function formatDate(value: number | string | null | undefined): string {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return dateTimeFormatFor(undefined, FORMAT_DATE_OPTS).format(d);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dateTimeFormatFor, formatDate } from './display';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the module-scope `Intl.DateTimeFormat` cache in
|
||||
* `display.ts` ({@link formatDate} / {@link dateTimeFormatFor}).
|
||||
*
|
||||
* Audit finding: `formatDate` built a fresh `Intl.DateTimeFormat` on every
|
||||
* call (`toLocaleDateString(undefined, opts)` constructs one internally), and
|
||||
* it runs ~twice per row while file lists render and scroll — a 10k-item
|
||||
* folder paid tens of thousands of ICU formatter constructions (~50–200µs
|
||||
* each) during list fill. The fix caches formatters in a Map keyed by
|
||||
* (locale, options signature).
|
||||
*
|
||||
* This gate asserts (1) the cached path is byte-identical to the
|
||||
* construct-per-call code it replaced, across dates, option shapes, and
|
||||
* locales (including an RTL one), and (2) it is decisively (≥3x) faster. If
|
||||
* the perf assertion fails, the cache is not delivering and the change
|
||||
* should be rolled back (it would be pure complexity).
|
||||
*/
|
||||
|
||||
/** The option shapes the app actually uses (display.ts + component callsites). */
|
||||
const DATE_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const MONTH_OPTS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long' };
|
||||
const FULL_DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
const DATE_TIME_OPTS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
};
|
||||
const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
/**
|
||||
* The pre-fix `formatDate`, verbatim: `toLocaleDateString` constructs a new
|
||||
* `Intl.DateTimeFormat` internally on every call. This is the uncached
|
||||
* reference the cached implementation must match and beat.
|
||||
*/
|
||||
function referenceFormatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
let d: Date;
|
||||
if (typeof value === 'number') {
|
||||
// Heuristic: seconds vs milliseconds.
|
||||
d = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
d = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, DATE_OPTS);
|
||||
}
|
||||
|
||||
/** ~20 inputs exercising the seconds/ms heuristic, ISO parsing, and edge cases. */
|
||||
const DATE_VALUES: Array<number | string | null | undefined> = [
|
||||
0, // epoch, seconds branch
|
||||
1, // seconds
|
||||
86_399, // seconds, last second of 1970-01-01 UTC
|
||||
951_782_400, // seconds, 2000-02-29 (leap day)
|
||||
1_700_000_000, // seconds
|
||||
999_999_999_999, // just under the 1e12 cutoff → seconds branch, far future
|
||||
1_000_000_000_000, // exactly 1e12 → milliseconds branch, 2001
|
||||
1_700_000_000_000, // milliseconds
|
||||
1_766_620_800_000, // milliseconds, 2025-12-25
|
||||
Date.UTC(1999, 11, 31, 23, 59, 59), // ms, century boundary
|
||||
Date.UTC(2038, 0, 19, 3, 14, 7), // ms, past the 32-bit epoch rollover
|
||||
'2024-01-15', // date-only ISO (parsed as UTC midnight)
|
||||
'2024-02-29T12:34:56Z', // leap day, UTC
|
||||
'1999-12-31T23:59:59.999Z',
|
||||
'2020-06-15T10:00:00+05:30', // non-UTC offset
|
||||
'2031-11-05T08:15:30-05:00',
|
||||
'0001-01-01T00:00:00Z', // extreme past
|
||||
'2024-07-04T00:00:00', // no offset (local time)
|
||||
'definitely not a date', // invalid → ''
|
||||
'', // invalid → ''
|
||||
null, // → ''
|
||||
undefined // → ''
|
||||
];
|
||||
|
||||
/** Locales the app ships (see SUPPORTED_LOCALES); 'ar' renders RTL. */
|
||||
const SAMPLE_LOCALES = ['en', 'es', 'ar', 'ja'] as const;
|
||||
|
||||
describe('cached Intl.DateTimeFormat (benchmark gate)', () => {
|
||||
it('formatDate output is identical to the uncached reference', () => {
|
||||
for (const value of DATE_VALUES) {
|
||||
expect(formatDate(value), `formatDate(${JSON.stringify(value)})`).toBe(
|
||||
referenceFormatDate(value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('cached formatters match per-call construction across locales and option shapes', () => {
|
||||
const dates = DATE_VALUES.filter((v): v is number | string => v !== null && v !== undefined)
|
||||
.map((v) => (typeof v === 'number' ? new Date(v < 1e12 ? v * 1000 : v) : new Date(v)))
|
||||
.filter((d) => !Number.isNaN(d.getTime()));
|
||||
expect(dates.length).toBeGreaterThanOrEqual(18);
|
||||
|
||||
for (const locale of SAMPLE_LOCALES) {
|
||||
for (const d of dates) {
|
||||
// Each toLocale*String call below is specified as constructing a
|
||||
// fresh Intl.DateTimeFormat — the uncached reference behaviour.
|
||||
expect(dateTimeFormatFor(locale, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, MONTH_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, MONTH_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, FULL_DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, FULL_DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, DATE_TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(locale, DATE_TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(locale, TIME_OPTS).format(d)).toBe(
|
||||
d.toLocaleTimeString(locale, TIME_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString(undefined, DATE_OPTS)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses one instance per (locale, options) and never freezes the first locale', () => {
|
||||
// Same key → same instance (this is where the speedup comes from).
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS)).toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor(undefined, DATE_OPTS)).toBe(dateTimeFormatFor(undefined, DATE_OPTS));
|
||||
// Different locale or options → different instance: a runtime locale
|
||||
// change must not keep formatting with the first locale seen.
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
expect(dateTimeFormatFor('es', TIME_OPTS)).not.toBe(dateTimeFormatFor('es', DATE_OPTS));
|
||||
const d = new Date(Date.UTC(2024, 4, 17, 12, 0, 0));
|
||||
expect(dateTimeFormatFor('ar', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('ar', DATE_OPTS)
|
||||
);
|
||||
expect(dateTimeFormatFor('es', DATE_OPTS).format(d)).toBe(
|
||||
d.toLocaleDateString('es', DATE_OPTS)
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
'formats 20k dates ≥3x faster than per-call construction (perf gate)',
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
const N = 20_000;
|
||||
const base = Date.UTC(2020, 0, 1);
|
||||
// Deterministic spread of distinct ms timestamps across ~30 years.
|
||||
const values = Array.from({ length: N }, (_, i) => base + i * 47_777_777);
|
||||
|
||||
// Warm up both paths so JIT tiering and first-call construction sit
|
||||
// outside the measured windows. `sink` defeats dead-code elimination.
|
||||
let sink = 0;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
sink += formatDate(values[i]).length;
|
||||
sink += referenceFormatDate(values[i]).length;
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
for (const v of values) sink += formatDate(v).length;
|
||||
const cachedMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
for (const v of values) sink += referenceFormatDate(v).length;
|
||||
const uncachedMs = performance.now() - t1;
|
||||
|
||||
expect(sink).toBeGreaterThan(0);
|
||||
console.info(
|
||||
`formatDate x ${N}: cached ${cachedMs.toFixed(1)} ms vs construct-per-call ${uncachedMs.toFixed(1)} ms (${(uncachedMs / cachedMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(cachedMs).toBeLessThan(uncachedMs / 3);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user