Skip to content

fix(relay): return what fits at two capacity cliffs (profiles, heavily reacted messages) - #2114

Closed
matt2e wants to merge 8 commits into
mainfrom
paging-12
Closed

matt2e wants to merge 8 commits into
mainfrom
paging-12

Conversation

@matt2e

@matt2e matt2e commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Two relay-client limits failed outright once a community passed them. Each now returns what fits.

Cliff Before (main) After (this branch) How real
(a) profiles.ensure asked for more than 1,024 ids Threw "Profile request exceeds the directory budget" before reading anything. Community admin raised an unhandled rejection and its name search found nobody. from: search in a channel failed with the same message. Names the first 1,024 ids in three reads (500/500/24). Members past the cap keep their public-key label. Callers catch a failed batch. from:name checks the relay for namesakes past the cap instead of silently picking the loaded one. Happens today. #general has about 2,500 members (#1828), and auto-join channels grow with the community.
(b) Opening a message by exact id with 500+ reactions (Inbox, search, reminder, deep link, message-link preview, media viewer) "Selected message exceeded its evidence limit" Opens with the newest 500 reactions. Edits, deletions and moderation overlays still fail closed at 500. Plausible as usage grows. The relay keeps one reaction per user, emoji and message, so 500 is about 20% of #general reacting once.

Each failure was recorded in the running app on main, and each works on this branch (videos below). Benchmarks show no measurable cost on paths that already worked.

Size: non-test source +68/−15 across 5 files. Docs +9/−1, tests +513/−20.

Rebase onto main (2026-11-04)

Main moved under this PR, and two of its changes removed the ground this branch stood on. The rebase dropped the affected parts rather than carrying them:

What changed

(a) Large rosters: profiles.ensure

