Skip to content

Commit a0f4032

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
fix(read-state): O(n) eviction, fitsAfterTrim signal, suppress publish on overflow
Address two IMPORTANT findings from Thufir's review of #1305: 1. O(n²) re-encode loop — the eviction loop called encoder.encode() on every iteration, O(n) per step × n steps = O(n²). With 7,500 entries this caused a confirmed 4.7s UI freeze on first publish after upgrade. Replace with an O(n) pass: compute total bytes once, subtract each entry's per-entry delta (key.length + 4 + timestamp digits), collect entries to evict, delete them in a batch, then do one final encode as the authoritative check (handles JSON comma-accounting edge cases the delta estimate ignores). 2. Silent failure when channel-only blob exceeds budget — after exhausting all msg:/thread: entries the function returned without checking whether the remaining blob still exceeded the budget. The caller would then proceed to publish a blob the relay would reject. Fix: trimContextsToBudget() now returns { evicted, fitsAfterTrim }. currentContexts() returns null when fitsAfterTrim is false; publish() and initialize() both guard against null and skip the publish. Also address two MINOR findings: - JSDoc now documents that trimContextsToBudget mutates contexts in place - Empty-map test replaced with a test that actually exercises the fitsAfterTrim=false path (channel-only blob smaller than budget) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent dd32b21 commit a0f4032

2 files changed

Lines changed: 81 additions & 20 deletions

File tree

desktop/src/features/channels/readState/readStateManager.test.mjs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ const THREAD_ID = "b".repeat(64);
156156
test("trimContextsToBudget_underBudget_returnsZeroAndLeavesContextsUnchanged", () => {
157157
const contexts = { [`msg:${MSG_ID}`]: 100 };
158158
// A very large budget — nothing should be evicted.
159-
const evicted = trimContextsToBudget(contexts, CLIENT_ID, 1_000_000);
159+
const { evicted, fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, 1_000_000);
160160
assert.equal(evicted, 0);
161+
assert.equal(fitsAfterTrim, true);
161162
assert.ok(`msg:${MSG_ID}` in contexts);
162163
});
163164

@@ -176,8 +177,9 @@ test("trimContextsToBudget_overBudget_evictsMsgEntriesOldestFirst", () => {
176177
// Budget that requires evicting at least one entry.
177178
const budget = fullSize - 10;
178179

179-
const evicted = trimContextsToBudget(contexts, CLIENT_ID, budget);
180+
const { evicted, fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, budget);
180181
assert.ok(evicted >= 1, `expected at least 1 eviction, got ${evicted}`);
182+
assert.equal(fitsAfterTrim, true);
181183
// The oldest entry (ts=1) must be gone.
182184
assert.ok(!(`msg:${MSG_ID}` in contexts), "oldest msg entry should be evicted");
183185
// Result must fit within budget.
@@ -201,10 +203,11 @@ test("trimContextsToBudget_channelKeysNeverEvicted", () => {
201203
).length;
202204
const budget = Math.floor(fullSize / 2);
203205

204-
trimContextsToBudget(contexts, CLIENT_ID, budget);
206+
const { fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, budget);
205207

206208
// Channel key must survive regardless of how many msg entries were evicted.
207209
assert.ok("channel:some-channel-id" in contexts, "channel key must not be evicted");
210+
assert.equal(fitsAfterTrim, true);
208211
const resultSize = encoder.encode(
209212
JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts }),
210213
).length;
@@ -232,14 +235,37 @@ test("trimContextsToBudget_msgEvictedBeforeThread", () => {
232235
).length;
233236
const budget = oneEntrySize + 5; // fits one entry, not two
234237

235-
const evicted = trimContextsToBudget(contexts, CLIENT_ID, budget);
238+
const { evicted, fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, budget);
236239
assert.equal(evicted, 1);
240+
assert.equal(fitsAfterTrim, true);
237241
assert.ok(!(`msg:${MSG_ID}` in contexts), "msg entry should be evicted before thread");
238242
assert.ok(`thread:${THREAD_ID}` in contexts, "thread entry should survive");
239243
});
240244

