Skip to content

Commit b299c09

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
feat(read-state): semantic eviction + multi-slot splitting
The byte-budget trim in #1305 is a reactive backstop — it fires after the blob is already too large. Two proactive improvements: 1. Semantic eviction (NIP-RS §Eviction): thread: and msg: entries whose timestamp is <= the channel frontier are semantically inert. Drop them in currentContexts() before the byte-budget check fires, keeping the blob small proactively. Only runs when parentResolver is set; skips silently when null so we never evict what we can't verify is dominated. 2. Multi-slot splitting: when channel keys alone exceed 32 KB (degenerate case: thousands of channels), partition them across multiple kind:30078 events using the existing slotId mechanism. Each slot gets its own d-tag and is published independently. Capped at READ_STATE_MAX_SLOTS=8 (~5,200 channels). Extra slot IDs are persisted in localStorage. mergeEvents now unions all own-slot blobs (max-merge) instead of taking the single highest-created_at blob. fetchOwnBlobBeforePublish fetches all own slot d-tags in one query. publishOneSlot extracted to avoid duplication between single-slot and multi-slot paths. Adds evictDominatedEntries as an exported pure function for testability, with 6 new unit tests covering: dominated eviction, newer-than-channel survival, equal-ts eviction, unresolvable parent, missing parent frontier, and channel-key immunity. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 96c5a02 commit b299c09

3 files changed

Lines changed: 342 additions & 25 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export const MAX_CONTEXTS = 10_000;
1616
// expansion to ~45 KB ciphertext) while keeping the blob well under both caps.
1717
export const READ_STATE_MAX_PLAINTEXT_BYTES = 32_768;
1818

19+
// Maximum number of slots a client may publish. Each slot is a separate
20+
// kind:30078 event. Splitting across slots is the fallback when channel keys
21+
// alone exceed READ_STATE_MAX_PLAINTEXT_BYTES. 8 slots × ~650 channel keys per
22+
// slot = ~5,200 channels — well beyond any realistic user.
23+
export const READ_STATE_MAX_SLOTS = 8;
24+
1925
// Context-key prefix for a per-MESSAGE read marker (LP4 v3). One grow-only
2026
// marker per reply id; the badge predicate reads effective("msg:<id>") live so
2127
// reading an ancestor never covers a descendant (Issue 2 by construction).
@@ -104,6 +110,10 @@ export function isValidReadStateDTag(
104110
return slotId.length > 0 && slotId.length <= 64 && isAscii(slotId);
105111
}
106112