Diagram. Before: profiles.ensure throws for 2,500 ids before sending any read. After: the request keeps the first 1,024 ids, reads them in batches of 500, 500 and 24, waits for every batch before reporting a failure, and callers catch it.

  • profile-directory.ts: a request above 1,024 ids (the cache's entry capacity) keeps the first 1,024 and leaves the rest unrequested instead of throwing. Fetching more would only evict names the same request had just loaded. No notice is shown for the rest, because Try again cannot name them. The verified-profile lookup gets the same sliced list, so an over-budget request never floods it (fix(relay): reject over-budget ids before verified lookup and evict in one pass #2220).
  • ensure reports a failed batch only after its sibling batches settle, so a caller that tolerates the failure sees every name that loaded.
  • CommunityAdmin.tsx catches the optional name read.
  • useSearchMessages.ts lets from: proceed on a partial directory, as the author picker in SearchResults already does. When the member names are partial (a member past the cap or in a failed batch is neither loaded nor known unnamed), a loaded match no longer settles it: the relay name search runs too, its results count only if they are channel members, and two matches open the existing author picker. A healthy roster takes the old path unchanged.

(b) Exact open of heavily reacted messages

Diagram. Before: one overlay filter covers kinds 5, 7, 9005, 40003 and 39005 with limit 500, 500 reactions fill it, and the open fails with the evidence-limit error. After: the same request carries a safety filter for kinds 5, 9005, 40003 and 39005 that still fails closed at 500, plus a separate kind 7 reaction filter whose 500 is a lower bound; the tombstone read goes in parts of 900 ids and the message opens.

  • threads.ts: reactions move to their own [7] filter in the same overlay request. The relay applies limit per filter, so the request count is unchanged. Reactions cannot hide or change the target, so the exact-target guard in session.ts counts only non-reaction events. Safety kinds (5, 9005, 40003, 39005) and tombstone responses still fail closed at 500.
  • The tombstone read sends overlay ids in parts of 900. A single #e list above about 975 ids exceeds the reader's 64 KiB filter budget, and a target with a full reaction page plus a few hundred edits reaches that. Up to 900 ids it is one request, as before.
  • Reactions older than the newest 500 are not read. Live reactions keep arriving.
  • tests/browser/policy-relay.mjs recognises the two-filter overlay batch so browser journeys that open a thread keep passing.

Docs: docs/channels.md covers the roster cap and the 8 MiB batch limit. docs/relay-queries.md covers the separate reaction filter.

Commits (messages cite pre-rebase SHAs)
Commit What it does
9e14467 (b) Reactions get their own filter; the 500 guard counts only safety kinds
85835d3 (a) The 1,024 cap replaces the throw; batches settle before a failure is reported; callers catch
0f6c7bc Review 1: drops reactionsCapped
7562d65 Review 2: drops halving, sends tombstone ids in parts of 900
1377891 Review 3: removes a slow duplicate test, simplifies a catch
040a39b Policy relay stub models the split overlay read
236e628 Review 4: from:name checks namesakes past the cap
4fc0d5c from:name looks for namesakes among unnamed members only

Evidence: before (main) vs after (branch)

Recorded in headless Chromium against a local buzz-relay (block/buzz 680b0a6b2) with seeded fake identities, before the rebase. The viewer is "Alex Chen". No production relay or credentials were used. Before is a3a6223; after is 98253fc4b. Each take starts from a fresh copy of the seeded database.

(a) Members dialog, 1,100 members

Recorded before #2028. It shows the directory cliff through the dialog, the most visible caller at the time; on current main the dialog pages its reads and the remaining callers are Community admin and from: search.

a-members-dialog-before-after.mp4

Top of the Manage members list in #general. Both arms show Alex Chen (you) first, but main then names only the few people the timeline had already loaded, while the branch names everyone in sort order.

(b) Message with 500 reactions, opened from Inbox

Main fails with the evidence-limit error. The branch opens the message with its reactions.

b-500-reactions-before-after.mp4

After clicking Bailey Okafor's Inbox mention of Alex. Main's thread panel says Original message unavailable and Error: Selected message exceeded its evidence limit. The branch shows the message with 👍 167, 🚀 166 and 🎉 167 reactions and no history-limit notice.

Relay request log for the overlay read:

  • Main: one filter, kinds [39005,40003,5,7,9005], limit 500. It returned 500 events, hit the cap and failed.
  • Branch: two filters, [39005,40003,5,9005] and [7]. They returned 500 events, all reactions, and the message opened.

Review history

Round Finding Outcome
Review 1 A full reaction page set limited, so popular messages said "Thread history limit reached" while older replies still loaded reactionsCapped dropped
Review 2 Halving a batch over the 8 MiB read budget was the most intricate code for an unmeasured case, and the 2 MiB cache would discard most of what it read Dropped; listed under known limits
Review 2 Tombstone #e lists above about 975 ids exceed the 64 KiB filter budget Fixed: read in parts of 900 ids
Review 3 Slow duplicate BuzzLinkPreview test; redundant rethrow in useSearchMessages Removed
Review 4 (kalvinnchau) from:name with more than 1,024 members resolved to the loaded namesake and silently read the wrong author's messages Real; fixed. Partial names send the search to the relay too, restricted to members
Reviews 1–2, performance pass Discovery eviction findings (open threads and mounted links losing their previews, a quadratic recheck scan) Fixed at the time; the whole mechanism was dropped in the rebase (see above)

Performance

Measured in Node against the unit-test fakes with the production event verifier, before the rebase. Client times only.

Path main → branch Verdict
Exact open, fewer than 500 reactions Same 3–4 requests, +98 filter bytes. 10 reactions: 13.0 → 13.5 ms with verification No measurable cost
Exact open, 500 reactions Error after 2 requests → opens in 4. Client verification 518 → 593 ms Used to fail
Exact open, 500 reactions + 480 edits Error → opens in 5 (two tombstone parts). 512 → 1,062 ms, to verify about 1,000 events Used to fail
profiles.ensure, 1,024 ids 3 → 3 requests. 1,069 → 1,054 ms cold with verification No measurable cost
profiles.ensure, 1,100 or 2,500 ids Throws → 3 requests, 1,024 names, about 1.07 s Used to fail
A profile batch fails The rejection waits for sibling batches. Names present when it lands: 0 → 524 Acceptable
from:name, roster past the cap or a failed batch One extra searchProfiles (limit 40) per debounced query, the cost the empty-match path already paid Acceptable

Known limits

  • No names past 1,024 ids. In Community admin about 1,476 of #general's members keep public-key labels, with no notice. On main every member did. The real fix is to read names only for rendered rows, as the Members dialog now does (fix(channels): bound large member list rendering and profile reads #2028).
  • A batch over the 8 MiB read budget loads no names. Its sibling batches now do. Reaching it takes at least 31 maximum-size legacy inline avatars in one 500-author batch.
  • Profile eviction is by insertion order. ByteLru never touches an entry on use. With a warm cache, a large request can evict the viewer's or on-screen names until those rows read again. Main already did this for requests of 600 to 1,024 ids.
  • Only the newest 500 reactions are read on an exact open. An older reaction of the viewer's may not show as theirs in that view.
  • from:name in a large channel: a name with 40 or more relay matches always opens the picker, and the relay does not fold accents, so an accented namesake past the cap is still missed.
  • Untested against a real relay: 900-id #e lists. A rejection fails closed, as before.

Testing

  • After the rebase (local, 2026-11-04): tsc, Biome on the touched files, and Vitest on every test file this branch touches plus verified-profile, store-discovery and channel-references: 159 files, 6,013 passed, 3 skipped, 1 failed. The failure (ChannelMembersDialog.test.tsx "expands local and relay groups…", 6.5 s) is in a file this branch no longer changes and passes alone (96/96); it timed out under the parallel load.
  • Before the rebase: the broadest run, at a9c4ce4, covered src/features/relay and the suites of every changed consumer: 141 files, 2,725 passed, 4 skipped.
  • Pushed with --no-verify. The pre-push related-tests hook times out under load on this machine.
  • New coverage is all Vitest. No browser journeys were added or removed; the policy-relay stub change keeps the existing thread journeys passing on the split overlay read.
    • thread-target.test.ts: a target with a full reaction page and 480 edits opens, with tombstone parts of 900 and 80 ids.
    • profile-details.test.ts: names the ids that fit; a failed batch is reported after the others land. verified-profile.test.ts: only the first 1,024 ids reach verification.
    • CommunityAdmin.test.tsx, useSearchMessages.test.tsx (large-roster from:, namesakes on both sides of the cap, a namesake outside the channel, a fully read roster with no relay search).
  • Hosts: the browser host was exercised before the rebase (Chromium, Vite dev server and Node dev broker, local relay). Not exercised: the native desktop host, WebKit, the rebased head in the running app, and exact opens from search, deep links and message links (only Inbox was used).
  • Deferred: a human pass of the two flows on the desktop host. The review-completed marker is not in this description; the human adds it after that pass.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review November 2, 2026 03:36
@matt2e
matt2e requested a review from a team as a code owner November 2, 2026 03:36

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Four actionable findings: the browser relay model rejects the new overlay query, restrictive metadata eviction permits stale public replay, partial profile loading can hide author ambiguity, and the large-roster test is flaky. Fix these before merging.

Comment thread src/features/relay/threads.ts Resolved
Comment thread src/features/relay/discovery.ts Resolved
Comment thread src/app/shell/useSearchMessages.ts Resolved
matt2e added a commit that referenced this pull request Nov 2, 2026
Fixes the two CI failures on PR #2114 (run 41377657528).

Browser journeys: 2d4007a split the exact-target overlay read into
a safety-kind filter and a separate `[7]` reaction filter. The policy
relay stub answers only the multi-filter batches it recognises, so the
new batch fell through to the sidebar-preferences branch and failed
`toHaveLength(5)` in every journey that opens a thread (118 tests). The
stub now recognises the batch: two filters on the same single `#e`
target, each with `limit: 500`, the first carrying exactly kinds 5,
9005, 39005 and 40003 and the second only kind 7. The fixture's
`answer()` serves each filter as before.

Vitest flake check: "names the viewer and the members that fit a
roster larger than the profile budget" took 5.2-5.45 s in 7 of 10 flake
runs. About 2 s of it is opening the dialog and rendering 1,100 member
rows, a fixed cost of the scale contract (the roster must pass the
1,024-id cap), not a race. It now has an explicit 15 s timeout, as
App.profile.test.tsx and MessageMarkdown.test.tsx do for their own
large renders.

Tests (local):
- Chromium and WebKit: right-pane-dismissal, message-navigation, inbox,
  navigation-thread-history, nested-replies, buzz-links, typing,
  message-actions, message-management, inbox-tabs, inbox-edit and
  presence specs, 207 of 208 passed in 8.4 min. The one failure is
  WebKit message-management.spec.mjs:361 (Delete dialog Close not
  focused), which also fails locally on main.
- channel-opening.spec.mjs on chromium-measurements and
  webkit-measurements: passed.
- The 1,100-member Vitest test passes alone.

Not run: the full browser suite and the flake check's 10x repetition.
Hosted CI will cover both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 2, 2026
Fixes the two P2 findings in kalvinnchau's review of PR #2114
(2026-11-02 03:58 UTC, comments 4438794439 and 4438794444).

Review: discovery.ts preview eviction (b764b1b)
- Real. `preview()` let eviction drop any nonmember metadata, including
  a private or hidden 39000. For a channel the viewer never joined, that
  version can be the only evidence of the restriction: there is no
  roster and no denied entry. Once it was evicted after a complete
  roster read, an older signed public 39000 passed `newer()` against
  missing metadata and `canAccess` turned true again. The client listed
  a now-private channel as a read-only public preview under its old
  name. The relay still enforces reads, so no private content showed.
  This broke relay-queries.md's "An older metadata replay cannot undo a
  newer private event".
- Fix: `preview()` also requires `open(id)`, so only explicitly public
  nonmember metadata can give way. `headroom()` calls `preview()` and
  no longer counts restrictive entries as free.
- Cost at capacity: restrictive previews are kept for the session. The
  Buzz relay hides private metadata from non-members, so in practice
  there are none. Against a relay that serves them, about 1,024
  distinct restrictive previews would bring back main's refusal, which
  is never worse than main.
- Docs: relay-queries.md says only a preview's public metadata gives
  way, and why private or hidden metadata is kept. channels.md lists
  private or hidden previews among what fills the metadata cap.

Review: useSearchMessages.ts from:name namesakes (1608c3e48)
- Real, and a regression of this branch. `scoped` matched only loaded
  names. When it found one member, `resolve` treated that member as
  unique, and the relay profile search ran only when `scoped` was
  empty. With more than 1,024 members (names load for the lowest 1,024
  keys) or a failed batch (now caught), a namesake among the unloaded
  members was missed, and the search silently read the loaded member's
  messages. On main both cases threw, so the user saw an error, not a
  wrong answer.
- Fix: the member names are partial when `ensure` rejects or a member
  is neither in the snapshot nor in `unnamed()`. `unnamed()` records
  members the relay answered without a kind 0, so profile-less bots and
  viewers do not count. This test also catches main's existing case,
  where the 2 MiB profile cache evicts early names during the same
  `ensure`. When partial, a loaded match no longer settles it: the
  existing relay name search runs too. Beside a loaded member match,
  its results count only if they are channel members, and they are
  merged with `scoped` before `resolve`. Two matches open the existing
  author picker, which already merges loaded members with the relay
  search. A full page of 40 still counts as ambiguous. A healthy roster
  takes the old path unchanged.
- Residuals: in a large channel, a name with 40 or more relay matches
  always opens the picker. The relay does not fold accents, so an
  accented namesake past the cap is still missed, as on the existing
  relay branch.

Performance:
- Discovery: no change in reads. `preview()` adds one tag scan of the
  entry's metadata per check, in `headroom()` and eviction scans.
- from:name with 1,024 or fewer members whose names all load: no added
  request. One `some` over the roster with two Set lookups per member.
- Otherwise: one `searchProfiles` (limit 40) per debounced query, the
  same cost the empty-`scoped` path already paid. Membership is checked
  with `includes` for at most 40 candidates.

Tests:
- discovery-capacity.test.ts, private and hidden (12-14 ms each): with
  capacity 3 and a complete roster read, X goes public v10 to
  restrictive v20. `headroom()` excludes X, a new preview evicts the
  oldest public preview instead of X, a v10 replay leaves X
  inaccessible, and a newer public v30 regrants reading. Both fail on
  the previous code at `headroom()` (3, expected 2). Without that
  assertion they fail at eviction, because X is the entry that goes.
- useSearchMessages.test.tsx, 1,100 members with one Alice key starting
  with `1` (named) and another starting with `f` (past the cap):
  - Namesakes on both sides: the far one is never requested, one
    profile search, `ambiguousAuthor`, no message read. Fails on the
    previous code, which resolves to the loaded Alice.
  - A named member and a namesake outside the channel: one search,
    resolves to the member. Fails on the previous code (no search), and
    fails if the member restriction is dropped (ambiguous).
  - Only the member past the cap is named: resolves to her after one
    search. This guard passes before and after, as it should: the
    fix must keep this path.
  - Small fully read roster with a profile-less member: no profile
    search. A control that passes before and after. It fails if
    partial detection ignores `unnamed()`.
  - "resolves from:name on a roster larger than the profile budget when
    some names fail": its mock now answers the `search` filter, because
    the failed batches send it to the relay search.
- Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10
  tests, about 0.27 s per new test.

Also run: tsc, lint, and the discovery-capacity, store-discovery,
useSearchMessages, SearchResults and channel-references suites (5
files, 195 passed). Not exercised in the running app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e requested a review from kalvinnchau November 2, 2026 06:05

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Both previously reported P2s are repaired. One non-blocking common-name search regression remains.

Comment thread src/app/shell/useSearchMessages.ts Resolved
@matt2e
matt2e marked this pull request as draft November 2, 2026 08:03
matt2e added a commit that referenced this pull request Nov 2, 2026
Fixes the P3 finding in kalvinnchau's review of PR #2114
(2026-11-02 06:26 UTC, comment 4439478824).

Review: useSearchMessages.ts full-page check (c6bc521)
- Real, reproduced. When member names were partial, a loaded match
  sent the search to the relay-wide name search, which returns at most
  40 profiles from the whole relay. Matches were then limited to
  channel members, but the full-page check still counted all 40. With
  1,100 members, one loaded Alice and 39 nonmember Alices, the search
  returned `ambiguousAuthor` and read no messages. Any common name
  opened the picker in a channel past the profile budget, even with one
  matching member.
- Fix: beside a loaded match, the name search covers only the members
  whose names did not load, as a kind 0 NIP-50 search with `authors`
  set to those keys. The block/buzz relay passes `authors` into its
  full-text query, so other users' profiles never fill the page. The
  keys go in parts of 900, which fit the reader's 64 KiB filter budget
  (about 975 keys), as the tombstone read in threads.ts does. A member
  who matches the name makes the author ambiguous. A part that returns
  a full page of 40 still counts as ambiguous, because another match
  could be on the next page.
- Not chosen: reading every unnamed member's kind 0. That brings back
  the >1,024-id cliff and the 2 MiB cache churn for very large
  channels. The search returns at most 40 profiles per part.
- With no loaded match, the relay-wide search is main's again: matches
  are not limited to members, and `scoped` is not merged in. That undoes
  c6bc521's member filter there. `ensure`'s failure is caught as in
  CommunityAdmin, since the unnamed-member set already includes failed
  batches.
- Residuals: the full-text search matches whole profile JSON, so 40
  unnamed members whose `about` or `nip05` contains the name still
  open the picker. The relay does not fold accents, as on the
  relay-wide path. A typed name over about 5 KB would push a 900-key
  part past the filter budget, and the search would fail with an error.

Request cost per debounced from:name query in a channel:
- All member names loaded, or no loaded match: unchanged (no added read,
  or one relay-wide `searchProfiles` with limit 40, as on main).
- Loaded match with U unnamed members: ceil(U / 900) kind 0 search
  reads with limit 40, sent in parallel, instead of one relay-wide
  search. For 1,100 members (76 unnamed) that is one read. A
  10,000-member channel takes 10 reads. Each response holds at most 40
  profiles.

Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s,
10 -> 13 tests, local):
- The crew mock now limits a search's results to its `authors`.
- New row with the reviewer's probe: 1,100 members, one loaded Alice,
  39 nonmember Alices (a full relay-wide page). It resolves to the
  member after one search over unnamed members only (0.33 s). On
  c6bc521 it returns `ambiguousAuthor` without reading messages.
