feat(read-state): semantic eviction + multi-slot splitting#1309
Draft
wpfleger96 wants to merge 6 commits into
Draft
feat(read-state): semantic eviction + multi-slot splitting#1309wpfleger96 wants to merge 6 commits into
wpfleger96 wants to merge 6 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two proactive improvements on top of #1305's byte-budget trim:
Semantic eviction (NIP-RS §Eviction):
thread:andmsg:entries whose timestamp is already covered by the channel frontier are semantically inert — the channel read marker already implies them. Drop them incurrentContexts()before the byte-budget check fires, keeping the blob small proactively. Only runs whenparentResolveris set; skips silently whennullso 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:30078events using the existingslotIdmechanism. Each slot gets its owndtag and is published independently. Capped atREAD_STATE_MAX_SLOTS = 8(~5,200 channels). Extra slot IDs are persisted inlocalStorage.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: addREAD_STATE_MAX_SLOTS = 8constant andlocalExtraSlotIdsKey()helperreadStateManager.ts:evictDominatedEntries()pure function (semantic eviction, testable in isolation)splitContextsIntoBudgetedSlots()pure function (slot splitting, testable in isolation)currentContexts(): run semantic eviction before byte-budget trim; null return means "needs split"splitContextsIntoSlots(): delegates tosplitContextsIntoBudgetedSlots; thread/msg entries added to primary slot and trimmed to budgetpublishOneSlot(): extracted frompublish()to avoid duplicationpublishSplitSlots(): no-op suppression (union compare tolastPublishedContexts); resets then publishes each slotdeleteExtraSlots(): NIP-09 kind:5a-tag delete events for extra slots on split→single transition; clearsextraSlotIdsfrom localStoragepublish(): resetslastPublishedContexts = {}before single-slot publish; callsdeleteExtraSlots()when transitioning from split to single modemergeEvents(): union all own-slot blobs (max-merge) instead of winner-takes-allfetchOwnBlobBeforePublish(): fetch all own slot d-tags in one queryinitialize(): null fromcurrentContexts()schedules publish (multi-slot path)readStateManager.test.mjs: 6 new tests forevictDominatedEntries, 5 new tests forsplitContextsIntoBudgetedSlotsStack
Stack: #1305 → this PR