241-
test("trimContextsToBudget_emptyContexts_returnsZero", () => {
245+
test("trimContextsToBudget_emptyContexts_returnsZeroAndFits", () => {
246+
// Empty contexts: blob is just the skeleton — fits any reasonable budget.
242247
const contexts = {};
243-
const evicted = trimContextsToBudget(contexts, CLIENT_ID, 100);
248+
const { evicted, fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, 1_000_000);
244249
assert.equal(evicted, 0);
250+
assert.equal(fitsAfterTrim, true);
251+
});
252+
253+
test("trimContextsToBudget_channelOnlyBlobExceedsBudget_fitsAfterTrimFalse", () => {
254+
// Channel keys cannot be evicted. If the channel-only skeleton exceeds the
255+
// budget, fitsAfterTrim must be false so the caller can suppress the publish.
256+
const contexts = {
257+
"channel:some-channel-id": 100,
258+
};
259+
const encoder = new TextEncoder();
260+
const skeletonSize = encoder.encode(
261+
JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts }),
262+
).length;
263+
// Budget smaller than the channel-only skeleton — cannot be satisfied.
264+
const budget = skeletonSize - 1;
265+
266+
const { evicted, fitsAfterTrim } = trimContextsToBudget(contexts, CLIENT_ID, budget);
267+
assert.equal(evicted, 0, "no evictable entries exist");
268+
assert.equal(fitsAfterTrim, false, "channel-only blob still exceeds budget");
269+
// Channel key must still be present.
270+
assert.ok("channel:some-channel-id" in contexts);
245271
});