- Each row now asserts the search's scope: unnamed members, never the
  loaded match, beside a loaded match, and relay-wide otherwise. The
  namesakes-on-both-sides and outside-namesake rows fail on c6bc521
  on this assertion.
- "treats a full page of member name matches past the budget as
  ambiguous": 40 members past the budget named "Alice Smith" fill the
  page, so the author is ambiguous and no message is read (0.42 s).
  c6bc521 was also ambiguous here, but through the relay-wide search,
  so it fails only on the search-scope assertion.
- "searches the members whose names did not load in parts that fit
  the filter budget": 1,925 roster keys leave 901 unnamed, searched in
  parts of 900 and 1. The namesake in either part makes it ambiguous
  (0.27 s). Fails on c6bc521, which sent one relay-wide search.

Also run: tsc, lint, and the useSearchMessages and SearchResults suites
(2 files, 108 passed). Not exercised in the running app or against a
live relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
Fixes the browser-journey CI failure on PR #2114 (run 41377657528).

2d4007a split the exact-target overlay read into a safety-kind filter
and a separate `[7]` reaction filter. The policy relay stub answers
only the multi-filter batches it recognises, so the new batch fell
through to the sidebar-preferences branch and failed `toHaveLength(5)`
in every journey that opens a thread (118 tests). The stub now
recognises the batch: two filters on the same single `#e` target, each
with `limit: 500`, the first carrying exactly kinds 5, 9005, 39005 and
40003 and the second only kind 7. The fixture's `answer()` serves each
filter as before.

The 1,100-member Members dialog test this commit also timed was dropped
in the rebase onto main (#2028 pages the dialog's name reads).

Tests (local):
- Chromium and WebKit: right-pane-dismissal, message-navigation, inbox,
  navigation-thread-history, nested-replies, buzz-links, typing,
  message-actions, message-management, inbox-tabs, inbox-edit and
  presence specs, 207 of 208 passed in 8.4 min. The one failure is
  WebKit message-management.spec.mjs:361 (Delete dialog Close not
  focused), which also fails locally on main.
- channel-opening.spec.mjs on chromium-measurements and
  webkit-measurements: passed.

Not run: the full browser suite. Hosted CI will cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
Fixes the second P2 finding in kalvinnchau's review of PR #2114
(2026-11-02 03:58 UTC, comment 4438794444). The first (discovery
preview eviction keeping restrictive metadata) was dropped in the
rebase onto main: #1574 replaced the 1,024-entry discovery cap with a
256 MiB byte budget, so there is no preview eviction to fix.

Review: useSearchMessages.ts from:name namesakes (1608c3e48)
- Real, and a regression of this branch. `scoped` matched only loaded
  names. When it found one member, `resolve` treated that member as
  unique, and the relay profile search ran only when `scoped` was
  empty. With more than 1,024 members (names load for the lowest 1,024
  keys) or a failed batch (now caught), a namesake among the unloaded
  members was missed, and the search silently read the loaded member's
  messages. On main both cases threw, so the user saw an error, not a
  wrong answer.
- Fix: the member names are partial when `ensure` rejects or a member
  is neither in the snapshot nor in `unnamed()`. `unnamed()` records
  members the relay answered without a kind 0, so profile-less bots and
  viewers do not count. This test also catches main's existing case,
  where the 2 MiB profile cache evicts early names during the same
  `ensure`. When partial, a loaded match no longer settles it: the
  existing relay name search runs too. Beside a loaded member match,
  its results count only if they are channel members, and they are
  merged with `scoped` before `resolve`. Two matches open the existing
  author picker, which already merges loaded members with the relay
  search. A full page of 40 still counts as ambiguous. A healthy roster
  takes the old path unchanged.
- Residuals: in a large channel, a name with 40 or more relay matches
  always opens the picker. The relay does not fold accents, so an
  accented namesake past the cap is still missed, as on the existing
  relay branch.

Performance:
- from:name with 1,024 or fewer members whose names all load: no added
  request. One `some` over the roster with two Set lookups per member.
- Otherwise: one `searchProfiles` (limit 40) per debounced query, the
  same cost the empty-`scoped` path already paid. Membership is checked
  with `includes` for at most 40 candidates.

Tests:
- useSearchMessages.test.tsx, 1,100 members with one Alice key starting
  with `1` (named) and another starting with `f` (past the cap):
  - Namesakes on both sides: the far one is never requested, one
    profile search, `ambiguousAuthor`, no message read. Fails on the
    previous code, which resolves to the loaded Alice.
  - A named member and a namesake outside the channel: one search,
    resolves to the member. Fails on the previous code (no search), and
    fails if the member restriction is dropped (ambiguous).
  - Only the member past the cap is named: resolves to her after one
    search. This guard passes before and after, as it should: the
    fix must keep this path.
  - Small fully read roster with a profile-less member: no profile
    search. A control that passes before and after. It fails if
    partial detection ignores `unnamed()`.
  - "resolves from:name on a roster larger than the profile budget when
    some names fail": its mock now answers the `search` filter, because
    the failed batches send it to the relay search.
- Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10
  tests, about 0.27 s per new test.

Also run: tsc, lint, and the useSearchMessages suite (10 passed). Not
exercised in the running app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
Fixes the P3 finding in kalvinnchau's review of PR #2114
(2026-11-02 06:26 UTC, comment 4439478824).

Review: useSearchMessages.ts full-page check (c6bc521)
- Real, reproduced. When member names were partial, a loaded match
  sent the search to the relay-wide name search, which returns at most
  40 profiles from the whole relay. Matches were then limited to
  channel members, but the full-page check still counted all 40. With
  1,100 members, one loaded Alice and 39 nonmember Alices, the search
  returned `ambiguousAuthor` and read no messages. Any common name
  opened the picker in a channel past the profile budget, even with one
  matching member.
- Fix: beside a loaded match, the name search covers only the members
  whose names did not load, as a kind 0 NIP-50 search with `authors`
  set to those keys. The block/buzz relay passes `authors` into its
  full-text query, so other users' profiles never fill the page. The
  keys go in parts of 900, which fit the reader's 64 KiB filter budget
  (about 975 keys), as the tombstone read in threads.ts does. A member
  who matches the name makes the author ambiguous. A part that returns
  a full page of 40 still counts as ambiguous, because another match
  could be on the next page.
- Not chosen: reading every unnamed member's kind 0. That brings back
  the >1,024-id cliff and the 2 MiB cache churn for very large
  channels. The search returns at most 40 profiles per part.
- With no loaded match, the relay-wide search is main's again: matches
  are not limited to members, and `scoped` is not merged in. That undoes
  c6bc521's member filter there. `ensure`'s failure is caught as in
  CommunityAdmin, since the unnamed-member set already includes failed
  batches.
- Residuals: the full-text search matches whole profile JSON, so 40
  unnamed members whose `about` or `nip05` contains the name still
  open the picker. The relay does not fold accents, as on the
  relay-wide path. A typed name over about 5 KB would push a 900-key
  part past the filter budget, and the search would fail with an error.

Request cost per debounced from:name query in a channel:
- All member names loaded, or no loaded match: unchanged (no added read,
  or one relay-wide `searchProfiles` with limit 40, as on main).
- Loaded match with U unnamed members: ceil(U / 900) kind 0 search
  reads with limit 40, sent in parallel, instead of one relay-wide
  search. For 1,100 members (76 unnamed) that is one read. A
  10,000-member channel takes 10 reads. Each response holds at most 40
  profiles.

Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s,
10 -> 13 tests, local):
- The crew mock now limits a search's results to its `authors`.
- New row with the reviewer's probe: 1,100 members, one loaded Alice,
  39 nonmember Alices (a full relay-wide page). It resolves to the
  member after one search over unnamed members only (0.33 s). On
  c6bc521 it returns `ambiguousAuthor` without reading messages.
