Skip to content

feat(read-state): semantic eviction + multi-slot splitting#1309

Draft
wpfleger96 wants to merge 6 commits into
mainfrom
duncan/readstate-semantic-eviction-multiSlot
Draft

feat(read-state): semantic eviction + multi-slot splitting#1309
wpfleger96 wants to merge 6 commits into
mainfrom
duncan/readstate-semantic-eviction-multiSlot

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What

Two proactive improvements on top of #1305's byte-budget trim:

Semantic eviction (NIP-RS §Eviction): thread: and msg: entries whose timestamp is already covered by the channel frontier are semantically inert — the channel read marker already implies them. 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.

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.

Why

#1305 stops relay rejections but is reactive — it evicts oldest entries after the blob is already too large. Semantic eviction keeps the blob small proactively by removing entries that are already covered by the channel frontier. Multi-slot splitting handles the case semantic eviction can't: when channel keys themselves overflow the budget.

Changes

  • readStateFormat.ts: add READ_STATE_MAX_SLOTS = 8 constant and localExtraSlotIdsKey() helper
  • readStateManager.ts:
    • Export evictDominatedEntries() pure function (semantic eviction, testable in isolation)
    • Export splitContextsIntoBudgetedSlots() pure function (slot splitting, testable in isolation)
    • currentContexts(): run semantic eviction before byte-budget trim; null return means "needs split"
    • splitContextsIntoSlots(): delegates to splitContextsIntoBudgetedSlots; thread/msg entries added to primary slot and trimmed to budget
    • publishOneSlot(): extracted from publish() to avoid duplication
    • publishSplitSlots(): no-op suppression (union compare to lastPublishedContexts); resets then publishes each slot
    • deleteExtraSlots(): NIP-09 kind:5 a-tag delete events for extra slots on split→single transition; clears extraSlotIds from localStorage
    • publish(): resets lastPublishedContexts = {} before single-slot publish; calls deleteExtraSlots() when transitioning from split to single mode
    • mergeEvents(): union all own-slot blobs (max-merge) instead of winner-takes-all
    • fetchOwnBlobBeforePublish(): fetch all own slot d-tags in one query
    • initialize(): null from currentContexts() schedules publish (multi-slot path)
  • readStateManager.test.mjs: 6 new tests for evictDominatedEntries, 5 new tests for splitContextsIntoBudgetedSlots

Stack

Stack: #1305 → this PR

Note: this branch is based on duncan/readstate-byte-budget (#1305). The diff against main includes #1305's commits. Once #1305 merges, this PR's base will be rebased onto main and the diff will show only this PR's two commits.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 4 commits June 26, 2026 13:01
The relay rejects read-state events whose NIP-44 ciphertext exceeds the
relay's content limit. The previous guard used an entry-count threshold
(8k/10k) which is the wrong metric: at 8k msg: entries the JSON blob is
already ~544KB of keys alone, well past the relay's limit.

Replace the entry-count eviction with a serialized byte-budget loop.
currentContexts() now calls trimContextsToBudget() which serializes the
full {v:1, client_id, contexts} blob, checks its byte length, and evicts
oldest msg: entries first (lowest timestamp), then oldest thread: entries,
until the JSON fits within READ_STATE_MAX_PLAINTEXT_BYTES (32KB). Channel
keys are never evicted. The 32KB plaintext budget produces ~45KB ciphertext
after NIP-44's ~1.4x overhead, well under the relay's 256KB content cap
and NIP-44's 65,535-byte plaintext hard limit.

A console.warn fires when trim occurs so the fix is visible in staging.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…h 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>
Auto-format via biome format --write; remove two unused fullSize variables
in readStateManager.test.mjs (inlined into budget computation).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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>
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits June 26, 2026 13:47
Four findings from pass 1 review:

CRITICAL 1 — publishSplitSlots retry storm: add no-op suppression by
computing the union of all slot contexts and comparing to
lastPublishedContexts before resetting and publishing. Without this,
every debounce cycle in split mode re-published all slots even when
nothing changed.

CRITICAL 2 — split→single transition stale keys: reset
lastPublishedContexts = {} before single-slot publish so stale keys from
a previous split don't cause isIdenticalToLastPublished to return false
forever. Add deleteExtraSlots() which publishes NIP-09 kind:5 delete
events (a-tag) for each extra slot d-tag and clears extraSlotIds from
localStorage. Called at the top of publish() when transitioning from
split to single mode.

IMPORTANT 1 — thread/msg entries silently dropped in split mode:
splitContextsIntoSlots now collects thread/msg entries separately and
adds them to the primary slot (slot 0) after channel key distribution.
trimContextsToBudget is applied to slot 0 to keep it within budget.

IMPORTANT 2 — no tests for multi-slot machinery: extract core split
logic into exported pure function splitContextsIntoBudgetedSlots (takes
channelEntries, threadMsgEntries, clientId, slotIdGenerator, etc.) so
it can be tested without class instantiation. Add 5 unit tests:
- fitsInOneSlot_returnsSingleSlot
- requiresGrowth_allocatesExtraSlot
- exceedsMaxSlots_returnsNull
- includesThreadMsgInPrimarySlot
- threadMsgTrimmedWhenPrimarySlotOverBudget

Also addresses Thufir MINOR on distribute loop elegance: replaced the
clear-and-redistribute-in-place pattern with a clean distribute(count)
closure that allocates fresh slot maps each call.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…guard

The reset was placed outside the extraSlotIds.length > 0 guard, causing
it to fire on every debounce cycle in steady-state single-slot mode. This
cleared lastPublishedContexts before isIdenticalToLastPublished, making
it always return false and reintroducing the original retry storm.

Move the reset inside the guard so it only fires on the split→single
transition. Also adds publishSplitSlots_noopSuppression_skipsWhenUnchanged
— a ReadStateManager integration test that stubs publishOneSlot to avoid
tauri and verifies the no-op suppression path in publishSplitSlots.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant