mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): time-based sweep for stale localStorage caches (#5453)
Part of #5418 (Phase 1, lane B). ## What Adds a periodic, whitelist-driven TTL sweep for disposable localStorage caches so a desktop session left open for days converges to the same storage state as one restarted nightly. - New `desktop/src/shared/lib/localStorageSweep.ts`: declarative `LOCAL_STORAGE_SWEEP_RULES` table — six repaintable pure-cache prefixes (matching `PURE_CACHE_KEY_PREFIXES` in `localStorageQuota.ts`), all 14-day TTL, keyed on each payload's `updatedAt` (user-label buckets use their newest nested per-profile timestamp). - Entries with no trustworthy timestamp are retained, never guessed stale. `buzz-self-profile.v1:` is deliberately excluded — it is the load-bearing offline identity fallback (guard comment in the table). - Scheduler: first sweep deferred off the boot critical path via `requestIdleCallback` (1.5s timeout) with a 250ms timer fallback, then hourly and on return-to-visible, debounced to 5 minutes. Throw-safe throughout (failures `console.warn`, never crash — per `safeStorage.ts` conventions / #5078). - Wired in `desktop/src/main.tsx` beside `recoverLocalStorageQuotaOnStartup()`. ## Validation - Focused node test 7/7 at HEAD; pre-push gate green (desktop-check, desktop-typecheck, full desktop-test 4542/4542). - Manual Playwright (not covered by push hooks): `relay-connectivity.spec.ts -g "04"` (offline cached identity) passes 1/1 at HEAD — this spec caught and now guards the v1 regression. - Independent adversarial review: FULL REVIEW (REQUEST CHANGES) then VERIFIED — PASS at exactly this commit, including whitelist containment against the 58-site inventory, scheduler tracing, and smoke E2E. Authored by Summer (agent), reviewed by Beth (agent), integrated by Rick (agent). Discussion: Buzz channel time-based-localstorage-eviction, thread 0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Summer <1fdd3cc104e2911eb3b2da6f97d1b25f4a7f3550ded4492b24ff1d95acd66766@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -18,6 +18,7 @@ import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
|
||||
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__BUZZ_E2E__?: unknown;
|
||||
@@ -122,6 +123,7 @@ async function bootstrap() {
|
||||
resetDevWebviewStateFromUrl();
|
||||
configureDevE2eBridgeFromUrl();
|
||||
recoverLocalStorageQuotaOnStartup();
|
||||
startLocalStorageSweep();
|
||||
await installE2eBridgeIfConfigured();
|
||||
await migrateLegacyCommunityStorageBeforeRender();
|
||||
renderApp();
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
LOCAL_STORAGE_SWEEP_RULES,
|
||||
startLocalStorageSweep,
|
||||
sweepStaleLocalStorage,
|
||||
} from "./localStorageSweep.ts";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
function makeLocalStorage(entries = []) {
|
||||
const store = new Map(entries);
|
||||
return {
|
||||
store,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (index) => [...store.keys()][index] ?? null,
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: (key) => store.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
function installWindow(localStorage, overrides = {}) {
|
||||
globalThis.window = { localStorage, ...overrides };
|
||||
}
|
||||
|
||||
const snapshot = (updatedAt) => JSON.stringify({ updatedAt, payload: "cache" });
|
||||
|
||||
test("sweeps stale whitelisted caches and keeps fresh or durable state", () => {
|
||||
const now = 100 * DAY_MS;
|
||||
const entries = LOCAL_STORAGE_SWEEP_RULES.flatMap(
|
||||
({ keyPrefix, maxAgeMs }, index) => [
|
||||
[`${keyPrefix}stale-a-${index}`, snapshot(now - maxAgeMs)],
|
||||
[`${keyPrefix}stale-b-${index}`, snapshot(now - maxAgeMs - DAY_MS)],
|
||||
[`${keyPrefix}fresh-${index}`, snapshot(now - maxAgeMs + 1)],
|
||||
],
|
||||
);
|
||||
entries.push(["buzz-communities", snapshot(0)]);
|
||||
entries.push(["buzz-theme", snapshot(0)]);
|
||||
entries.push(["buzz-self-profile.v1:offline", snapshot(0)]);
|
||||
const localStorage = makeLocalStorage(entries);
|
||||
installWindow(localStorage);
|
||||
|
||||
assert.equal(
|
||||
sweepStaleLocalStorage(now),
|
||||
LOCAL_STORAGE_SWEEP_RULES.length * 2,
|
||||
);
|
||||
for (const { keyPrefix } of LOCAL_STORAGE_SWEEP_RULES) {
|
||||
assert.equal(
|
||||
[...localStorage.store.keys()].some((key) =>
|
||||
key.startsWith(`${keyPrefix}stale-`),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
[...localStorage.store.keys()].some((key) =>
|
||||
key.startsWith(`${keyPrefix}fresh-`),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(localStorage.getItem("buzz-communities"), snapshot(0));
|
||||
assert.equal(localStorage.getItem("buzz-theme"), snapshot(0));
|
||||
assert.equal(
|
||||
localStorage.getItem("buzz-self-profile.v1:offline"),
|
||||
snapshot(0),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the newest per-profile timestamp for user-label cache buckets", () => {
|
||||
const now = 100 * DAY_MS;
|
||||
const localStorage = makeLocalStorage([
|
||||
[
|
||||
"buzz-user-labels.v1:all-stale",
|
||||
JSON.stringify({
|
||||
profiles: {
|
||||
first: { updatedAt: now - 20 * DAY_MS },
|
||||
second: { updatedAt: now - 14 * DAY_MS },
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"buzz-user-labels.v1:one-fresh",
|
||||
JSON.stringify({
|
||||
profiles: {
|
||||
stale: { updatedAt: now - 20 * DAY_MS },
|
||||
fresh: { updatedAt: now - DAY_MS },
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
installWindow(localStorage);
|
||||
|
||||
assert.equal(sweepStaleLocalStorage(now), 1);
|
||||
assert.equal(localStorage.getItem("buzz-user-labels.v1:all-stale"), null);
|
||||
assert.notEqual(localStorage.getItem("buzz-user-labels.v1:one-fresh"), null);
|
||||
});
|
||||
|
||||
test("leaves malformed and timestamp-free cache entries untouched", () => {
|
||||
const localStorage = makeLocalStorage([
|
||||
["buzz-channel-messages.v1:malformed", "not json"],
|
||||
["buzz-channels.v1:no-timestamp", JSON.stringify({ payload: "cache" })],
|
||||
["buzz-observed-unread.v1:bad-timestamp", snapshot(Number.NaN)],
|
||||
]);
|
||||
installWindow(localStorage);
|
||||
|
||||
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
|
||||
assert.equal(localStorage.store.size, 3);
|
||||
});
|
||||
|
||||
test("storage access failures warn and never escape", () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "localStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("SecurityError");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.doesNotThrow(() => sweepStaleLocalStorage(100 * DAY_MS));
|
||||
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
|
||||
assert.equal(warnings.length, 2);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("scheduler setup failures warn and never escape startup", () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
globalThis.document = {
|
||||
addEventListener() {
|
||||
throw new Error("listener unavailable");
|
||||
},
|
||||
removeEventListener() {},
|
||||
visibilityState: "visible",
|
||||
};
|
||||
installWindow(makeLocalStorage(), {
|
||||
clearInterval() {},
|
||||
setInterval() {
|
||||
throw new Error("timer unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let stop;
|
||||
assert.doesNotThrow(() => {
|
||||
stop = startLocalStorageSweep();
|
||||
});
|
||||
assert.doesNotThrow(() => stop());
|
||||
assert.equal(warnings.length, 1);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test("scheduler uses a deferred timer when requestIdleCallback is unavailable", () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const documentTarget = new EventTarget();
|
||||
Object.defineProperty(documentTarget, "visibilityState", {
|
||||
value: "visible",
|
||||
});
|
||||
globalThis.document = documentTarget;
|
||||
|
||||
const timeouts = new Map();
|
||||
let nextTimeoutId = 50;
|
||||
globalThis.setTimeout = (callback, delay) => {
|
||||
const id = nextTimeoutId++;
|
||||
timeouts.set(id, { callback, delay });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timeouts.delete(id);
|
||||
|
||||
const localStorage = makeLocalStorage([
|
||||
["buzz-channel-messages.v1:startup", snapshot(0)],
|
||||
]);
|
||||
installWindow(localStorage, {
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
|
||||
const stop = startLocalStorageSweep();
|
||||
try {
|
||||
assert.notEqual(
|
||||
localStorage.getItem("buzz-channel-messages.v1:startup"),
|
||||
null,
|
||||
);
|
||||
const [{ callback, delay }] = timeouts.values();
|
||||
assert.equal(delay, 250);
|
||||
callback();
|
||||
assert.equal(
|
||||
localStorage.getItem("buzz-channel-messages.v1:startup"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
assert.equal(timeouts.size, 0);
|
||||
});
|
||||
|
||||
test("scheduler sweeps after idle, on debounced visibility, and hourly", () => {
|
||||
const originalNow = Date.now;
|
||||
const originalDocument = globalThis.document;
|
||||
let now = 100 * DAY_MS;
|
||||
Date.now = () => now;
|
||||
|
||||
const documentTarget = new EventTarget();
|
||||
Object.defineProperty(documentTarget, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
globalThis.document = documentTarget;
|
||||
|
||||
const intervals = new Map();
|
||||
const idleCallbacks = new Map();
|
||||
const cancelledIdleCallbacks = [];
|
||||
let nextIntervalId = 1;
|
||||
let nextIdleId = 100;
|
||||
const localStorage = makeLocalStorage([
|
||||
["buzz-channel-messages.v1:startup", snapshot(now - 14 * DAY_MS)],
|
||||
]);
|
||||
installWindow(localStorage, {
|
||||
requestIdleCallback(callback, options) {
|
||||
const id = nextIdleId++;
|
||||
idleCallbacks.set(id, { callback, options });
|
||||
return id;
|
||||
},
|
||||
cancelIdleCallback(id) {
|
||||
cancelledIdleCallbacks.push(id);
|
||||
idleCallbacks.delete(id);
|
||||
},
|
||||
setInterval(callback, delay) {
|
||||
const id = nextIntervalId++;
|
||||
intervals.set(id, { callback, delay });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id) => intervals.delete(id),
|
||||
});
|
||||
|
||||
let idleId;
|
||||
const stop = startLocalStorageSweep();
|
||||
try {
|
||||
assert.notEqual(
|
||||
localStorage.getItem("buzz-channel-messages.v1:startup"),
|
||||
null,
|
||||
"initial sweep must not run synchronously on the boot path",
|
||||
);
|
||||
assert.equal(idleCallbacks.size, 1);
|
||||
const idleEntry = idleCallbacks.entries().next().value;
|
||||
idleId = idleEntry[0];
|
||||
const { callback: idleCallback, options } = idleEntry[1];
|
||||
assert.equal(options.timeout, 1_500);
|
||||
idleCallback();
|
||||
assert.equal(
|
||||
localStorage.getItem("buzz-channel-messages.v1:startup"),
|
||||
null,
|
||||
);
|
||||
assert.equal(intervals.size, 1);
|
||||
const [{ callback, delay }] = intervals.values();
|
||||
assert.equal(delay, 60 * 60 * 1_000);
|
||||
|
||||
localStorage.setItem(
|
||||
"buzz-channel-messages.v1:visibility",
|
||||
snapshot(now - 14 * DAY_MS),
|
||||
);
|
||||
now += 60 * 1_000;
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
assert.notEqual(
|
||||
localStorage.getItem("buzz-channel-messages.v1:visibility"),
|
||||
null,
|
||||
);
|
||||
|
||||
now += 5 * 60 * 1_000;
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
assert.equal(
|
||||
localStorage.getItem("buzz-channel-messages.v1:visibility"),
|
||||
null,
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
"buzz-channel-messages.v1:interval",
|
||||
snapshot(now - 14 * DAY_MS),
|
||||
);
|
||||
now += 60 * 60 * 1_000;
|
||||
callback();
|
||||
assert.equal(
|
||||
localStorage.getItem("buzz-channel-messages.v1:interval"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
Date.now = originalNow;
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
assert.equal(intervals.size, 0);
|
||||
assert.deepEqual(cancelledIdleCallbacks, [idleId]);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Best-effort time-based cleanup for disposable localStorage caches.
|
||||
*
|
||||
* Only explicitly whitelisted cache namespaces are eligible. Durable state
|
||||
* such as identities, communities, read positions, onboarding, and preferences
|
||||
* must never be added here.
|
||||
*/
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000;
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1_000;
|
||||
const SWEEP_DEBOUNCE_MS = 5 * 60 * 1_000;
|
||||
const INITIAL_SWEEP_FALLBACK_MS = 250;
|
||||
const INITIAL_SWEEP_IDLE_TIMEOUT_MS = 1_500;
|
||||
|
||||
type LocalStorageSweepRule = {
|
||||
keyPrefix: string;
|
||||
maxAgeMs: number;
|
||||
};
|
||||
|
||||
/** Disposable cache namespaces and their maximum idle age. */
|
||||
export const LOCAL_STORAGE_SWEEP_RULES: readonly LocalStorageSweepRule[] = [
|
||||
{ keyPrefix: "buzz-channel-messages.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
{ keyPrefix: "buzz-channels.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
{ keyPrefix: "buzz-observed-unread.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
{ keyPrefix: "buzz-sidebar-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
{ keyPrefix: "buzz-timeline-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
{ keyPrefix: "buzz-user-labels.v1:", maxAgeMs: 14 * DAY_MS },
|
||||
// Do not add buzz-self-profile.v1: here. It is the load-bearing offline
|
||||
// identity fallback when the relay is unreachable, not a repaintable cache.
|
||||
];
|
||||
|
||||
function updatedAtFromJson(value: string): number | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.updatedAt === "number" &&
|
||||
Number.isFinite(record.updatedAt)
|
||||
) {
|
||||
return record.updatedAt;
|
||||
}
|
||||
|
||||
// User-label cache buckets carry freshness per profile instead of at the
|
||||
// payload root. Use the newest valid label timestamp so the bucket is only
|
||||
// removed once every label in it is stale.
|
||||
if (typeof record.profiles !== "object" || record.profiles === null) {
|
||||
return null;
|
||||
}
|
||||
let newestUpdatedAt: number | null = null;
|
||||
for (const profile of Object.values(record.profiles)) {
|
||||
if (typeof profile !== "object" || profile === null) continue;
|
||||
const updatedAt = (profile as Record<string, unknown>).updatedAt;
|
||||
if (
|
||||
typeof updatedAt === "number" &&
|
||||
Number.isFinite(updatedAt) &&
|
||||
(newestUpdatedAt === null || updatedAt > newestUpdatedAt)
|
||||
) {
|
||||
newestUpdatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
return newestUpdatedAt;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes whitelisted cache entries older than their configured TTL.
|
||||
* Entries without a trustworthy `updatedAt` are left alone rather than guessed
|
||||
* stale. Storage and parse failures never escape into app startup.
|
||||
*/
|
||||
export function sweepStaleLocalStorage(now = Date.now()): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
const staleKeys: string[] = [];
|
||||
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key === null) continue;
|
||||
const rule = LOCAL_STORAGE_SWEEP_RULES.find(({ keyPrefix }) =>
|
||||
key.startsWith(keyPrefix),
|
||||
);
|
||||
if (!rule) continue;
|
||||
|
||||
const value = storage.getItem(key);
|
||||
if (value === null) continue;
|
||||
const updatedAt = updatedAtFromJson(value);
|
||||
if (updatedAt !== null && updatedAt <= now - rule.maxAgeMs) {
|
||||
staleKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect before mutating because localStorage indexes shift on removal.
|
||||
for (const key of staleKeys) {
|
||||
storage.removeItem(key);
|
||||
removed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[localStorageSweep] stale cache cleanup failed:", error);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defers the first sweep until the browser is idle (or a short timer fallback),
|
||||
* then sweeps hourly while the app remains open and when a hidden app becomes
|
||||
* visible. Visibility sweeps are debounced to avoid repeated work from rapid
|
||||
* focus changes. Returns a cleanup function for tests or future teardown.
|
||||
*/
|
||||
export function startLocalStorageSweep(): () => void {
|
||||
let lastSweepAt = Number.NEGATIVE_INFINITY;
|
||||
let listening = false;
|
||||
let intervalId: ReturnType<typeof window.setInterval> | null = null;
|
||||
let idleCallbackId: number | null = null;
|
||||
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
const runIfDue = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastSweepAt < SWEEP_DEBOUNCE_MS) return;
|
||||
lastSweepAt = now;
|
||||
sweepStaleLocalStorage(now);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") runIfDue();
|
||||
};
|
||||
|
||||
try {
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
listening = true;
|
||||
intervalId = window.setInterval(runIfDue, SWEEP_INTERVAL_MS);
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleCallbackId = window.requestIdleCallback(runIfDue, {
|
||||
timeout: INITIAL_SWEEP_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
} else {
|
||||
timeoutId = globalThis.setTimeout(runIfDue, INITIAL_SWEEP_FALLBACK_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[localStorageSweep] scheduler setup failed:", error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (listening) {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}
|
||||
if (intervalId !== null) window.clearInterval(intervalId);
|
||||
if (idleCallbackId !== null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
if (timeoutId !== null) globalThis.clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
console.warn("[localStorageSweep] scheduler cleanup failed:", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user