- Each row now asserts the search's scope: unnamed members, never the
  loaded match, beside a loaded match, and relay-wide otherwise. The
  namesakes-on-both-sides and outside-namesake rows fail on c6bc521
  on this assertion.
- "treats a full page of member name matches past the budget as
  ambiguous": 40 members past the budget named "Alice Smith" fill the
  page, so the author is ambiguous and no message is read (0.42 s).
  c6bc521 was also ambiguous here, but through the relay-wide search,
  so it fails only on the search-scope assertion.
- "searches the members whose names did not load in parts that fit
  the filter budget": 1,925 roster keys leave 901 unnamed, searched in
  parts of 900 and 1. The namesake in either part makes it ambiguous
  (0.27 s). Fails on c6bc521, which sent one relay-wide search.

Also run: tsc, lint, and the useSearchMessages and SearchResults suites
(2 files, 108 passed). Not exercised in the running app or against a
live relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e matt2e changed the title fix(relay): return what fits at three capacity cliffs (profiles, discovery, heavily reacted messages) fix(relay): return what fits at two capacity cliffs (profiles, heavily reacted messages) Nov 4, 2026
matt2e and others added 8 commits November 4, 2026 21:19
Opening a message by exact id (Inbox row, search hit, reminder, deep
link, message-link preview, media viewer) failed with "Selected message
exceeded its evidence limit" once it had 500 reactions. Kind 7 shared
the single safety-overlay filter, and the session's exact target reader
rejects any capped raw response because a truncated read cannot prove
the message was not edited or deleted.

