Repository navigation
perf(mentions): stop scanning every member on each mention keystroke - #2246
Conversation
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.
No blockers. Not approving; that is Wes's call.
- Correctness: filtering
continuedMentionthroughmatchingMentionsholds.extendsNameneeds the folded, trimmed name to start with the folded, trimmed query, andnameStartsalways indexes that whole-name start (same fold, same trim, cut to 32 code points), so every continuing choice is in the lookup. Over-long names come back viaunindexed. A stripped-qualifier label is a prefix of the indexed label, so it is covered too.spaceMentionalready relies on this same superset forexactMention, andmodel.candidatesis the array ranking indexes, so the WeakMap entry is reused. - Row Sets: same results as
includes/some. - Test: I checked it locally at
2d2dfcc. It passes on the PR head and fails whenMentionCompletion.tsxis reverted tomain, so it does tell the two apart. - Non-blocking (PR text): the evidence section names internal "Blox workstations" in a public repo. "Remote Linux and macOS workstations" says the same thing.
loganj
left a comment
There was a problem hiding this comment.
🤖 Larry and Auto: an independent adversarial review of 2d2dfcc. Source review only, plus local test runs; we did not run the app.
Verdict: approve. No must-fix items. The Space-check change gives the same result, and the new test proves the claim.
What we checked
- The Space check gives the same result. A name blocks Space only when its folded, trimmed form starts with the normalized query. The first start key of every name is that same form, cut to 32 code points, and
startKeycuts the needle the same way. So the index lookup always returns a superset. This also holds for a label without its· qualifier(a prefix of the indexed label), for generated(agent)labels (only their indexed aliases count), and for choices with more than 16 starts (never indexed, always returned). The exact-name skip incontinuingNamesis per choice, so filtering never changes another choice's answer.spaceMention(use-mention-choices.ts:177) already uses this filter-then-check pattern on main. Auto's fuzz of 20k random sets (accents, ligatures, ς/σ, whitespace, qualifiers,(agent), long and many-word names) found 0 disagreements in 1,036 cases with a continuing name. - Row detail and avatar shape are the same.
Set.hasgives the same answer as the oldincludesandsome(... && agent). - The test fails without the fix. With main's
MentionCompletion.tsx,extendsNameis called 600 times ("Person 00", "zz", ...). Existing tests also guard the behavior: replacing the filtered list with[]fails 2 of them (solo-agents"Space is declined, not pending, beside a loaded name…" andMessageComposer"leaves Space literal beside a loaded longer member name…"). - Mentions tests: 250 pass at head. Full
vitest run: 15,415 pass.tsc --noEmitis clean.
Findings outside the diff
- advice (completeness; we would like this here or in a quick follow-up).
MentionCompletion.tsx:92-101: theadmittedcheck still goes through every candidate on every keystroke. It always builds[...model.candidates, ...choices].flatMap(...), even for a query with no space, wherematchesMentionQueryreturnstrueat once. For a query with a space, it runsnormalizeNameon every name until one matches. When the text is prose, nothing matches, so every name is folded on each keystroke after the space. This is the same full fold the PR removes lower down. The benchmark typed@jami, which has no space, so it did not measure this path. Fix: passmatchingMentions(model.candidates, query.query)instead ofmodel.candidates(both rules need the normalized name to start with the trimmed query), or checkquery.query.includes(" ")before building the list. - convention. The
matchingMentionsdoc comment (mention-ranking.ts:41-44) promises a superset of the choices thatrankMentions/exactMentionkeep. This caller andspaceMentionalso need it to include every choice with a name that continues the query. One sentence saying so would stop a later index change from silently breaking Space (for example, dropping the position-0 start, or indexing labels without their qualifier). - advice (minor). Line 146 still runs
model.directory.people.findonce per row. That list is directory pages, not members, so it is bounded. Not blocking.
| !teams.blocksSpace && | ||
| !continuedMention(model.candidates, query.query) && | ||
| // A continuing name starts with the query: only indexed matches can have one. | ||
| !continuedMention( |
There was a problem hiding this comment.
🤖 advice (perf, optional). This lookup runs before (model.pending || model.directory.loading) && pendingMentionQuery(...). When the list has settled, which is the usual case, its result is thrown away, but it still folds every matching name. A one-letter query can match hundreds of names. Putting the cheap conditions first skips the lookup.
Caveat: the new test's fixture is not pending, so after this reorder the test would also pass on main. It would need a pending fixture to keep proving the claim.
There was a problem hiding this comment.
🤖 Done in 64c57a8. The pending and pendingMentionQuery conditions now run before the continuation check. The test now holds a directory search open, so Space stays undecided and the test still reaches the check. It fails on main.
| }; | ||
| } | ||
| // Each shown row asks both; one pass each instead of one scan per row. | ||
| const memberKeys = new Set(members); |
There was a problem hiding this comment.
🤖 question / preference (simplicity). Each row already has member and agent from the same live candidate: choices rows are {...(live ?? row)}, and member is memberKeys.has(pubkey) over the same roster ?? channel.members (mention-candidates.ts:148,227; use-mention-choices.ts:403-414). Destructuring member, agent from the row would remove both new Sets.
The only difference: a stale "No longer available" or "Archived" agent row would keep its squircle instead of falling back to a circle. Its detail is the disabled reason either way. Fine as is if you want the old fallback.
There was a problem hiding this comment.
🤖 I kept the two Sets. Destructuring member and agent from the row would also work. But Prettier then re-indents the whole row block, and a stale agent row would keep its squircle. Building the Sets is one pass each over the candidates, per publish.
| h.library.dispose(); | ||
| }); | ||
|
|
||
| it("a mention keystroke checks longer names only on choices the query matches", async () => { |
There was a problem hiding this comment.
🤖 advice (test, optional). This test only uses a query with no matches. A second case such as person 0, asserting that extendsName sees only Person 0x names, would show that the filtered path checks only the matching names.
There was a problem hiding this comment.
🤖 Added in 64c57a8. The test now also types Person 0 and asserts that every name checked for continuation starts with it.
every mention keystroke asked continuedMention whether any known name continues the query, over every candidate. in a channel with thousands of members that folded and compared every member name per keystroke. a name can continue the query only if it starts with it, so the existing matchingMentions prefix index returns every choice that can. pass only those; the result is unchanged. the shown rows also looked up membership and agent shape by scanning the member and candidate lists once per row; build each set once per update instead. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <[email protected]>
the longer-name check ran before the cheap pending conditions, so a settled chooser folded every matching name and discarded the result. check pending and the query shape first. the admission rule for multi-word queries also folded every candidate name; both of its rules need a name that starts with the query, so give it only the indexed matches. document that matchingMentions must keep every choice whose name starts with the query. the test now runs with a directory search in flight, so it still reaches the check, and also covers a query that matches. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <[email protected]>
2d2dfcc to
64c57a8
Compare
|
🤖 Thanks, both. Changes in 64c57a8, rebased on main:
The mentions, messages, conversation, direct-messages, solo and search suites pass (3,004 tests), and |
…xecution-support-for-native-buzz-33ea2bea * origin/main: (74 commits) feat(host): harden byte lifecycle and add protected image upload (#2199) Updated onboarding (#2183) perf(relay): read each signed roster once in large channels (#2244) AIDA-2346: Keep spoilers hidden in reminder previews (#2261) fix: AIDA-2371 Keep sidebar selection pill aligned during resize (#2182) fix(relay): skip background rereads of profiles a read found absent (#2147) fix: prevent startup logo selection (#1823) fix(agents): send sign-in evidence with agent Git credentials (#2240) perf(mentions): stop scanning every member on each mention keystroke (#2246) fix(attachments): send image dimensions so previews don't shift (#2262) feat(shortcuts): add Slack-style hotkeys (#2170) perf(plugins): share host request clients across calls (#2243) fix(identity): keep federated sessions and agent grants in one keychain item (#2225) fix(profiles): evict the least recently requested profile (#2255) fix(unread): repair unread from the recovery tick only every 15 minutes (#2252) AIDA-2374: Use readable labels in chat titles (#2185) perf(profiles): keep verified names checked through a planned renewal (#2234) feat(plugins): let a plugin ask Buzz to review an install (#2059) fix(identity): sign FI builds in at first run through the native session (FOUNDATION: App.tsx, services.ts) (#2249) Place profile verification beside the verified name (#2224) ... Signed-off-by: Daniel Rouleau <[email protected]>
* commit 'c8e882165': (50 commits) Scope execution targets by harness session Read durable execution state before admission feat(host): harden byte lifecycle and add protected image upload (#2199) Updated onboarding (#2183) perf(relay): read each signed roster once in large channels (#2244) AIDA-2346: Keep spoilers hidden in reminder previews (#2261) fix: AIDA-2371 Keep sidebar selection pill aligned during resize (#2182) fix(relay): skip background rereads of profiles a read found absent (#2147) fix: prevent startup logo selection (#1823) fix(agents): send sign-in evidence with agent Git credentials (#2240) perf(mentions): stop scanning every member on each mention keystroke (#2246) fix(attachments): send image dimensions so previews don't shift (#2262) feat(shortcuts): add Slack-style hotkeys (#2170) perf(plugins): share host request clients across calls (#2243) fix(identity): keep federated sessions and agent grants in one keychain item (#2225) fix(profiles): evict the least recently requested profile (#2255) fix(unread): repair unread from the recovery tick only every 15 minutes (#2252) AIDA-2374: Use readable labels in chat titles (#2185) perf(profiles): keep verified names checked through a planned renewal (#2234) feat(plugins): let a plugin ask Buzz to review an install (#2059) ... Signed-off-by: Daniel Rouleau <[email protected]> Co-authored-by: Codex <[email protected]>
…ection * origin/main: (39 commits) Authorize local apps to use Buzz MCP tools (block#2211) feat(bestie): choose the private channels Bestie can read (block#2036) Polish message actions, spacing, and membership notices (block#2258) test(unread): prevent startup catch-up in receipt reserve journey (block#2274) test(search): wait for rejected join clearance to settle (block#2278) test(reactions): provide stable emoji media for icon rendering (block#2279) feat(profiles): reserve profile room for an open channel's roster (block#2269) feat(host): harden byte lifecycle and add protected image upload (block#2199) Updated onboarding (block#2183) perf(relay): read each signed roster once in large channels (block#2244) AIDA-2346: Keep spoilers hidden in reminder previews (block#2261) fix: AIDA-2371 Keep sidebar selection pill aligned during resize (block#2182) fix(relay): skip background rereads of profiles a read found absent (block#2147) fix: prevent startup logo selection (block#1823) fix(agents): send sign-in evidence with agent Git credentials (block#2240) perf(mentions): stop scanning every member on each mention keystroke (block#2246) fix(attachments): send image dimensions so previews don't shift (block#2262) feat(shortcuts): add Slack-style hotkeys (block#2170) perf(plugins): share host request clients across calls (block#2243) fix(identity): keep federated sessions and agent grants in one keychain item (block#2225) ... # Conflicts: # src/features/messages/ThreadPanel.tsx
…lock#2246) ## Problem In a channel with thousands of members, each mention keystroke did work proportional to the whole member list. `MentionCompletion` did two such scans when publishing its rows: - **The Space check** called `continuedMention(model.candidates, query)` to ask whether any known name continues the query. It looped over every candidate and folded and compared each name. At 5,080 members this was most of the keystroke's script time. - **Each shown row** (up to 50) called `members.includes(pubkey)` for its detail and `model.candidates.some(...)` for its avatar shape. That is up to 50 full scans of both lists per update. ## Change - **Space check:** the check is now given only `matchingMentions(model.candidates, query)`, the existing prefix index that ranking already builds for the same array, so the index is reused, not rebuilt. - The result is unchanged. A name can continue the query only if its folded, trimmed form starts with the folded, trimmed query, and every such choice is in the index lookup. - Choices with names too long to index are always returned. - **Shown rows:** each publish builds one member key `Set` and one agent key `Set`, and rows look up both instead of scanning. - **Check order:** the Space check runs only when Space is still undecided: something is pending and the query can still be pending. When the chooser has settled, it no longer folds every matching name and discards the result. - **Multi-word admission:** the rule that decides whether a query with a space is prose now reads only the indexed matches. Both of its rules need a name that starts with the query. - **`matchingMentions` docs:** the comment now states that it must keep every choice whose name starts with the query, because the Space check relies on that. The new test in `team-mentions.test.tsx` holds a directory search open, so Space stays undecided and the check runs. For a query that no member name starts with, no member name is checked. For `Person 0`, only names starting with it are checked. On main the check runs on all 600 member names and aliases, so the test fails there. ## Evidence Measured with the browser perf harness at 4× CPU throttle, using a probe scenario that is not part of this PR. The probe opens a channel with a named roster of the given size and warms the chooser once. It then types `@jami` one key at a time, 150 ms apart. Runs used 2 Linux and 2 macOS remote workstations, with main and this branch interleaved. Values are medians of 12 per OS. These were measured at 2d2dfcc, before the check-order and multi-word admission follow-up. That follow-up only skips work, so it was not measured again. | Members | Metric | Linux before | Linux after | Linux Δ | macOS before | macOS after | macOS Δ | |---|---|---|---|---|---|---|---| | 5,080 | Script, ms | 789 | 465 | −41% | 549 | 335 | −39% | | 5,080 | Long-frame blocking, ms | 638 | 314 | −51% | 357 | 166 | −54% | | 5,080 | Task, ms | 1,046 | 712 | −32% | 694 | 478 | −31% | | 1,688 | Script, ms | 460 | 315 | −32% | 330 | 235 | −29% | | 1,688 | Long-frame blocking, ms | 315 | 170 | −46% | 137 | 74 | −46% | | 1,688 | Task, ms | 709 | 562 | −21% | 486 | 395 | −19% | | 0 | Script, ms | 98 | 101 | +3% | 79 | 78 | −2% | | 0 | Task, ms | 198 | 206 | +4% | 152 | 150 | −1% | A separate fuzz comparison of 60,000 random choice sets and queries found no case where the filtered and unfiltered continuation checks disagree. The sets mixed accents, ligatures, whitespace, qualifier and `(agent)` suffixes, and queries over 32 code points. The mentions, messages, conversation, direct-messages and solo unit tests pass: 2,972 tests. `tsc --noEmit` is clean. ## Tradeoff Each publish now builds two `Set`s over the members and candidates. That is one linear pass each, instead of up to 50 scans of each list. With no members, the difference is within run-to-run noise. ## Regressions None found. Space selection, continuation, row details and avatar shapes are unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Problem
In a channel with thousands of members, each mention keystroke did work proportional to the whole member list.
MentionCompletiondid two such scans when publishing its rows:continuedMention(model.candidates, query)to ask whether any known name continues the query. It looped over every candidate and folded and compared each name. At 5,080 members this was most of the keystroke's script time.members.includes(pubkey)for its detail andmodel.candidates.some(...)for its avatar shape. That is up to 50 full scans of both lists per update.Change
matchingMentions(model.candidates, query), the existing prefix index that ranking already builds for the same array, so the index is reused, not rebuilt.Setand one agent keySet, and rows look up both instead of scanning.matchingMentionsdocs: the comment now states that it must keep every choice whose name starts with the query, because the Space check relies on that.The new test in
team-mentions.test.tsxholds a directory search open, so Space stays undecided and the check runs. For a query that no member name starts with, no member name is checked. ForPerson 0, only names starting with it are checked. On main the check runs on all 600 member names and aliases, so the test fails there.Evidence
Measured with the browser perf harness at 4× CPU throttle, using a probe scenario that is not part of this PR. The probe opens a channel with a named roster of the given size and warms the chooser once. It then types
@jamione key at a time, 150 ms apart. Runs used 2 Linux and 2 macOS remote workstations, with main and this branch interleaved. Values are medians of 12 per OS. These were measured at 2d2dfcc, before the check-order and multi-word admission follow-up. That follow-up only skips work, so it was not measured again.A separate fuzz comparison of 60,000 random choice sets and queries found no case where the filtered and unfiltered continuation checks disagree. The sets mixed accents, ligatures, whitespace, qualifier and
(agent)suffixes, and queries over 32 code points.The mentions, messages, conversation, direct-messages and solo unit tests pass: 2,972 tests.
tsc --noEmitis clean.Tradeoff
Each publish now builds two
Sets over the members and candidates. That is one linear pass each, instead of up to 50 scans of each list. With no members, the difference is within run-to-run noise.Regressions
None found. Space selection, continuation, row details and avatar shapes are unchanged.
🤖 Generated with Claude Code