mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): cut steady-state relay traffic from polls and read-state echo (#5879)
## Problem Desktop webview CPU stayed high after the presence-scope fix (#5830) and the shared useNow ticker (#5861). A per-kind byte tap hot-patched into `relayClientSession.ts` on a live desktop (~500 channels, large agent fleet; 850 s capture correlated with CPU sampling) showed the remaining steady-state relay traffic is mostly self-inflicted: | kind | what | share of inbound bytes | shape | |------|------|-----------------------|-------| | 30078 | read-state | **34%** | our own ~44 KB nip44 blob echoed back every ~10-30 s while reading | | 30030 | emoji union | **33%** | 2-min poll refetching every member's full set (~300 KB burst) | | 30175 | persona catalog | **13%** | same 2-min backstop pattern, ~150 KB per walk | CPU tracked the bursts directly: 3-5% in quiet 10 s buckets vs 44-54% in buckets containing a poll burst or read-state echo. (The kind-24200 observer-frame theory was tested and disproven by the same tap: 9.7% of bytes, steady trickle.) ## Outcome - **Read-state echo drop.** `ReadStateManager` remembers the ids of events it just published (FIFO set capped at 64) and drops their relay echoes before the nip44-decrypt + `JSON.parse` step. Ids are recorded *before* publishing so relay fan-out can't race the OK. The drop consumes the id, so a reconnect replay of the same event still parses normally. Events from other clients of the same pubkey are untouched. - **Poll backstops stretched 2 min → 20 min** for the emoji union and persona catalog queries. The live subscriptions (invalidate on any new 30030/30175) and the reconnect invalidations remain the freshness paths; the poll only exists to cover a silently dropped live event. Behavior on publish, focus, and reconnect is unchanged. - Mechanical: localStorage identity helpers moved to `readStateIdentity.ts` (no behavior change) to keep `readStateManager.ts` under the file-size ratchet. Expected effect on the measured profile: the poll stretch cuts the 30030/30175 bursts (46% of inbound bytes) by 10x; the echo drop removes the recurring ~44 KB nip44-decrypt + parse per publish cycle (the echo still arrives on the wire — nostr filters cannot exclude own-author events — so this is a CPU/IPC saving, not a bandwidth one). ## Acceptance - New tests: echo dropped **before** decrypt (mutation-checked: disabling the drop fails the test), replayed duplicate of the same id still parses, foreign-client events always parse, published-id set stays capped when publishes fail (never-echoed ids). - Full desktop suite **4794/4794**, `tsc --noEmit` clean, `pnpm check` (biome + ratchets) clean at head. ## Not addressed (follow-ups) - The 44 KB blob itself (one read-state event carries all ~500 channels; a delta or per-channel-shard format is a protocol change). - Duplicate delivery of the same events on concurrent `history-` subscriptions (relay/client dedupe). - Webview RSS of 12.5 GB observed on the same machine — retention hunt is separate work; shrinking the heap multiplies the value of this PR since the GC floor scales with live-heap size. Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -15,8 +15,12 @@ import {
|
||||
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
|
||||
import { KIND_PERSONA } from "@/shared/constants/kinds";
|
||||
|
||||
/** Keeps focused polling at the established 2-minute backstop cadence. */
|
||||
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000;
|
||||
/** Poll backstop cadence. The live subscription (invalidate on any new
|
||||
* 30175) and the reconnect invalidation are the freshness paths; this poll
|
||||
* exists only to cover a silently dropped live event, so it can be rare. At
|
||||
* the previous 2-minute cadence it re-walked the entire persona catalog
|
||||
* (~150 KB burst) often enough to be a top-three desktop traffic source. */
|
||||
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 20 * 60_000;
|
||||
/** Suppresses the focus refetch until persona catalog data is genuinely stale.
|
||||
* The live subscription (invalidateQueries) is the primary freshness path. */
|
||||
export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { localExtraSlotIdsKey } from "@/features/channels/readState/readStateFormat";
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
|
||||
/**
|
||||
* localStorage-persisted identity for the read-state manager: the stable
|
||||
* client id, this client's slot id, and any extra slot ids allocated when the
|
||||
* blob outgrows the single-slot budget (NIP-RS multi-slot mode).
|
||||
*/
|
||||
|
||||
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
|
||||
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
|
||||
|
||||
export function generateHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function getOrCreatePersisted(
|
||||
key: string,
|
||||
generator: () => string,
|
||||
): string {
|
||||
let value = localStorage.getItem(key);
|
||||
if (!value) {
|
||||
value = generator();
|
||||
setLocalStorageItemWithRecovery(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function clientIdKey(pubkey: string): string {
|
||||
return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
export function slotIdKey(pubkey: string): string {
|
||||
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
export function loadExtraSlotIds(pubkey: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(v): v is string => typeof v === "string" && v.length > 0,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExtraSlotIds(pubkey: string, ids: string[]): void {
|
||||
setLocalStorageItemWithRecovery(
|
||||
localExtraSlotIdsKey(pubkey),
|
||||
JSON.stringify(ids),
|
||||
);
|
||||
}
|
||||
@@ -841,3 +841,71 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => {
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
// ── ReadStateManager — self-echo drop ─────────────────────────────────────────
|
||||
|
||||
// The live subscription (authors=[us]) echoes back every event we publish.
|
||||
// handleIncomingEvent must drop those echoes by id BEFORE the decrypt/parse
|
||||
// step: with hundreds of channels a blob is tens of KB, so decrypting our own
|
||||
// echo on every read was a large recurring cost. Strategy mirrors the split-
|
||||
// mode test above: stub the private parseEvent seam to count decrypt attempts.
|
||||
test("handleIncomingEvent_dropsSelfEchoBeforeDecrypt", async () => {
|
||||
globalThis.window.localStorage = makeLocalStorage();
|
||||
|
||||
const pubkey = "c".repeat(64);
|
||||
const mgr = new ReadStateManager(pubkey, makeFakeRelay());
|
||||
|
||||
let parseCount = 0;
|
||||
mgr.parseEvent = async () => {
|
||||
parseCount++;
|
||||
return null;
|
||||
};
|
||||
|
||||
const makeEvent = (id) => ({
|
||||
id,
|
||||
pubkey,
|
||||
kind: 30078,
|
||||
created_at: 1_000,
|
||||
content: "ciphertext",
|
||||
tags: [
|
||||
["d", "read-state:slot"],
|
||||
["t", "read-state"],
|
||||
],
|
||||
});
|
||||
|
||||
// An event we published ourselves: echo must be dropped without a parse.
|
||||
const ownId = "e".repeat(64);
|
||||
mgr.rememberPublishedId(ownId);
|
||||
await mgr.handleIncomingEvent(makeEvent(ownId));
|
||||
assert.equal(parseCount, 0, "self-echo must not reach decrypt/parse");
|
||||
|
||||
// The drop consumes the remembered id — a replayed duplicate (e.g. from a
|
||||
// reconnect catch-up) goes through the normal parse path.
|
||||
await mgr.handleIncomingEvent(makeEvent(ownId));
|
||||
assert.equal(parseCount, 1, "second delivery of same id must parse");
|
||||
|
||||
// An event from another client of the same pubkey must always parse.
|
||||
await mgr.handleIncomingEvent(makeEvent("f".repeat(64)));
|
||||
assert.equal(parseCount, 2, "foreign-client event must parse");
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
// The remembered-id set must stay bounded even if publishes fail (a failed
|
||||
// publish leaves an id that is never echoed back, so nothing deletes it).
|
||||
test("rememberPublishedId_evictsOldestBeyondCap", () => {
|
||||
globalThis.window.localStorage = makeLocalStorage();
|
||||
|
||||
const mgr = new ReadStateManager("d".repeat(64), makeFakeRelay());
|
||||
|
||||
const total = 100; // beyond the 64-id cap
|
||||
for (let i = 0; i < total; i++) {
|
||||
mgr.rememberPublishedId(`id-${i}`);
|
||||
}
|
||||
const ids = mgr.recentlyPublishedIds;
|
||||
assert.equal(ids.size, 64, "set must be capped");
|
||||
assert.ok(!ids.has("id-0"), "oldest id must be evicted");
|
||||
assert.ok(ids.has(`id-${total - 1}`), "newest id must be retained");
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
@@ -10,10 +10,17 @@ import {
|
||||
READ_STATE_MAX_SLOTS,
|
||||
MSG_PREFIX,
|
||||
THREAD_PREFIX,
|
||||
localExtraSlotIdsKey,
|
||||
type ReadStateBlob,
|
||||
} from "@/features/channels/readState/readStateFormat";
|
||||
import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot";
|
||||
import {
|
||||
clientIdKey,
|
||||
generateHex,
|
||||
getOrCreatePersisted,
|
||||
loadExtraSlotIds,
|
||||
saveExtraSlotIds,
|
||||
slotIdKey,
|
||||
} from "@/features/channels/readState/readStateIdentity";
|
||||
import {
|
||||
readStoredReadState,
|
||||
writeStoredReadState,
|
||||
@@ -21,54 +28,12 @@ import {
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
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 PUBLISH_DEBOUNCE_MS = 5_000;
|
||||
const LOCAL_PERSIST_MAX_WAIT_MS = 1_000;
|
||||
|
||||
function generateHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function getOrCreatePersisted(key: string, generator: () => string): string {
|
||||
let value = localStorage.getItem(key);
|
||||
if (!value) {
|
||||
value = generator();
|
||||
setLocalStorageItemWithRecovery(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clientIdKey(pubkey: string): string {
|
||||
return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
function slotIdKey(pubkey: string): string {
|
||||
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
function loadExtraSlotIds(pubkey: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(v): v is string => typeof v === "string" && v.length > 0,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveExtraSlotIds(pubkey: string, ids: string[]): void {
|
||||
setLocalStorageItemWithRecovery(
|
||||
localExtraSlotIdsKey(pubkey),
|
||||
JSON.stringify(ids),
|
||||
);
|
||||
}
|
||||
/** How many of our own just-published event ids to remember so the live
|
||||
* subscription can drop their relay echoes before the nip44 decrypt. A
|
||||
* publish cycle emits at most a handful of slot events; 64 is generous. */
|
||||
const PUBLISHED_ID_MEMORY = 64;
|
||||
|
||||
export type ApplyRemoteContextResult = "unchanged" | "advanced";
|
||||
|
||||
@@ -322,6 +287,8 @@ export class ReadStateManager {
|
||||
private pendingSyncedAdvances = new Set<string>();
|
||||
private destroyed = false;
|
||||
private parentResolver: ContextParentResolver | null = null;
|
||||
/** Event ids we published ourselves; used to skip decrypting their echoes. */
|
||||
private recentlyPublishedIds = new Set<string>();
|
||||
|
||||
constructor(pubkey: string, relayClient: RelayClient) {
|
||||
this.pubkey = pubkey;
|
||||
@@ -496,7 +463,7 @@ export class ReadStateManager {
|
||||
>();
|
||||
|
||||
for (const event of events) {
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
const parsed = await this.parseEvent(event);
|
||||
if (this.destroyed) return;
|
||||
if (!parsed) continue;
|
||||
|
||||
@@ -533,7 +500,7 @@ export class ReadStateManager {
|
||||
// Conflict detection: check if another client_id is squatting on our
|
||||
// d-tag coordinate. If so, rotate our slotId to avoid clobbering.
|
||||
for (const event of events) {
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
const parsed = await this.parseEvent(event);
|
||||
if (this.destroyed) return;
|
||||
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
|
||||
if (parsed.blob.client_id !== this.clientId) {
|
||||
@@ -588,11 +555,24 @@ export class ReadStateManager {
|
||||
|
||||
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
|
||||
if (this.destroyed || event.pubkey !== this.pubkey) return;
|
||||
|
||||
// Echo drop: the live subscription (authors=[us]) receives every event we
|
||||
// publish right back from the relay. Decrypting and re-parsing our own
|
||||
// blob is pure waste — with hundreds of channels a single blob runs tens
|
||||
// of KB, so on an actively-reading client the echo was a large recurring
|
||||
// nip44-decrypt + JSON.parse for information we already hold.
|
||||
if (this.recentlyPublishedIds.delete(event.id)) {
|
||||
console.debug(
|
||||
`[ReadStateManager] dropped self-echo event=${event.id.substring(0, 8)}…`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
|
||||
);
|
||||
|
||||
const parsed = await parseReadStateEvent(event, this.pubkey);
|
||||
const parsed = await this.parseEvent(event);
|
||||
if (!parsed || this.destroyed) return;
|
||||
|
||||
this.maxFetchedCreatedAt = Math.max(
|
||||
@@ -635,6 +615,22 @@ export class ReadStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Seam over `parseReadStateEvent` so tests can count/stub decrypts
|
||||
* (see readStateManager.test.mjs echo-drop tests). */
|
||||
private parseEvent(event: RelayEvent) {
|
||||
return parseReadStateEvent(event, this.pubkey);
|
||||
}
|
||||
|
||||
/** Record an id we just published, capped so failed publishes can't grow
|
||||
* the set unboundedly. Set preserves insertion order, so eviction is FIFO. */
|
||||
private rememberPublishedId(id: string): void {
|
||||
this.recentlyPublishedIds.add(id);
|
||||
if (this.recentlyPublishedIds.size > PUBLISHED_ID_MEMORY) {
|
||||
const oldest = this.recentlyPublishedIds.values().next().value;
|
||||
if (oldest !== undefined) this.recentlyPublishedIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePublish(): void {
|
||||
if (this.destroyed) return;
|
||||
if (this.debounceTimer !== null) {
|
||||
@@ -713,6 +709,10 @@ export class ReadStateManager {
|
||||
tags,
|
||||
});
|
||||
|
||||
// Remember the id BEFORE publishing: the relay may fan the event out to
|
||||
// our own live subscription before the publish OK resolves. A failed
|
||||
// publish leaves a never-echoed id in the set; the size cap evicts it.
|
||||
this.rememberPublishedId(event.id);
|
||||
await this.relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out publishing read state.",
|
||||
|
||||
@@ -23,8 +23,12 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
* live event is missed. Mirrors `user-status/hooks.ts`.
|
||||
*/
|
||||
|
||||
/** Keeps focused polling at the established 2-minute backstop cadence. */
|
||||
export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 120_000;
|
||||
/** Poll backstop cadence. The live subscription (invalidate on any member's
|
||||
* new 30030) and the reconnect invalidation are the freshness paths; this
|
||||
* poll exists only to cover a silently dropped live event, so it can be
|
||||
* rare. At the previous 2-minute cadence it refetched every member's full
|
||||
* set (~300 KB burst) often enough to dominate desktop relay traffic. */
|
||||
export const CUSTOM_EMOJI_REFETCH_INTERVAL_MS = 20 * 60_000;
|
||||
/** Suppresses the focus refetch until emoji data is genuinely stale.
|
||||
* The live subscription (invalidateQueries) is the primary freshness path. */
|
||||
export const CUSTOM_EMOJI_FOCUS_STALE_TIME_MS = 5 * 60_000;
|
||||
|
||||
Reference in New Issue
Block a user