-
- {session.name}
-
+ {editingId === session.id ? (
+ setDraftName(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') commitRename();
+ if (e.key === 'Escape') setEditingId(null);
+ }}
+ className="input-field py-0.5 text-sm flex-1 min-w-0"
+ aria-label={t('sessions.rename')}
+ />
+ ) : (
+ { setEditingId(session.id); setDraftName(session.name); }}
+ className="group flex items-center gap-1.5 min-w-0 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
+ title={t('sessions.rename')}
+ >
+
+ {session.name}
+
+
+
+ )}
{isCurrent && (
{t('sessions.current')}
diff --git a/src/components/__tests__/SessionCache.test.jsx b/src/components/__tests__/SessionCache.test.jsx
index 26785ff..265e200 100644
--- a/src/components/__tests__/SessionCache.test.jsx
+++ b/src/components/__tests__/SessionCache.test.jsx
@@ -109,6 +109,26 @@ describe('local_cache_key session cache', () => {
expect(readCacheKey()).toBe(key);
}, 15000);
+ it('keeps the previous analysis cached when starting a new one', async () => {
+ const user = userEvent.setup();
+ render();
+ await uploadLog(user, 'run-a.log');
+ await waitFor(() => expect(readCacheKey()).not.toBeNull(), { timeout: 4000 });
+ const firstKey = readCacheKey();
+
+ await user.click(screen.getByLabelText(i18n.t('sessions.aria')));
+ await user.click(await screen.findByText(i18n.t('sessions.newAnalysis')));
+ await waitFor(() => expect(readCacheKey()).toBeNull());
+
+ await uploadLog(user, 'run-b.log');
+ await waitFor(() => expect(readCacheKey()).not.toBeNull(), { timeout: 4000 });
+ expect(readCacheKey()).not.toBe(firstKey);
+
+ const sessions = await listSessions();
+ expect(sessions).toHaveLength(2);
+ expect(sessions.map(s => s.name).sort()).toEqual(['run-a.log', 'run-b.log']);
+ }, 15000);
+
it('warns and drops the key when the link is not cached in this browser', async () => {
window.location.hash = '#!/viewer?local_cache_key=11111111-2222-3333-4444-555555555555';
render();
diff --git a/src/utils/__tests__/sessionCache.test.js b/src/utils/__tests__/sessionCache.test.js
index 5e22baa..ae0f9df 100644
--- a/src/utils/__tests__/sessionCache.test.js
+++ b/src/utils/__tests__/sessionCache.test.js
@@ -9,6 +9,7 @@ import {
deleteSession,
clearSessions,
pruneSessions,
+ renameSession,
snapshotFiles,
estimateBytes,
deriveSessionName,
@@ -88,6 +89,17 @@ describe('sessionCache', () => {
expect(list[0].files).toBeUndefined();
});
+ it('renames a snapshot without touching its payload', async () => {
+ await saveSession({ id: 'key-1', files: [file('a.log')] });
+ await renameSession('key-1', 'ResNet sweep');
+ const [summary] = await listSessions();
+ expect(summary.name).toBe('ResNet sweep');
+ expect((await loadSession('key-1')).files).toHaveLength(1);
+ // A later save keeps the chosen name rather than reverting to file names.
+ await saveSession({ id: 'key-1', files: [file('a.log'), file('b.log')] });
+ expect((await listSessions())[0].name).toBe('ResNet sweep');
+ });
+
it('deletes a snapshot and its summary', async () => {
await saveSession({ id: 'key-1', files: [file('a.log')] });
await deleteSession('key-1');
From 4fb1e1a661cc83a451348d3610c7859c6b835f3d Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 29 Aug 2026 10:18:54 +0000
Subject: [PATCH 3/3] perf: split cached content and metrics out of the session
record
Measured in Chromium against the previous layout, which held the raw logs
and every parsed point inside one session record:
20MB / 10 files: first save 718ms, re-save on any state change 752ms
200MB / 10 files: first save 1503ms, re-save 1532ms
...and the raw logs were silently dropped at 64MB
Every checkbox toggle, range edit or metric rename rewrote the whole
snapshot, so the cost scaled with everything cached rather than with what
changed. The 64MB per-session ceiling also degraded all-or-nothing: one
byte over and every file lost its content, which 30 x 2MB already tripped
because parsed points counted toward it.
Now content and metrics live in their own records, one per file, written
only when they actually change; the session record holds names, enabled
flags, configs and references. Same machine, same scenarios:
20MB / 10 files: first save 177ms, toggle 3ms, restore 73ms
60MB / 30 files: first save 505ms, toggle 4ms, restore 190ms
200MB / 10 files: first save 2728ms, toggle 5ms, restore 1295ms
...with all raw logs kept
Also:
- Points pack into Float64Array pairs rather than {x, y} objects: 1M
points write in 106ms instead of 779ms, read in 191ms instead of 931ms.
- Content is stored as UTF-8 bytes taken from the text already in memory,
not as the original File. A File is a reference to the file on disk, so
the next training run overwriting train.log would leave the cache
serving content that no longer matches the metrics parsed from it.
- The byte ceiling is now min(2GB, half the origin's quota) instead of a
flat 192MB, and storage.persist() is requested so the browser is less
likely to evict us.
- Out of space: evict other analyses first, then keep the parsed series
and drop the raw logs, so charts still render.
The lazy-encode rule has its own test: encoding content before deciding
whether it needs writing costs exactly as much as writing it, and put
seconds back into every toggle once already.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_0167bNHXjLvWKocE6AXUJz3A
---
CLAUDE.md | 11 +-
src/utils/__tests__/sessionCache.test.js | 106 ++++--
src/utils/idb.js | 26 +-
src/utils/sessionCache.js | 421 ++++++++++++++++++-----
4 files changed, 442 insertions(+), 122 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 44dddcc..c3e2ade 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,16 @@ This document provides comprehensive guidance for AI assistants (like Claude) wo
IndexedDB under a UUID and addressed by `#!/viewer?local_cache_key=`
- Reload, bookmark or a second tab reopens the same logs without re-uploading
- Local to the browser by design — nothing is uploaded anywhere
- - LRU eviction by age (30d), count (20) and total bytes (192MB)
+ - Four stores, split by write frequency: `sessionIndex` (summaries, so
+ listing never reads log text), `sessions` (small metadata), `content`
+ (one record per file, UTF-8 bytes), `metrics` (one record per file,
+ Float64Array pairs). Content and metrics are written only when they
+ actually change, so a checkbox toggle writes ~4ms regardless of how many
+ hundreds of MB are cached
+ - LRU eviction by age (30d), count (20) and bytes (min of 2GB and half the
+ origin's quota); `navigator.storage.persist()` is requested best-effort
+ - Out of space: evict other analyses, then keep the parsed series and drop
+ the raw logs (charts still render; files are flagged for re-upload)
4. **Global Drag-and-Drop**
- Full-page drag overlay for intuitive file uploads
diff --git a/src/utils/__tests__/sessionCache.test.js b/src/utils/__tests__/sessionCache.test.js
index ae0f9df..ec8cd4c 100644
--- a/src/utils/__tests__/sessionCache.test.js
+++ b/src/utils/__tests__/sessionCache.test.js
@@ -1,7 +1,7 @@
// jsdom has no IndexedDB, so this suite runs the real store logic against
// fake-indexeddb — the same code path browsers take.
import 'fake-indexeddb/auto';
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
saveSession,
loadSession,
@@ -10,16 +10,20 @@ import {
clearSessions,
pruneSessions,
renameSession,
- snapshotFiles,
+ planWrites,
+ packMetrics,
+ unpackMetrics,
+ metricsFingerprint,
estimateBytes,
deriveSessionName,
isCacheAvailable,
MAX_SESSIONS,
- MAX_SESSION_BYTES,
MAX_AGE_MS
} from '../sessionCache';
-function file(name, content = 'loss: 1\n', metrics = { Loss: [{ x: 0, y: 1 }] }) {
+const SERIES = [{ x: 0, y: 1 }, { x: 1, y: 0.5 }];
+
+function file(name, content = 'loss: 1\n', metrics = { Loss: SERIES }) {
return {
id: name,
name,
@@ -29,8 +33,7 @@ function file(name, content = 'loss: 1\n', metrics = { Loss: [{ x: 0, y: 1 }] })
metricsData: metrics,
// runtime-only fields that must not be persisted
isParsing: true,
- progress: 0.5,
- file: { fake: true }
+ progress: 0.5
};
}
@@ -53,7 +56,7 @@ describe('sessionCache', () => {
expect(record.files).toHaveLength(1);
expect(record.files[0].name).toBe('a.log');
expect(record.files[0].content).toBe('loss: 1\n');
- expect(record.files[0].metricsData.Loss).toEqual([{ x: 0, y: 1 }]);
+ expect(record.files[0].metricsData.Loss).toEqual(SERIES);
expect(record.globalParsingConfig.stepKeyword).toBe('iter:');
});
@@ -62,7 +65,6 @@ describe('sessionCache', () => {
const record = await loadSession('key-1');
expect(record.files[0].isParsing).toBeUndefined();
expect(record.files[0].progress).toBeUndefined();
- expect(record.files[0].file).toBeUndefined();
});
it('returns null for an unknown key', async () => {
@@ -78,6 +80,13 @@ describe('sessionCache', () => {
expect(list[0].createdAt).toBe(first.createdAt);
});
+ it('drops the records of files removed from a session', async () => {
+ await saveSession({ id: 'key-1', files: [file('a.log'), file('b.log')] });
+ await saveSession({ id: 'key-1', files: [file('a.log')] });
+ const record = await loadSession('key-1');
+ expect(record.files.map(f => f.name)).toEqual(['a.log']);
+ });
+
it('lists summaries newest first, without file contents', async () => {
const t0 = Date.now();
await saveSession({ id: 'key-1', files: [file('a.log')], now: t0 });
@@ -134,23 +143,78 @@ describe('sessionCache', () => {
expect(await listSessions()).toEqual([]);
});
- it('drops raw content for snapshots over the per-session ceiling', async () => {
- const huge = 'x'.repeat(MAX_SESSION_BYTES + 1);
- const snapshot = snapshotFiles([file('big.log', huge)]);
- expect(snapshot[0].content).toBeNull();
- expect(snapshot[0].isLargeFile).toBe(true);
- // Parsed metrics survive, so charts still render after a reload.
- expect(snapshot[0].metricsData.Loss).toHaveLength(1);
+ it('evicts past the byte ceiling, least recently used first', async () => {
+ const t0 = Date.now();
+ await saveSession({ id: 'small-old', files: [file('a.log', 'x'.repeat(1000))], now: t0 });
+ await saveSession({ id: 'small-new', files: [file('b.log', 'x'.repeat(1000))], now: t0 + 1000 });
+ const evicted = await pruneSessions({ budget: 1500, now: t0 + 2000 });
+ expect(evicted).toEqual(['small-old']);
+ expect((await listSessions()).map(s => s.id)).toEqual(['small-new']);
+ });
- await saveSession({ id: 'key-big', files: [file('big.log', huge)] });
- const [summary] = await listSessions();
- expect(summary.truncated).toBe(true);
+ describe('incremental writes', () => {
+ // Writing content and metrics on every state change is what made the first
+ // version unusable with many large logs; these are the rules that stop it.
+ it('writes content and metrics on the first save', () => {
+ const plan = planWrites([file('a.log')], null, 'key-1');
+ expect(plan.contentWrites).toHaveLength(1);
+ expect(plan.metricsWrites).toHaveLength(1);
+ });
+
+ it('writes nothing but metadata when only a checkbox changed', () => {
+ const first = planWrites([file('a.log')], null, 'key-1');
+ const previous = { files: first.entries };
+ const toggled = { ...file('a.log'), enabled: false };
+ const plan = planWrites([toggled], previous, 'key-1');
+ expect(plan.contentWrites).toEqual([]);
+ expect(plan.metricsWrites).toEqual([]);
+ expect(plan.entries[0].enabled).toBe(false);
+ });
+
+ it('does not even encode content it is not going to write', () => {
+ // Encoding first and deciding after is the same cost as writing: it put
+ // seconds back into every checkbox toggle once before.
+ const first = planWrites([file('a.log')], null, 'key-1');
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ planWrites([{ ...file('a.log'), enabled: false }], { files: first.entries }, 'key-1');
+ expect(encode).not.toHaveBeenCalled();
+ encode.mockRestore();
+ });
+
+ it('rewrites metrics — and only metrics — after a re-parse', () => {
+ const first = planWrites([file('a.log')], null, 'key-1');
+ const previous = { files: first.entries };
+ const reparsed = { ...file('a.log'), metricsData: { Loss: [{ x: 0, y: 9 }] } };
+ const plan = planWrites([reparsed], previous, 'key-1');
+ expect(plan.contentWrites).toEqual([]);
+ expect(plan.metricsWrites).toHaveLength(1);
+ });
+
+ it('reports files that dropped out of the session', () => {
+ const first = planWrites([file('a.log'), file('b.log')], null, 'key-1');
+ const plan = planWrites([file('a.log')], { files: first.entries }, 'key-1');
+ expect(plan.staleIds).toEqual(['b.log']);
+ });
+ });
+
+ it('packs and unpacks series losslessly', () => {
+ const metrics = { Loss: [{ x: 0, y: 1.5 }, { x: 10, y: -2.25e-8 }] };
+ const packed = packMetrics(metrics);
+ expect(packed.Loss.x).toBeInstanceOf(Float64Array);
+ expect(unpackMetrics(packed)).toEqual(metrics);
+ });
+
+ it('fingerprints a parse by count and endpoints', () => {
+ const a = metricsFingerprint({ Loss: SERIES });
+ expect(metricsFingerprint({ Loss: [...SERIES] })).toBe(a);
+ expect(metricsFingerprint({ Loss: [...SERIES, { x: 2, y: 3 }] })).not.toBe(a);
+ expect(metricsFingerprint({ Loss: [{ x: 0, y: 1 }, { x: 1, y: 0.6 }] })).not.toBe(a);
});
- it('sizes a snapshot from content and point counts', () => {
+ it('sizes a snapshot from content bytes and point counts', () => {
expect(estimateBytes([])).toBe(0);
- const bytes = estimateBytes([{ name: 'a', content: 'abcde', metricsData: { L: [1, 2, 3] } }]);
- expect(bytes).toBe(5 + 1 + 3 * 40);
+ expect(estimateBytes([{ content: 'abcde', metricsData: { L: [1, 2, 3] } }])).toBe(5 + 3 * 16);
+ expect(estimateBytes([{ content: 'ab', metricsData: {} }])).toBe(2);
});
it('names a session after its files', () => {
diff --git a/src/utils/idb.js b/src/utils/idb.js
index 3415355..5133098 100644
--- a/src/utils/idb.js
+++ b/src/utils/idb.js
@@ -1,19 +1,29 @@
// Shared IndexedDB handle for the app.
//
-// Three object stores live in one database and therefore must be created by a
+// The object stores live in one database and therefore must be created by a
// single upgrade path — two modules opening the same database at different
// versions would make the older opener fail with a VersionError:
// files – the single active working set (fileStorage.js)
-// sessions – full cached snapshots keyed by cache key (sessionCache.js)
-// sessionIndex – lightweight summaries so the session list can render
-// without deserializing megabytes of log content
+// sessionIndex – lightweight summaries, so listing cached analyses never
+// touches log text
+// sessions – per-analysis metadata: file names, enabled flags, configs,
+// and references into the two stores below
+// content – one record per file, holding the raw log
+// metrics – one record per file, holding parsed series as typed arrays
+//
+// Content and metrics are split out of the session record on purpose: they are
+// large and rarely change, while the session record is small and rewritten on
+// every checkbox toggle. Keeping them together made a toggle rewrite hundreds
+// of megabytes (measured at ~1.5s per 100MB); split, it writes ~1ms.
const DB_NAME = 'log-parser';
-const DB_VERSION = 2;
+const DB_VERSION = 3;
export const FILES_STORE = 'files';
export const SESSIONS_STORE = 'sessions';
export const SESSION_INDEX_STORE = 'sessionIndex';
+export const CONTENT_STORE = 'content';
+export const METRICS_STORE = 'metrics';
export function hasIDB() {
return typeof indexedDB !== 'undefined';
@@ -38,6 +48,12 @@ export function openDB() {
const idx = db.createObjectStore(SESSION_INDEX_STORE, { keyPath: 'id' });
idx.createIndex('lastAccess', 'lastAccess');
}
+ if (!db.objectStoreNames.contains(CONTENT_STORE)) {
+ db.createObjectStore(CONTENT_STORE);
+ }
+ if (!db.objectStoreNames.contains(METRICS_STORE)) {
+ db.createObjectStore(METRICS_STORE);
+ }
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
diff --git a/src/utils/sessionCache.js b/src/utils/sessionCache.js
index 0c96713..e0c74d3 100644
--- a/src/utils/sessionCache.js
+++ b/src/utils/sessionCache.js
@@ -7,8 +7,22 @@
// parsed metrics + the parsing config that produced them) is written to
// IndexedDB under a cache key, and `cacheUrl.js` puts that key in the hash.
//
-// Two stores back it: `sessions` holds the full payload, `sessionIndex` holds a
-// small summary so listing cached analyses never has to deserialize log text.
+// Layout matters here, because people routinely open a lot of large logs:
+//
+// sessionIndex small summary per analysis — listing never reads log text
+// sessions small metadata record — names, enabled flags, configs, refs
+// content one record per file, holding the raw log (a Blob when the
+// original File is still around, which stores by reference)
+// metrics one record per file, series packed as Float64Array pairs
+//
+// Content and metrics are written only when they actually change, so toggling a
+// checkbox or editing a range rewrites the small record and nothing else. The
+// measurements that drove this (Chromium, IndexedDB): 100MB of logs in one
+// record costs ~1.5s to write and was rewritten on *every* state change; split
+// out, the same toggle writes ~1ms. Packing 1M points as typed arrays instead
+// of {x,y} objects took writes from 779ms to 106ms and reads from 931ms to
+// 191ms.
+//
// Eviction is LRU with age, count and byte ceilings — a cache that grows until
// the browser kills the origin's storage is worse than one that forgets.
@@ -19,49 +33,142 @@ import {
requestToPromise,
txDone,
SESSIONS_STORE,
- SESSION_INDEX_STORE
+ SESSION_INDEX_STORE,
+ CONTENT_STORE,
+ METRICS_STORE
} from './idb.js';
export const MAX_SESSIONS = 20;
-export const MAX_TOTAL_BYTES = 192 * 1024 * 1024;
-export const MAX_SESSION_BYTES = 64 * 1024 * 1024;
+// Ceiling on everything the cache holds. Also clamped to half the origin's
+// reported quota, so a small disk doesn't get filled by cached logs.
+export const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
+export const MIN_TOTAL_BYTES = 128 * 1024 * 1024;
export const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+// Two Float64s per point.
+const BYTES_PER_POINT = 16;
export function isCacheAvailable() {
return hasIDB();
}
-// Rough byte cost of a snapshot. Deliberately cheap: JSON.stringify on a
-// multi-megabyte log on every keystroke-triggered save would cost more than the
-// write it is sizing.
+const recordKey = (sessionId, fileId) => `${sessionId}:${fileId}`;
+const sessionRange = (sessionId) => IDBKeyRange.bound(`${sessionId}:`, `${sessionId}:`);
+
+// Ask the browser not to evict us under storage pressure. Best effort: it is a
+// no-op where unsupported, and a denial just means the cache behaves as before.
+let persistRequested = false;
+function requestPersistence() {
+ if (persistRequested) return;
+ persistRequested = true;
+ try {
+ navigator?.storage?.persist?.().catch(() => {});
+ } catch { /* unsupported */ }
+}
+
+async function totalBudget() {
+ try {
+ const quota = (await navigator?.storage?.estimate?.())?.quota;
+ if (quota) {
+ return Math.max(MIN_TOTAL_BYTES, Math.min(MAX_TOTAL_BYTES, Math.floor(quota / 2)));
+ }
+ } catch { /* estimate unavailable */ }
+ return MAX_TOTAL_BYTES;
+}
+
+export function countPoints(metricsData) {
+ return Object.values(metricsData || {})
+ .reduce((n, series) => n + (Array.isArray(series) ? series.length : 0), 0);
+}
+
+// Byte cost of one file's cached form: raw log plus packed series. Content is
+// sized by character count — an upper bound for the UTF-8 bytes actually
+// stored, for the ASCII that training logs are made of.
+export function fileBytes(file) {
+ return contentBytes(file) + countPoints(file.metricsData) * BYTES_PER_POINT;
+}
+
export function estimateBytes(files) {
- return (files || []).reduce((total, file) => {
- let n = (file.content ? file.content.length : 0) + (file.name ? file.name.length : 0);
- const metrics = file.metricsData || {};
- Object.keys(metrics).forEach(key => {
- const series = metrics[key];
- if (Array.isArray(series)) n += series.length * 40;
- });
- return total + n;
- }, 0);
-}
-
-// Strip runtime-only fields (worker progress, File handles, transient errors)
-// and drop raw content for snapshots too large to be worth caching whole — the
-// parsed metrics still render, the file is just flagged for re-upload if the
-// user wants to re-parse it.
-export function snapshotFiles(files) {
- const list = (files || []).map(({ id, name, enabled, content, config, metricsData }) => ({
- id,
- name,
- enabled: enabled ?? true,
- content: content ?? null,
- config,
- metricsData: metricsData || {},
- isLargeFile: false
- }));
- if (estimateBytes(list) <= MAX_SESSION_BYTES) return list;
- return list.map(file => ({ ...file, content: null, isLargeFile: true }));
+ return (files || []).reduce((total, file) => total + fileBytes(file), 0);
+}
+
+// Points are plain {x, y} numbers (see logParser.worker.js), so two Float64
+// arrays hold them losslessly at a fraction of the structured-clone cost.
+export function packMetrics(metricsData) {
+ const packed = {};
+ Object.keys(metricsData || {}).forEach(name => {
+ const series = metricsData[name];
+ if (!Array.isArray(series)) return;
+ const x = new Float64Array(series.length);
+ const y = new Float64Array(series.length);
+ for (let i = 0; i < series.length; i += 1) {
+ const point = series[i] || {};
+ x[i] = point.x;
+ y[i] = point.y;
+ }
+ packed[name] = { x, y };
+ });
+ return packed;
+}
+
+export function unpackMetrics(packed) {
+ const out = {};
+ Object.keys(packed || {}).forEach(name => {
+ const entry = packed[name];
+ if (!entry) return;
+ if (Array.isArray(entry)) { out[name] = entry; return; }
+ const { x, y } = entry;
+ const points = new Array(x.length);
+ for (let i = 0; i < x.length; i += 1) points[i] = { x: x[i], y: y[i] };
+ out[name] = points;
+ });
+ return out;
+}
+
+// Cheap change detector, so an unchanged parse is not rewritten. A re-parse
+// always changes the point count or the values at one of the ends.
+export function metricsFingerprint(metricsData) {
+ return Object.keys(metricsData || {}).sort().map(name => {
+ const series = metricsData[name];
+ if (!Array.isArray(series) || series.length === 0) return `${name}:0`;
+ const first = series[0] || {};
+ const last = series[series.length - 1] || {};
+ return `${name}:${series.length}:${first.x},${first.y}:${last.x},${last.y}`;
+ }).join('|');
+}
+
+function contentBytes(file) {
+ const content = file.content;
+ return content ? content.length : 0;
+}
+
+// The raw log, stored so that re-parsing works after a reload.
+//
+// Stored as UTF-8 bytes taken from the text we already hold, deliberately not
+// as the original File: a File is a reference to the file on disk, so the next
+// training run overwriting train.log would leave the cache serving content that
+// no longer matches the metrics parsed from it — or failing to read at all.
+// The encoded copy also halves the stored bytes for ASCII logs, since JS
+// strings are UTF-16, and structured-clones far faster than a string does.
+function contentValue(file) {
+ if (file.content == null) return null;
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(file.content);
+ return file.content;
+}
+
+async function readContentValue(value) {
+ if (value == null) return null;
+ try {
+ if (typeof value === 'string') return value;
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
+ return new TextDecoder().decode(value);
+ }
+ // Blob, from a cache written by an older build.
+ if (typeof value.text === 'function') return await value.text();
+ } catch {
+ // Unreadable record — treat it as a file that has to be re-uploaded
+ // before it can be re-parsed. The parsed series still render.
+ }
+ return null;
}
export function deriveSessionName(files) {
@@ -81,12 +188,21 @@ async function readIndex() {
async function removeIds(ids) {
if (ids.length === 0) return;
const db = await openDB();
- const tx = db.transaction([SESSIONS_STORE, SESSION_INDEX_STORE], 'readwrite');
+ const tx = db.transaction(
+ [SESSIONS_STORE, SESSION_INDEX_STORE, CONTENT_STORE, METRICS_STORE],
+ 'readwrite'
+ );
const sessions = tx.objectStore(SESSIONS_STORE);
const index = tx.objectStore(SESSION_INDEX_STORE);
+ const content = tx.objectStore(CONTENT_STORE);
+ const metrics = tx.objectStore(METRICS_STORE);
ids.forEach(id => {
sessions.delete(id);
index.delete(id);
+ // Keys are `${sessionId}:${fileId}`, so one range clears all of a
+ // session's files without reading the record first.
+ content.delete(sessionRange(id));
+ metrics.delete(sessionRange(id));
});
await txDone(tx);
}
@@ -94,7 +210,7 @@ async function removeIds(ids) {
// Enforce the age / count / byte ceilings, least-recently-used first.
// `keepId` is never evicted — dropping the analysis currently on screen would
// break the link in the address bar.
-export async function pruneSessions({ keepId = null, now = Date.now() } = {}) {
+export async function pruneSessions({ keepId = null, now = Date.now(), budget } = {}) {
if (!hasIDB()) return [];
let entries;
try {
@@ -102,6 +218,7 @@ export async function pruneSessions({ keepId = null, now = Date.now() } = {}) {
} catch {
return [];
}
+ const byteCeiling = budget ?? await totalBudget();
const evict = new Set();
const survivors = [];
entries.forEach(entry => {
@@ -116,7 +233,7 @@ export async function pruneSessions({ keepId = null, now = Date.now() } = {}) {
let total = survivors.reduce((n, e) => n + (e.bytes || 0), 0);
let count = survivors.length;
for (const entry of survivors) {
- if (count <= MAX_SESSIONS && total <= MAX_TOTAL_BYTES) break;
+ if (count <= MAX_SESSIONS && total <= byteCeiling) break;
if (entry.id === keepId) continue;
evict.add(entry.id);
total -= entry.bytes || 0;
@@ -131,9 +248,81 @@ export async function pruneSessions({ keepId = null, now = Date.now() } = {}) {
return ids;
}
-async function writeSession(record, summary) {
+async function getRecord(store, key) {
+ if (!hasIDB() || !key) return null;
+ try {
+ const db = await openDB();
+ const tx = db.transaction(store, 'readonly');
+ const result = await requestToPromise(tx.objectStore(store).get(key));
+ return result || null;
+ } catch {
+ return null;
+ }
+}
+
+export function getSummary(id) {
+ return getRecord(SESSION_INDEX_STORE, id);
+}
+
+// Decide what actually has to be written. Content is immutable for a given file
+// id, so it is written once; metrics are rewritten only when the parse changed.
+// Exported for tests: this is the rule that keeps a checkbox toggle from
+// rewriting hundreds of megabytes.
+export function planWrites(files, previous, sessionId, { withContent = true } = {}) {
+ const prevById = new Map((previous?.files || []).map(entry => [entry.id, entry]));
+ const contentWrites = [];
+ const metricsWrites = [];
+ const entries = files.map(file => {
+ const prev = prevById.get(file.id);
+ const bytes = contentBytes(file);
+ // Decide before encoding: a file already in the cache must not be re-encoded
+ // just to find out it does not need writing. Encoding first is what made a
+ // checkbox toggle cost seconds on a few hundred MB of logs.
+ const reuseContent = !!prev?.hasContent && prev.contentBytes === bytes;
+ let hasContent = reuseContent;
+ if (withContent && !reuseContent) {
+ const value = contentValue(file);
+ if (value != null) {
+ contentWrites.push([recordKey(sessionId, file.id), value]);
+ hasContent = true;
+ }
+ }
+
+ const fingerprint = metricsFingerprint(file.metricsData);
+ const points = countPoints(file.metricsData);
+ if (points > 0 && prev?.fingerprint !== fingerprint) {
+ metricsWrites.push([recordKey(sessionId, file.id), packMetrics(file.metricsData)]);
+ }
+
+ return {
+ id: file.id,
+ name: file.name,
+ enabled: file.enabled ?? true,
+ config: file.config,
+ hasContent,
+ contentBytes: bytes,
+ fingerprint,
+ points
+ };
+ });
+ const staleIds = [...prevById.keys()].filter(id => !files.some(f => f.id === id));
+ return { entries, contentWrites, metricsWrites, staleIds };
+}
+
+async function commit({ record, summary, contentWrites, metricsWrites, staleIds, sessionId }) {
const db = await openDB();
- const tx = db.transaction([SESSIONS_STORE, SESSION_INDEX_STORE], 'readwrite');
+ const tx = db.transaction(
+ [SESSIONS_STORE, SESSION_INDEX_STORE, CONTENT_STORE, METRICS_STORE],
+ 'readwrite'
+ );
+ const content = tx.objectStore(CONTENT_STORE);
+ const metrics = tx.objectStore(METRICS_STORE);
+ contentWrites.forEach(([key, value]) => content.put(value, key));
+ metricsWrites.forEach(([key, value]) => metrics.put(value, key));
+ staleIds.forEach(fileId => {
+ content.delete(recordKey(sessionId, fileId));
+ metrics.delete(recordKey(sessionId, fileId));
+ });
tx.objectStore(SESSIONS_STORE).put(record);
tx.objectStore(SESSION_INDEX_STORE).put(summary);
await txDone(tx);
@@ -142,94 +331,136 @@ async function writeSession(record, summary) {
/**
* Write (or overwrite) the snapshot stored under `id`.
* Returns the index summary, or null when caching is unavailable.
- * Throws QuotaExceededError if the browser refuses the write even after evicting.
+ * Throws QuotaExceededError if the browser refuses the write even after
+ * evicting other analyses and giving up the raw logs.
*/
export async function saveSession({ id, files, globalParsingConfig, name, createdAt, now = Date.now() }) {
if (!hasIDB()) return null;
- const snapshot = snapshotFiles(files);
- const bytes = estimateBytes(snapshot);
- const existing = await getSummary(id);
- const record = {
- id,
- files: snapshot,
- globalParsingConfig: globalParsingConfig ? JSON.parse(JSON.stringify(globalParsingConfig)) : null,
- createdAt: createdAt || existing?.createdAt || now,
- updatedAt: now
+ requestPersistence();
+
+ const previous = await getRecord(SESSIONS_STORE, id);
+ const existingSummary = await getSummary(id);
+
+ const build = (opts) => {
+ const plan = planWrites(files || [], previous, id, opts);
+ const bytes = plan.entries.reduce(
+ (total, entry) => total + (entry.hasContent ? entry.contentBytes : 0) + entry.points * BYTES_PER_POINT,
+ 0
+ );
+ return {
+ plan,
+ record: {
+ id,
+ files: plan.entries,
+ globalParsingConfig: globalParsingConfig ? JSON.parse(JSON.stringify(globalParsingConfig)) : null,
+ createdAt: createdAt || previous?.createdAt || existingSummary?.createdAt || now,
+ updatedAt: now
+ },
+ summary: {
+ id,
+ name: name || existingSummary?.name || deriveSessionName(files),
+ fileNames: plan.entries.map(f => f.name),
+ fileCount: plan.entries.length,
+ bytes,
+ truncated: plan.entries.some(f => !f.hasContent && f.points > 0),
+ createdAt: createdAt || previous?.createdAt || existingSummary?.createdAt || now,
+ updatedAt: now,
+ lastAccess: now
+ }
+ };
};
- const summary = {
- id,
- name: name || existing?.name || deriveSessionName(snapshot),
- fileNames: snapshot.map(f => f.name),
- fileCount: snapshot.length,
- bytes,
- truncated: snapshot.some(f => f.isLargeFile),
- createdAt: record.createdAt,
- updatedAt: now,
- lastAccess: now
+
+ const write = async ({ plan, record, summary }) => {
+ await commit({
+ record,
+ summary,
+ contentWrites: plan.contentWrites,
+ metricsWrites: plan.metricsWrites,
+ staleIds: plan.staleIds,
+ sessionId: id
+ });
+ return summary;
};
+ const full = build();
try {
- await writeSession(record, summary);
+ const summary = await write(full);
+ await pruneSessions({ keepId: id, now });
+ return summary;
} catch (err) {
if (!isQuotaError(err)) throw err;
- // Storage is full: evict everything but the snapshot being written, then
- // give it exactly one more try before surfacing the failure.
+ // Storage is full. First give up the other cached analyses…
const entries = await readIndex().catch(() => []);
await removeIds(entries.map(e => e.id).filter(otherId => otherId !== id)).catch(() => {});
- await writeSession(record, summary);
- }
- await pruneSessions({ keepId: id, now });
- return summary;
-}
-
-export async function getSummary(id) {
- if (!hasIDB() || !id) return null;
- try {
- const db = await openDB();
- const tx = db.transaction(SESSION_INDEX_STORE, 'readonly');
- const result = await requestToPromise(tx.objectStore(SESSION_INDEX_STORE).get(id));
- return result || null;
- } catch {
- return null;
+ try {
+ return await write(build());
+ } catch (retryErr) {
+ if (!isQuotaError(retryErr)) throw retryErr;
+ // …then the raw logs. Charts still render from the parsed series; the
+ // files are flagged so the UI can ask for a re-upload before re-parsing.
+ await removeIds([id]).catch(() => {});
+ return write(build({ withContent: false }));
+ }
}
}
/** Read a snapshot back and mark it as most-recently used. */
export async function loadSession(id) {
if (!hasIDB() || !id) return null;
- let record;
+ const record = await getRecord(SESSIONS_STORE, id);
+ if (!record) return null;
+
+ let contents = [];
+ let metrics = [];
try {
const db = await openDB();
- const tx = db.transaction(SESSIONS_STORE, 'readonly');
- record = await requestToPromise(tx.objectStore(SESSIONS_STORE).get(id));
+ const tx = db.transaction([CONTENT_STORE, METRICS_STORE], 'readonly');
+ const contentStore = tx.objectStore(CONTENT_STORE);
+ const metricsStore = tx.objectStore(METRICS_STORE);
+ const keys = (record.files || []).map(entry => recordKey(id, entry.id));
+ contents = await Promise.all(keys.map(key => requestToPromise(contentStore.get(key))));
+ metrics = await Promise.all(keys.map(key => requestToPromise(metricsStore.get(key))));
} catch {
return null;
}
- if (!record) return null;
+
+ const files = await Promise.all((record.files || []).map(async (entry, i) => {
+ const content = await readContentValue(contents[i]);
+ return {
+ id: entry.id,
+ name: entry.name,
+ enabled: entry.enabled ?? true,
+ config: entry.config,
+ content,
+ metricsData: unpackMetrics(metrics[i]),
+ // Mirrors the working set's own flag: no raw log means re-parsing needs
+ // the file back, even though the charts render fine without it.
+ isLargeFile: content == null
+ };
+ }));
+
await touchSession(id).catch(() => {});
- return record;
+ return { id: record.id, files, globalParsingConfig: record.globalParsingConfig };
}
-export async function touchSession(id, now = Date.now()) {
- const summary = await getSummary(id);
- if (!summary) return null;
- const next = { ...summary, lastAccess: now };
+async function putSummary(summary) {
const db = await openDB();
const tx = db.transaction(SESSION_INDEX_STORE, 'readwrite');
- tx.objectStore(SESSION_INDEX_STORE).put(next);
+ tx.objectStore(SESSION_INDEX_STORE).put(summary);
await txDone(tx);
- return next;
+ return summary;
+}
+
+export async function touchSession(id, now = Date.now()) {
+ const summary = await getSummary(id);
+ if (!summary) return null;
+ return putSummary({ ...summary, lastAccess: now });
}
export async function renameSession(id, name) {
const summary = await getSummary(id);
if (!summary) return null;
- const next = { ...summary, name };
- const db = await openDB();
- const tx = db.transaction(SESSION_INDEX_STORE, 'readwrite');
- tx.objectStore(SESSION_INDEX_STORE).put(next);
- await txDone(tx);
- return next;
+ return putSummary({ ...summary, name });
}
/** Summaries for the cache list, most recently updated first. */