desktop/src/features/channels/readState/readStateManager.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,25 +123,40 @@ export function applyRemoteContextTimestamp(args: {
123123
return result;
124124
}
125125

126+
/**
127+
* Result of a `trimContextsToBudget` call.
128+
*/
129+
export interface TrimResult {
130+
/** Number of entries removed from `contexts`. */
131+
evicted: number;
132+
/** True when the serialized blob fits within `maxBytes` after trimming. */
133+
fitsAfterTrim: boolean;
134+
}
135+
126136
/**
127137
* Trim a contexts map to fit within `maxBytes` when serialized as the JSON
128138
* blob `{v:1, client_id, contexts}`. Evicts oldest `msg:` entries first
129139
* (lowest timestamp), then oldest `thread:` entries. Channel keys are never
130-
* evicted. Returns the number of entries removed.
140+
* evicted. Mutates `contexts` in place.
141+
*
142+
* Returns `{ evicted, fitsAfterTrim }`. `fitsAfterTrim` is false when the
143+
* remaining blob (channel keys only) still exceeds `maxBytes` — the caller
144+
* must not publish in that case.
131145
*
132146
* Exported for unit testing; callers should prefer `currentContexts()`.
133147
*/
134148
export function trimContextsToBudget(
135149
contexts: Record<string, number>,
136150
clientId: string,
137151
maxBytes: number,
138-
): number {
152+
): TrimResult {
139153
const encoder = new TextEncoder();
140154
const blobFor = (c: Record<string, number>) =>
141155
JSON.stringify({ v: 1, client_id: clientId, contexts: c });
142156

143-
if (encoder.encode(blobFor(contexts)).length <= maxBytes) {
144-
return 0;
157+
let currentBytes = encoder.encode(blobFor(contexts)).length;
158+
if (currentBytes <= maxBytes) {
159+
return { evicted: 0, fitsAfterTrim: true };
145160
}
146161

147162
const msgEntries: [string, number][] = [];
@@ -157,13 +172,26 @@ export function trimContextsToBudget(
157172
msgEntries.sort((a, b) => a[1] - b[1]);
158173
threadEntries.sort((a, b) => a[1] - b[1]);
159174

160-
let evicted = 0;
161-
for (const [key] of [...msgEntries, ...threadEntries]) {
162-
if (encoder.encode(blobFor(contexts)).length <= maxBytes) break;
175+
// O(n) pass: subtract each entry's byte contribution from currentBytes and
176+
// collect entries to evict. The per-entry estimate is `,"key":timestamp`
177+
// (key.length + 3 bytes for `"`, `"`, `:` plus 1 comma) + timestamp digits.
178+
// This is an approximation — the final encode below is the authoritative check.
179+
const toEvict: string[] = [];
180+
for (const [key, ts] of [...msgEntries, ...threadEntries]) {
181+
if (currentBytes <= maxBytes) break;
182+
// Contribution: `,"key":timestamp` — comma + quoted key + colon + value
183+
currentBytes -= key.length + 3 + String(ts).length + 1;
184+
toEvict.push(key);
185+
}
186+
187+
for (const key of toEvict) {
163188
delete contexts[key];
164-
evicted++;
165189
}
166-
return evicted;
190+
191+
// Final authoritative check — handles JSON comma-accounting edge cases
192+
// (e.g. last-entry comma disappears) that the per-entry estimate ignores.
193+
const fitsAfterTrim = encoder.encode(blobFor(contexts)).length <= maxBytes;
194+
return { evicted: toEvict.length, fitsAfterTrim };
167195
}
168196

169197
export class ReadStateManager {
@@ -207,7 +235,8 @@ export class ReadStateManager {
207235
if (this.destroyed) return;
208236
await this.startLiveSubscription();
209237
if (this.destroyed) return;
210-
if (!this.isIdenticalToLastPublished(this.currentContexts())) {
238+
const initContexts = this.currentContexts();
239+
if (initContexts !== null && !this.isIdenticalToLastPublished(initContexts)) {
211240
this.schedulePublish();
212241
}
213242

@@ -543,8 +572,8 @@ export class ReadStateManager {
543572
// Build blob from contexts this client is allowed to publish.
544573
const contexts = this.currentContexts();
545574

546-
// Suppress no-op publishes
547-
if (this.isIdenticalToLastPublished(contexts)) return;
575+
// Suppress no-op publishes; also skip if the blob cannot fit within budget.
576+
if (contexts === null || this.isIdenticalToLastPublished(contexts)) return;
548577

549578
const blob: ReadStateBlob = {
550579
v: 1,
@@ -630,7 +659,7 @@ export class ReadStateManager {
630659
return true;
631660
}
632661

633-
private currentContexts(): Record<string, number> {
662+
private currentContexts(): Record<string, number> | null {
634663
const contexts: Record<string, number> = {};
635664
for (const [ctx, ts] of this.effectiveState) {
636665
if (!this.publishableContextIds.has(ctx)) {
@@ -643,7 +672,7 @@ export class ReadStateManager {
643672
// the wrong metric — the relay rejects on byte size, not entry count. Evict
644673
// oldest msg: entries first (lowest timestamp), then thread: entries, until
645674
// the JSON fits. Channel keys are never evicted.
646-
const evicted = trimContextsToBudget(
675+
const { evicted, fitsAfterTrim } = trimContextsToBudget(
647676
contexts,
648677
this.clientId,
649678
READ_STATE_MAX_PLAINTEXT_BYTES,
@@ -653,6 +682,12 @@ export class ReadStateManager {
653682
`[ReadStateManager] currentContexts trimmed ${evicted} entries to fit byte budget`,
654683
);
655684
}
685+
if (!fitsAfterTrim) {
686+
console.error(
687+
"[ReadStateManager] currentContexts: blob exceeds byte budget even after full eviction — skipping publish",
688+
);
689+
return null;
690+
}
656691

657692
return contexts;
658693
}

0 commit comments

Comments
 (0)