mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): coalesce read state localStorage persistence (#5591)
Follow-on to #5453/#5454's localStorage work — found while investigating app-slowness reports on a real profile. ## Problem `ReadStateManager.persistLocalState()` serialized and rewrote **all three** read-state localStorage blobs (`buzz.channel-read-state.v2`, `.publishable.v1`, `.source-created-at.v1`) synchronously on every context advance. On a real profile (1,643 contexts, ~450K chars across the three blobs) this produced ~880KB of localStorage sqlite WAL growth per 30 seconds at idle, with writes every ~5s — steady main-thread serialization + sync IPC for no user-visible benefit. Observed WAL size on the affected profile: 94–114MB. ## Fix - Local persistence coalesced behind a **1s trailing-edge timer**: a burst of N advances produces one `writeStoredReadState` (one write per blob). - Pending dirty state **flushes synchronously** on `pagehide`, hidden `visibilitychange`, `destroy()`, and before each relay publish — disk is current before any relay event goes out. - Hydration still persists immediately. Publish debounce (5s), merge logic, and blob formats unchanged (`DEBOUNCE_MS` renamed to `PUBLISH_DEBOUNCE_MS` only). ## Accepted residual A hard kill (SIGKILL/power loss — not webview teardown) inside the 1s window loses ≤1s of local read-state advances; relay max-merge bounds the effect to a message flickering back unread. On the record per review. ## Validation - `readStateManager.test.mjs`: fake-timer/mock-storage coverage — exactly one 3-blob write per burst (zero before the timer fires), hidden-flush cancels the timer and persists, hydrate persists immediately, pre-publish flush. Suite 26/26. - Push gate at the pushed commit: desktop check, typecheck, full desktop unit suite 4,670/4,670. - Independent adversarial FULL REVIEW: **APPROVE** at tree `371a02cf` (commit metadata rewritten afterward for attribution; tree identical) — all six `persistLocalState` call sites traced, lifecycle/leak checks (StrictMode remount, pubkey change), no external readers of the blob keys. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -16,10 +16,72 @@ import {
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map();
|
||||
const writes = [];
|
||||
return {
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
setItem: (key, value) => {
|
||||
writes.push([key, value]);
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key) => store.delete(key),
|
||||
writes,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeTimers() {
|
||||
let nextId = 1;
|
||||
let now = 0;
|
||||
const timers = new Map();
|
||||
|
||||
function runThrough(targetTime) {
|
||||
while (true) {
|
||||
const next = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= targetTime)
|
||||
.sort(
|
||||
([firstId, first], [secondId, second]) =>
|
||||
first.dueAt - second.dueAt || firstId - secondId,
|
||||
)[0];
|
||||
if (!next) break;
|
||||
|
||||
const [id, timer] = next;
|
||||
timers.delete(id);
|
||||
now = timer.dueAt;
|
||||
timer.fn();
|
||||
}
|
||||
now = targetTime;
|
||||
}
|
||||
|
||||
return {
|
||||
setTimeout(fn, delay = 0) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { fn, dueAt: now + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
advanceBy(ms) {
|
||||
runThrough(now + ms);
|
||||
},
|
||||
runAll() {
|
||||
while (timers.size > 0) {
|
||||
const nextDueAt = Math.min(
|
||||
...[...timers.values()].map((timer) => timer.dueAt),
|
||||
);
|
||||
runThrough(nextDueAt);
|
||||
}
|
||||
},
|
||||
get size() {
|
||||
return timers.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeRelay() {
|
||||
return {
|
||||
fetchEvents: async () => [],
|
||||
publishEvent: async () => {},
|
||||
subscribeLive: () => () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,19 +89,23 @@ function makeLocalStorage() {
|
||||
// replaced per-test for isolation; the bare `localStorage` global proxies to it.
|
||||
{
|
||||
const ls = makeLocalStorage();
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {
|
||||
localStorage: ls,
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
};
|
||||
} else {
|
||||
globalThis.window.localStorage = ls;
|
||||
if (!globalThis.window.clearTimeout) {
|
||||
globalThis.window.clearTimeout = (id) => clearTimeout(id);
|
||||
globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms);
|
||||
}
|
||||
}
|
||||
const windowEvents = new EventTarget();
|
||||
const documentEvents = new EventTarget();
|
||||
globalThis.window = {
|
||||
localStorage: ls,
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
addEventListener: (...args) => windowEvents.addEventListener(...args),
|
||||
removeEventListener: (...args) => windowEvents.removeEventListener(...args),
|
||||
dispatchEvent: (...args) => windowEvents.dispatchEvent(...args),
|
||||
};
|
||||
globalThis.document = {
|
||||
visibilityState: "visible",
|
||||
addEventListener: (...args) => documentEvents.addEventListener(...args),
|
||||
removeEventListener: (...args) =>
|
||||
documentEvents.removeEventListener(...args),
|
||||
dispatchEvent: (...args) => documentEvents.dispatchEvent(...args),
|
||||
};
|
||||
// Ensure bare `localStorage` always proxies to window.localStorage.
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
get: () => globalThis.window.localStorage,
|
||||
@@ -52,6 +118,204 @@ const channelKey = "channel-1";
|
||||
const channelResolver = (ctx) =>
|
||||
ctx.startsWith("thread:") ? channelKey : null;
|
||||
|
||||
// ── ReadStateManager local persistence ────────────────────────────────────────
|
||||
|
||||
function withFakeTimers() {
|
||||
const timers = makeFakeTimers();
|
||||
const originalSetTimeout = globalThis.window.setTimeout;
|
||||
const originalClearTimeout = globalThis.window.clearTimeout;
|
||||
globalThis.window.setTimeout = (fn, ms) => timers.setTimeout(fn, ms);
|
||||
globalThis.window.clearTimeout = (id) => timers.clearTimeout(id);
|
||||
return {
|
||||
timers,
|
||||
restore() {
|
||||
globalThis.window.setTimeout = originalSetTimeout;
|
||||
globalThis.window.clearTimeout = originalClearTimeout;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("advanceContext burst coalesces local persistence into one write", () => {
|
||||
const storage = makeLocalStorage();
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const manager = new ReadStateManager("1".repeat(64), makeFakeRelay());
|
||||
const baselineWrites = storage.writes.length;
|
||||
|
||||
try {
|
||||
manager.seedContextRead("channel-1", 100);
|
||||
manager.seedContextRead("channel-2", 200);
|
||||
manager.seedContextRead("channel-3", 300);
|
||||
|
||||
assert.equal(timers.size, 1, "burst should leave one trailing timer");
|
||||
assert.equal(storage.writes.length, baselineWrites);
|
||||
|
||||
timers.runAll();
|
||||
assert.equal(
|
||||
storage.writes.length - baselineWrites,
|
||||
3,
|
||||
"one writeStoredReadState call writes its three blobs once",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sustained advances persist within each one-second window", () => {
|
||||
const storage = makeLocalStorage();
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const manager = new ReadStateManager("5".repeat(64), makeFakeRelay());
|
||||
const baselineWrites = storage.writes.length;
|
||||
|
||||
try {
|
||||
manager.seedContextRead("channel-1", 100);
|
||||
|
||||
for (let timestamp = 200; timestamp <= 3_200; timestamp += 200) {
|
||||
timers.advanceBy(200);
|
||||
manager.seedContextRead("channel-1", timestamp);
|
||||
|
||||
if (timestamp % 1_000 === 0) {
|
||||
assert.equal(
|
||||
storage.writes.length - baselineWrites,
|
||||
(timestamp / 1_000) * 3,
|
||||
"latest state should persist once per one-second window",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contexts = JSON.parse(
|
||||
storage.getItem(`buzz.channel-read-state.v2:${"5".repeat(64)}`),
|
||||
);
|
||||
assert.equal(contexts["channel-1"], new Date(2_800_000).toISOString());
|
||||
assert.equal(timers.size, 1, "latest advance should open the next window");
|
||||
} finally {
|
||||
manager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("visibility hidden flushes pending local state", () => {
|
||||
const storage = makeLocalStorage();
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const manager = new ReadStateManager("2".repeat(64), makeFakeRelay());
|
||||
const baselineWrites = storage.writes.length;
|
||||
|
||||
try {
|
||||
manager.seedContextRead("channel-1", 100);
|
||||
assert.equal(storage.writes.length, baselineWrites);
|
||||
|
||||
globalThis.document.visibilityState = "hidden";
|
||||
globalThis.document.dispatchEvent(new Event("visibilitychange"));
|
||||
|
||||
assert.equal(timers.size, 0, "flush should cancel the trailing timer");
|
||||
assert.equal(storage.writes.length - baselineWrites, 3);
|
||||
const contexts = JSON.parse(
|
||||
storage.getItem(`buzz.channel-read-state.v2:${"2".repeat(64)}`),
|
||||
);
|
||||
assert.equal(contexts["channel-1"], new Date(100_000).toISOString());
|
||||
} finally {
|
||||
globalThis.document.visibilityState = "visible";
|
||||
manager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("hydrateFromLocalStorage persists immediately", () => {
|
||||
const storage = makeLocalStorage();
|
||||
const pubkey = "3".repeat(64);
|
||||
storage.setItem(
|
||||
`buzz.channel-read-state.v2:${pubkey}`,
|
||||
JSON.stringify({ "channel-1": new Date(100_000).toISOString() }),
|
||||
);
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const manager = new ReadStateManager(pubkey, makeFakeRelay());
|
||||
const baselineWrites = storage.writes.length;
|
||||
|
||||
try {
|
||||
manager.hydrateFromLocalStorage();
|
||||
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(storage.writes.length - baselineWrites, 3);
|
||||
assert.equal(manager.getOwnTimestamp("channel-1"), 100);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("publish flushes pending local state first", async () => {
|
||||
const storage = makeLocalStorage();
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const manager = new ReadStateManager("4".repeat(64), makeFakeRelay());
|
||||
const baselineWrites = storage.writes.length;
|
||||
|
||||
try {
|
||||
manager.seedContextRead("channel-1", 100);
|
||||
manager.fetchOwnBlobBeforePublish = async () => {};
|
||||
|
||||
await manager.publish();
|
||||
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(storage.writes.length - baselineWrites, 3);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroyed manager cannot persist after an in-flight fetch resolves", async () => {
|
||||
const storage = makeLocalStorage();
|
||||
globalThis.window.localStorage = storage;
|
||||
const { timers, restore } = withFakeTimers();
|
||||
const pubkey = "6".repeat(64);
|
||||
let resolveFetch;
|
||||
const fetchPending = new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
const staleManager = new ReadStateManager(pubkey, {
|
||||
...makeFakeRelay(),
|
||||
fetchEvents: () => fetchPending,
|
||||
});
|
||||
|
||||
try {
|
||||
const initialization = staleManager.initialize();
|
||||
staleManager.seedContextRead("channel-1", 100);
|
||||
staleManager.destroy();
|
||||
|
||||
const replacementManager = new ReadStateManager(pubkey, makeFakeRelay());
|
||||
replacementManager.seedContextRead("channel-1", 200);
|
||||
timers.advanceBy(1_000);
|
||||
const writesAfterReplacement = storage.writes.length;
|
||||
|
||||
resolveFetch([]);
|
||||
await initialization;
|
||||
timers.runAll();
|
||||
|
||||
assert.equal(
|
||||
storage.writes.length,
|
||||
writesAfterReplacement,
|
||||
"the stale manager must not schedule persistence after destruction",
|
||||
);
|
||||
const contexts = JSON.parse(
|
||||
storage.getItem(`buzz.channel-read-state.v2:${pubkey}`),
|
||||
);
|
||||
assert.equal(
|
||||
contexts["channel-1"],
|
||||
new Date(200_000).toISOString(),
|
||||
"the stale manager must not overwrite the replacement manager",
|
||||
);
|
||||
replacementManager.destroy();
|
||||
} finally {
|
||||
staleManager.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveEffectiveTimestamp returns own value when context has no parent", () => {
|
||||
const effectiveState = new Map([[channelKey, 200]]);
|
||||
const result = resolveEffectiveTimestamp({
|
||||
|
||||
@@ -23,7 +23,8 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
|
||||
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
|
||||
const DEBOUNCE_MS = 5_000;
|
||||
const PUBLISH_DEBOUNCE_MS = 5_000;
|
||||
const LOCAL_PERSIST_MAX_WAIT_MS = 1_000;
|
||||
|
||||
function generateHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
@@ -312,6 +313,7 @@ export class ReadStateManager {
|
||||
private publishableContextIds = new Set<string>();
|
||||
private lastPublishedContexts: Record<string, number> = {};
|
||||
private debounceTimer: number | null = null;
|
||||
private localPersistTimer: number | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private unsubscribeLive: (() => void) | null = null;
|
||||
private initialized = false;
|
||||
@@ -331,6 +333,8 @@ export class ReadStateManager {
|
||||
generateHex(16),
|
||||
);
|
||||
this.extraSlotIds = loadExtraSlotIds(pubkey);
|
||||
window.addEventListener("pagehide", this.flushLocalState);
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -377,6 +381,7 @@ export class ReadStateManager {
|
||||
unixTimestamp: number,
|
||||
options: { publishable: boolean },
|
||||
): void {
|
||||
if (this.destroyed) return;
|
||||
const current = this.effectiveState.get(contextId) ?? 0;
|
||||
if (unixTimestamp <= current) {
|
||||
if (!options.publishable || this.publishableContextIds.has(contextId)) {
|
||||
@@ -438,7 +443,14 @@ export class ReadStateManager {
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
// Flush any pending writes immediately
|
||||
window.removeEventListener("pagehide", this.flushLocalState);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange,
|
||||
);
|
||||
this.flushLocalState();
|
||||
|
||||
// Flush any pending relay publish immediately
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
@@ -475,6 +487,7 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
private async mergeEvents(events: RelayEvent[]): Promise<void> {
|
||||
if (this.destroyed) return;
|
||||
// Collect all own blobs (keyed by slot d-tag) to union them all.
|
||||
// NIP-RS: multiple own-slot blobs must be max-merged, not winner-takes-all.
|
||||
const ownBlobsBySlot = new Map<
|
||||
@@ -484,6 +497,7 @@ export class ReadStateManager {
|
||||
|
||||
for (const event of events) {
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
if (this.destroyed) return;
|
||||
if (!parsed) continue;
|
||||
|
||||
this.maxFetchedCreatedAt = Math.max(
|
||||
@@ -520,6 +534,7 @@ export class ReadStateManager {
|
||||
// d-tag coordinate. If so, rotate our slotId to avoid clobbering.
|
||||
for (const event of events) {
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
if (this.destroyed) return;
|
||||
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
|
||||
if (parsed.blob.client_id !== this.clientId) {
|
||||
this.slotId = generateHex(16);
|
||||
@@ -572,14 +587,13 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
|
||||
if (event.pubkey !== this.pubkey) return;
|
||||
if (this.destroyed) return;
|
||||
if (this.destroyed || event.pubkey !== this.pubkey) return;
|
||||
console.debug(
|
||||
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
|
||||
);
|
||||
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
if (!parsed) return;
|
||||
if (!parsed || this.destroyed) return;
|
||||
|
||||
this.maxFetchedCreatedAt = Math.max(
|
||||
this.maxFetchedCreatedAt,
|
||||
@@ -622,18 +636,21 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
private schedulePublish(): void {
|
||||
if (this.destroyed) return;
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.publish();
|
||||
}, DEBOUNCE_MS);
|
||||
}, PUBLISH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async publish(): Promise<void> {
|
||||
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
|
||||
await this.fetchOwnBlobBeforePublish();
|
||||
if (this.destroyed) return;
|
||||
this.flushLocalState();
|
||||
|
||||
// Build blob from contexts this client is allowed to publish.
|
||||
const contexts = this.currentContexts();
|
||||
@@ -927,10 +944,33 @@ export class ReadStateManager {
|
||||
for (const [contextId, createdAt] of stored.contextSourceCreatedAt) {
|
||||
this.contextSourceCreatedAt.set(contextId, createdAt);
|
||||
}
|
||||
this.persistLocalState();
|
||||
this.writeLocalState();
|
||||
}
|
||||
|
||||
private persistLocalState(): void {
|
||||
if (this.destroyed || this.localPersistTimer !== null) return;
|
||||
|
||||
this.localPersistTimer = window.setTimeout(() => {
|
||||
this.localPersistTimer = null;
|
||||
this.writeLocalState();
|
||||
}, LOCAL_PERSIST_MAX_WAIT_MS);
|
||||
}
|
||||
|
||||
private readonly flushLocalState = (): void => {
|
||||
if (this.localPersistTimer === null) return;
|
||||
|
||||
window.clearTimeout(this.localPersistTimer);
|
||||
this.localPersistTimer = null;
|
||||
this.writeLocalState();
|
||||
};
|
||||
|
||||
private readonly handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
this.flushLocalState();
|
||||
}
|
||||
};
|
||||
|
||||
private writeLocalState(): void {
|
||||
writeStoredReadState(
|
||||
this.pubkey,
|
||||
this.effectiveState,
|
||||
|
||||
Reference in New Issue
Block a user