113+
export function localExtraSlotIdsKey(pubkey: string): string {
114+
return `buzz.nip-rs.extra-slot-ids:${pubkey}`;
115+
}
116+
107117
export function localIsoToUnixSeconds(value: unknown): number | null {
108118
if (typeof value !== "string" || value.length === 0) {
109119
return null;

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import test from "node:test";
33

44
import {
55
applyRemoteContextTimestamp,
6+
evictDominatedEntries,
67
resolveEffectiveTimestamp,
78
trimContextsToBudget,
89
} from "./readStateManager.ts";
@@ -300,3 +301,103 @@ test("trimContextsToBudget_channelOnlyBlobExceedsBudget_fitsAfterTrimFalse", ()
300301
// Channel key must still be present.
301302
assert.ok("channel:some-channel-id" in contexts);
302303
});
304+
305+
// ── evictDominatedEntries ─────────────────────────────────────────────────────
306+
307+
const CHANNEL_ID = "channel-abc";
308+
const THREAD_ID_A = "a".repeat(64);
309+
const MSG_ID_A = "b".repeat(64);
310+
311+
const threadResolver = (ctx) => {
312+
if (ctx.startsWith("thread:") || ctx.startsWith("msg:")) return CHANNEL_ID;
313+
return null;
314+
};
315+
316+
test("evictDominatedEntries_dominatedThreadAndMsg_evictedChannelSurvives", () => {
317+
// Channel read at 500; thread read at 300 (dominated); msg read at 200 (dominated).
318+
const effectiveState = new Map([
319+
[CHANNEL_ID, 500],
320+
[`thread:${THREAD_ID_A}`, 300],
321+
[`msg:${MSG_ID_A}`, 200],
322+
]);
323+
const contexts = {
324+
[CHANNEL_ID]: 500,
325+
[`thread:${THREAD_ID_A}`]: 300,
326+
[`msg:${MSG_ID_A}`]: 200,
327+
};
328+
329+
const count = evictDominatedEntries(contexts, effectiveState, threadResolver);
330+
331+
assert.equal(count, 2, "both dominated entries evicted");
332+
assert.ok(!(`thread:${THREAD_ID_A}` in contexts), "thread entry evicted");
333+
assert.ok(!(`msg:${MSG_ID_A}` in contexts), "msg entry evicted");
334+
assert.ok(CHANNEL_ID in contexts, "channel key survives");
335+
});
336+
337+
test("evictDominatedEntries_threadNewerThanChannel_notEvicted", () => {
338+
// Thread read at 600 > channel at 500 — thread is NOT dominated.
339+
const effectiveState = new Map([
340+
[CHANNEL_ID, 500],
341+
[`thread:${THREAD_ID_A}`, 600],
342+
]);
343+
const contexts = {
344+
[CHANNEL_ID]: 500,
345+
[`thread:${THREAD_ID_A}`]: 600,
346+
};
347+
348+
const count = evictDominatedEntries(contexts, effectiveState, threadResolver);
349+
350+
assert.equal(count, 0, "no entries evicted when thread is newer");
351+
assert.ok(`thread:${THREAD_ID_A}` in contexts, "thread entry survives");
352+
});
353+
354+
test("evictDominatedEntries_threadEqualToChannel_evicted", () => {
355+
// ts === parentTs is dominated (<=).
356+
const effectiveState = new Map([
357+
[CHANNEL_ID, 400],
358+
[`thread:${THREAD_ID_A}`, 400],
359+
]);
360+
const contexts = {
361+
[`thread:${THREAD_ID_A}`]: 400,
362+
};
363+
364+
const count = evictDominatedEntries(contexts, effectiveState, threadResolver);
365+
366+
assert.equal(count, 1);
367+
assert.ok(!(`thread:${THREAD_ID_A}` in contexts), "equal-ts entry evicted");
368+
});
369+
370+
test("evictDominatedEntries_unresolvedParent_notEvicted", () => {
371+
// Resolver returns null for this key — must not evict.
372+
const effectiveState = new Map([[`thread:${THREAD_ID_A}`, 300]]);
373+
const contexts = { [`thread:${THREAD_ID_A}`]: 300 };
374+
const nullResolver = () => null;
375+
376+
const count = evictDominatedEntries(contexts, effectiveState, nullResolver);
377+
378+
assert.equal(count, 0, "no eviction when parent unresolvable");
379+
assert.ok(`thread:${THREAD_ID_A}` in contexts);
380+
});
381+
382+
test("evictDominatedEntries_parentNotInEffectiveState_notEvicted", () => {
383+
// Parent resolves to CHANNEL_ID but CHANNEL_ID has no entry in effectiveState.
384+
const effectiveState = new Map([[`thread:${THREAD_ID_A}`, 300]]);
385+
const contexts = { [`thread:${THREAD_ID_A}`]: 300 };
386+
387+
const count = evictDominatedEntries(contexts, effectiveState, threadResolver);
388+
389+
assert.equal(count, 0, "no eviction when parent has no frontier");
390+
assert.ok(`thread:${THREAD_ID_A}` in contexts);
391+
});
392+
393+
test("evictDominatedEntries_channelKeysNeverTouched", () => {
394+
// Channel keys don't start with thread: or msg: — must never be evicted.
395+
const effectiveState = new Map([[CHANNEL_ID, 999]]);
396+
const contexts = { [CHANNEL_ID]: 999, "other-key": 100 };
397+
398+
const count = evictDominatedEntries(contexts, effectiveState, threadResolver);
399+
400+
assert.equal(count, 0);
401+
assert.ok(CHANNEL_ID in contexts, "channel key untouched");
402+
assert.ok("other-key" in contexts, "non-prefixed key untouched");
403+
});

0 commit comments

Comments
 (0)