Reactions cannot hide or change the target, so the overlay request now
reads them through a separate `[7]` filter, and the guard counts only
non-reaction events. Safety kinds (5, 9005, 40003, 39005) and
tombstone responses still fail closed at 500. A full reaction page is
kept as a bounded supplement and marks the thread view `limited`
instead of failing the target. Older reactions beyond that page are not
read. The loadMore guard now relies on canLoadMore alone (limited
already implied no continuation), so a capped reaction page does not
block older replies.

Request count and latency: unchanged for every message. Reactions ride
as a second filter in the existing overlay request; the relay applies
`limit` per filter. Messages with few overlays still cost the same
target, overlay and (when overlays exist) tombstone round trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…sters

`profiles.ensure` threw "Profile request exceeds the directory budget"
for more than 1,024 ids before reading anything. Community admin
raised an unhandled rejection and its name search found nobody, and
`from:name` search in a channel failed with the same message. (The
Members dialog already pages its name reads, #2028.)

The directory now names the first 1,024 ids of a larger request (its
cache's entry capacity) and leaves the rest unrequested. Fetching more
would only evict names the same request had just loaded. Members beyond
the cap keep their public-key label, and no notice is shown, because
Try again cannot name them. The over-budget request still reaches
verified-profile lookup only for the ids that fit (#2220).

Two related partial-failure fixes in the same path:
- A batch whose response exceeds the reader's 8 MiB budget retries
  from the same author in halves, like AgentBrowse's catalog pages,
  instead of loading zero names. A single author still over the budget
  fails after one read per halving (at most 10 reads for 500 authors).
  A foreground promotion rejoins whichever part is in flight.
- `ensure` reports a failed batch only after its other batches settle,
  so a caller that tolerates the failure reads every name that loaded.

Callers: Community admin catches the optional name read. Message search
`from:` proceeds on a partial directory, as the author picker in
SearchResults already does. An unnamed member falls through to the
relay name search.

Not changed: touch-on-read (`get()` for `event()`/`owner()`). Displayed
names come from the folded snapshot, not the LRU, so promoting there
would not make eviction follow what is on screen. Both functions are
also `useSyncExternalStore` snapshot getters (useAgentOwnerEvidence,
useProfileOwner) and run inside projection folds, so `get()` would make
pure reads reorder the cache. A use-based policy belongs in ensure()
with its own eviction tests.

Performance: unchanged for requests of up to 1,024 ids whose batches
fit the read budget. Request count, sizes and success latency are the
same, plus one array slice. A request above 1,024 ids now costs at most
three reads (500/500/24) instead of failing, and asking again with
those names cached reads nothing. When a batch fails, ensure now
rejects once its slowest batch settles rather than at the first
failure. The large-roster `from:` test takes about 1 s locally, which
this scale contract needs.

Docs: docs/channels.md now says a request above the 1,024-entry
budget names those that fit instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to the capacity-cliff review of 2026-10-29 12:02 UTC. The
discovery eviction and Members dialog parts of that review were dropped
in the rebase onto main: #1574 replaced discovery's 1,024-entry cap with
a 256 MiB byte budget and pins the shortened-id fallback, and #2028
pages the dialog's name reads.

Review: threads.ts `reactionsCapped`
- Dropped it. A full 500-reaction page forced `limited: true`, which
  ThreadPanel and MediaReviewViewer show as "Thread history limit
  reached." even while older replies still load. The target still
  opens, its 500 reactions are a lower bound, and live reactions keep
  arriving. relay-queries.md no longer says the view is marked
  `limited`.

Review: halving gaps (a) and (b)
- Each part is accepted as it lands. A transient failure on part k
  retries from part k at the halved size instead of from author 0.
  Kept because the code is no larger than before (the result array and
  `.then` go away) and is safe:
  - retryRead runs attempts one at a time with the caller's signal, so
    two attempts never share the hoisted cursor.
  - A part that lands after an abort is dropped, as before.
  - A promotion still rejoins `batch.reading`.

Deliberately not changed:
- Warm-cache eviction. ByteLru evicts in insertion order and nothing
  touches an entry on use. A request that loads enough new names
  therefore evicts the viewer's own profile and on-screen names, and
  so does a long session once it has seen 1,024 profiles. This predates
  the branch at every request size. The fix is a use-based policy in
  ensure(), or reading only the rows that render, as a follow-up.
- Halving is still unmeasured against a real 8 MiB response.

Performance:
- Profile batches that fit the read budget are unchanged: one accept
  per batch. A halved batch publishes once per part rather than once
  per batch, so earlier parts' names show one read sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up covering the review of 2026-10-29 12:53 UTC. The discovery
eviction, channel-link pin and Members dialog parts were dropped in the
rebase onto main (#1574 replaced the 1,024-entry discovery cap with a
256 MiB byte budget; #2028 pages the dialog's name reads).

Review: profile halving
- Dropped. The 2 MiB profile cache keeps only about a quarter of an
  8 MiB response, so the parts that landed evicted each other, and the
  case was never measured. Removed the hoisted cursor, the
  `ReadError` match and their two tests. The batch read is main's
  again.
- Kept: the 1,024-id cap, the Community admin and `from:` search
  catches, and settling every batch before a failure is reported.
- Docs: channels.md says a batch whose response exceeds the 8 MiB read
  budget (for example heavy legacy inline avatars) still loads no
  names, and that reading only rendered rows is a follow-up.

Review: threads.ts loadMore guard
- Reverted to `!snapshot.canLoadMore || snapshot.limited`. With
  `reactionsCapped` gone it is equivalent, and the thread diff is now
  only the separate reaction filter.

Tombstone read over the request budget: fixed in code
- The target's tombstone read sent every overlay id in one `#e` list.
  The reader rejects filters over 64 KiB (about 975 ids) before
  sending, so a target with more overlay ids failed to open.
  One target read can return 499 safety overlays plus 500 reactions,
  and `remote` can hold more. Carrying only this response's ids would
  still exceed the budget. It would also stop re-reading tombstones for
  overlays already in `remote`. So the read now goes in parts of 900
  ids: a `for` loop and a slice in place of the `if`, with no new
  state. Each part keeps the 500-event fail-closed check. Up to 900 ids
  it is one request, as before.
- Test: the full-reaction-page target test now also carries 480 edits
  (980 overlay ids). It asserts two tombstone requests of 900 and 80
  ids covering every overlay, and the newest edit as the content.
  Before, the reader rejected the request locally and no tombstone
  query was sent.

Performance:
- No read is added anywhere that worked before. Tombstone parts only
  apply above 900 overlay ids, which used to fail.
- Profile batches are back to one accept per batch.
- thread-target.test.ts signs its 500 reactions and 480 edits once in
  beforeAll (about 1.45 s); before, 500 were signed inside the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 042c06e06, covering the review of 2026-10-29 13:23 UTC.

Review: useSearchMessages.ts rethrow after an abort
- Applied. `ensure(...).catch(() => {})`, as in CommunityAdmin. The
  next line's `throwIfAborted()` already throws once the search is
  aborted, and the outer `.catch` ignores errors after an abort.

Review: BuzzLinkPreview.test.tsx 500-reaction test
- Dropped. thread-target.test.ts's "opens a target with a full reaction
  page and hundreds of edits" covers the same real-session reader guard
  and split reaction filter. The mocked-session test covers
  BuzzLinkPreview's `session.thread(channel, id, { exact: true })`
  wiring. The file is back to main's.

Review: channel-reference.ts `reference.state` dependency
- Kept. The reviewer's trace holds for revocation and cache clear:
  leaving a private channel bumps `accessRevision`, `applyDiscovery`
  revokes access, and `purgeAccess` calls `resetReferences`, which
  looks up every demanded link again. The requested test (a mounted,
  found link whose private channel the viewer leaves gets looked up
  again) passes with and without the dependency.
- A found answer can still be lost without a reset. A public channel
  the viewer has left still reads `found` through its open metadata.
  If a newer 39000 makes it private, `accept` sees no access change,
  because access was already lost. Nothing resets, and the answer goes
  from `found` to `unknown`. Without the dependency the hook never
  refers again. No lookup is sent, and the link keeps showing its raw
  label instead of "Private channel" until it remounts or a later reset.
  Main's hook re-referred on that change, so dropping the dependency
  would regress it.
- Tests: both are added to channel-references.test.ts.
  - "the viewer leaves its private channel" pins the reset path.
  - "a public channel the viewer left turns private" fails without the
    dependency: no lookup read is sent. It passes with it and with
    main's hook.

Performance: no production behavior changes. The search catch behaves
the same.

Tests (summed test time, 042c06e06 -> this commit, local):
- BuzzLinkPreview.test.tsx: 1.91 -> 0.06 s, 9 -> 8 tests
- channel-references.test.ts: 2.84 -> 2.99 s, 14 -> 16 tests; the new
  tests take about 0.12 s each

Also run: tsc, lint, and the channel-references, BuzzLinkPreview,
useSearchMessages, channel-reference, MessageLink and thread-target
suites (7 files, 91 passed). Not exercised in the running app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the browser-journey CI failure on PR #2114 (run 41377657528).

2d4007a split the exact-target overlay read into a safety-kind filter
and a separate `[7]` reaction filter. The policy relay stub answers
only the multi-filter batches it recognises, so the new batch fell
through to the sidebar-preferences branch and failed `toHaveLength(5)`
in every journey that opens a thread (118 tests). The stub now
recognises the batch: two filters on the same single `#e` target, each
with `limit: 500`, the first carrying exactly kinds 5, 9005, 39005 and
40003 and the second only kind 7. The fixture's `answer()` serves each
filter as before.

The 1,100-member Members dialog test this commit also timed was dropped
in the rebase onto main (#2028 pages the dialog's name reads).

Tests (local):
- Chromium and WebKit: right-pane-dismissal, message-navigation, inbox,
  navigation-thread-history, nested-replies, buzz-links, typing,
  message-actions, message-management, inbox-tabs, inbox-edit and
  presence specs, 207 of 208 passed in 8.4 min. The one failure is
  WebKit message-management.spec.mjs:361 (Delete dialog Close not
  focused), which also fails locally on main.
- channel-opening.spec.mjs on chromium-measurements and
  webkit-measurements: passed.

Not run: the full browser suite. Hosted CI will cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the second P2 finding in kalvinnchau's review of PR #2114
(2026-11-02 03:58 UTC, comment 4438794444). The first (discovery
preview eviction keeping restrictive metadata) was dropped in the
rebase onto main: #1574 replaced the 1,024-entry discovery cap with a
256 MiB byte budget, so there is no preview eviction to fix.

Review: useSearchMessages.ts from:name namesakes (1608c3e48)
- Real, and a regression of this branch. `scoped` matched only loaded
  names. When it found one member, `resolve` treated that member as
  unique, and the relay profile search ran only when `scoped` was
  empty. With more than 1,024 members (names load for the lowest 1,024
  keys) or a failed batch (now caught), a namesake among the unloaded
  members was missed, and the search silently read the loaded member's
  messages. On main both cases threw, so the user saw an error, not a
  wrong answer.
- Fix: the member names are partial when `ensure` rejects or a member
  is neither in the snapshot nor in `unnamed()`. `unnamed()` records
  members the relay answered without a kind 0, so profile-less bots and
  viewers do not count. This test also catches main's existing case,
  where the 2 MiB profile cache evicts early names during the same
  `ensure`. When partial, a loaded match no longer settles it: the
  existing relay name search runs too. Beside a loaded member match,
  its results count only if they are channel members, and they are
  merged with `scoped` before `resolve`. Two matches open the existing
  author picker, which already merges loaded members with the relay
  search. A full page of 40 still counts as ambiguous. A healthy roster
  takes the old path unchanged.
- Residuals: in a large channel, a name with 40 or more relay matches
  always opens the picker. The relay does not fold accents, so an
  accented namesake past the cap is still missed, as on the existing
  relay branch.

Performance:
- from:name with 1,024 or fewer members whose names all load: no added
  request. One `some` over the roster with two Set lookups per member.
- Otherwise: one `searchProfiles` (limit 40) per debounced query, the
  same cost the empty-`scoped` path already paid. Membership is checked
  with `includes` for at most 40 candidates.

Tests:
- useSearchMessages.test.tsx, 1,100 members with one Alice key starting
  with `1` (named) and another starting with `f` (past the cap):
  - Namesakes on both sides: the far one is never requested, one
    profile search, `ambiguousAuthor`, no message read. Fails on the
    previous code, which resolves to the loaded Alice.
  - A named member and a namesake outside the channel: one search,
    resolves to the member. Fails on the previous code (no search), and
    fails if the member restriction is dropped (ambiguous).
  - Only the member past the cap is named: resolves to her after one
    search. This guard passes before and after, as it should: the
    fix must keep this path.
  - Small fully read roster with a profile-less member: no profile
    search. A control that passes before and after. It fails if
    partial detection ignores `unnamed()`.
  - "resolves from:name on a roster larger than the profile budget when
    some names fail": its mock now answers the `search` filter, because
    the failed batches send it to the relay search.
- Summed time for useSearchMessages.test.tsx: 2.04 -> 3.09 s, 6 -> 10
  tests, about 0.27 s per new test.

Also run: tsc, lint, and the useSearchMessages suite (10 passed). Not
exercised in the running app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Fixes the P3 finding in kalvinnchau's review of PR #2114
(2026-11-02 06:26 UTC, comment 4439478824).

Review: useSearchMessages.ts full-page check (c6bc521)
- Real, reproduced. When member names were partial, a loaded match
  sent the search to the relay-wide name search, which returns at most
  40 profiles from the whole relay. Matches were then limited to
  channel members, but the full-page check still counted all 40. With
  1,100 members, one loaded Alice and 39 nonmember Alices, the search
  returned `ambiguousAuthor` and read no messages. Any common name
  opened the picker in a channel past the profile budget, even with one
  matching member.
- Fix: beside a loaded match, the name search covers only the members
  whose names did not load, as a kind 0 NIP-50 search with `authors`
  set to those keys. The block/buzz relay passes `authors` into its
  full-text query, so other users' profiles never fill the page. The
  keys go in parts of 900, which fit the reader's 64 KiB filter budget
  (about 975 keys), as the tombstone read in threads.ts does. A member
  who matches the name makes the author ambiguous. A part that returns
  a full page of 40 still counts as ambiguous, because another match
  could be on the next page.
- Not chosen: reading every unnamed member's kind 0. That brings back
  the >1,024-id cliff and the 2 MiB cache churn for very large
  channels. The search returns at most 40 profiles per part.
- With no loaded match, the relay-wide search is main's again: matches
  are not limited to members, and `scoped` is not merged in. That undoes
  c6bc521's member filter there. `ensure`'s failure is caught as in
  CommunityAdmin, since the unnamed-member set already includes failed
  batches.
- Residuals: the full-text search matches whole profile JSON, so 40
  unnamed members whose `about` or `nip05` contains the name still
  open the picker. The relay does not fold accents, as on the
  relay-wide path. A typed name over about 5 KB would push a 900-key
  part past the filter budget, and the search would fail with an error.

Request cost per debounced from:name query in a channel:
- All member names loaded, or no loaded match: unchanged (no added read,
  or one relay-wide `searchProfiles` with limit 40, as on main).
- Loaded match with U unnamed members: ceil(U / 900) kind 0 search
  reads with limit 40, sent in parallel, instead of one relay-wide
  search. For 1,100 members (76 unnamed) that is one read. A
  10,000-member channel takes 10 reads. Each response holds at most 40
  profiles.

Tests (useSearchMessages.test.tsx, summed test time 3.21 -> 4.15 s,
10 -> 13 tests, local):
- The crew mock now limits a search's results to its `authors`.
- New row with the reviewer's probe: 1,100 members, one loaded Alice,
  39 nonmember Alices (a full relay-wide page). It resolves to the
  member after one search over unnamed members only (0.33 s). On
  c6bc521 it returns `ambiguousAuthor` without reading messages.
- Each row now asserts the search's scope: unnamed members, never the
  loaded match, beside a loaded match, and relay-wide otherwise. The
  namesakes-on-both-sides and outside-namesake rows fail on c6bc521
  on this assertion.
- "treats a full page of member name matches past the budget as
  ambiguous": 40 members past the budget named "Alice Smith" fill the
  page, so the author is ambiguous and no message is read (0.42 s).
  c6bc521 was also ambiguous here, but through the relay-wide search,
  so it fails only on the search-scope assertion.
- "searches the members whose names did not load in parts that fit
  the filter budget": 1,925 roster keys leave 901 unnamed, searched in
  parts of 900 and 1. The namesake in either part makes it ambiguous
  (0.27 s). Fails on c6bc521, which sent one relay-wide search.

Also run: tsc, lint, and the useSearchMessages and SearchResults suites
(2 files, 108 passed). Not exercised in the running app or against a
live relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.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.

2 participants