fix(feed): window the mentions feed per conversation to stop inbox starvation - #5834
fix(feed): window the mentions feed per conversation to stop inbox starvation#5834thomaspblock wants to merge 1 commit into
Conversation
…arvation ## Problem The Inbox collapses to a handful of rows the busier your agents get. Reported by Morgan (3 rows spanning 12 hours) and reproduced by Thomas (~10 rows spanning a day and a half). ## Cause The mentions feed was a flat `ORDER BY created_at DESC LIMIT n` over every event p-tagging the user. Clients group those events into conversation rows *after* the cut, so the window is event-shaped while the inbox is conversation-shaped: one chatty DM or thread (e.g. an agent posting many consecutive callback mentions) consumes nearly every slot, then grouping collapses them into a single row. Every conversation older than the window is gone before the UI ever sees it. ## Fix Window the mentions query per conversation in the relay (`build_mentions_query`, crates/buzz-db/src/feed.rs): - candidates: indexed `event_mentions` walk, newest-first, bounded by `FEED_WINDOW_SCAN_CAP` (2000) - keyed: conversation key = `dm:<channel_id>` for DM channels, else the NIP-10 thread root from `thread_metadata` (event's own id for top-level) — mirroring the client's `getInboxConversationId` - ranked: `ROW_NUMBER()` per conversation keeps the newest `FEED_CONVERSATION_EVENT_CAP` (3) events; conversations surface newest-activity-first, so the final `LIMIT` truncates at a conversation boundary A 50-event window now spans at least ~17 distinct conversations instead of potentially 1. No schema change. The desktop `get_feed` mention filter now carries `feed_types: ["mentions"]`, routing it through the relay's feed path (which applies the windowing) instead of the flat generic query. The kinds list is retained as the fallback bound for older relays that ignore `feed_types`. The e2e bridge mirrors the same wire shape. ## Verification - SQL-shape unit test (`mentions_query_windows_per_conversation`) and a Postgres regression (`query_mentions_survives_chatty_conversation_ starvation`): a 40-reply chatty thread plus 6 standalone mentions in a 20-event window — all 7 conversations survive, chatty capped at 3. - Before/after Playwright spec against an isolated relay seeding a 60-message DM burst over 6 standalone mentions (inbox-windowing-screenshots.spec.ts): the flat window shows 1 surviving row; the windowed path shows all 7 conversations. - buzz-db suite, buzz-relay lib suite (879), desktop tauri tests (2414), desktop check/typecheck/test (4775) all green. Co-authored-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Thomas Petersen <thomasp@squareup.com>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 thanks for this — the per-conversation windowing is the right shape and the before/after e2e proof is great. Two of my agents reviewed this independently; consolidated feedback below (inline comments on the two SQL points).
The one thing I think needs a change before merge: the Buzz Git kinds are in the mentions kind list but don't actually get the windowing. The conversation key mirrors two of getInboxConversationId's three branches (dm:, thread root) but not the project: branch. NIP-34 events are community-global — ingest only writes thread_metadata for requires_h_channel_scope kinds (handlers/ingest.rs), and PR updates carry their root in an uppercase E tag anyway — so every PR update/status/comment falls through COALESCE(tm.root_event_id, c.id) to its own id and becomes its own one-event conversation at rank 1. A busy PR p-tagging you can fill the final LIMIT newest-first and then collapse to a single row client-side, which is the same starvation shape this PR fixes for DMs and threads. Both reviewers converged on this one independently. I'd derive the project conversation key server-side (e/E root for the activity kinds, own id for PR/issue roots) and add a mixed root/update/status/comment regression.
Two smaller things, non-blocking:
unreadCountin the client (inbox.ts) is computed from the windowed items, so a conversation with 40 unread mentions now badges at most 3. That's probably fine given the representative-event model, but it's user-visible — just want an explicit ack that it's intended.- the doc comment says the final
LIMIT"truncates at a conversation boundary" — that only holds when the limit is a multiple of the cap; a trailing conversation can still be cut mid-group. Harmless since the client regroups, maybe soften the wording.
| qb.push(" ORDER BY m.event_created_at DESC LIMIT ") | ||
| .push_bind(limit); | ||
| qb.push(format!( | ||
| " ORDER BY m.event_created_at DESC LIMIT {FEED_WINDOW_SCAN_CAP} \ |
There was a problem hiding this comment.
🤖 one of the two reviewers flagged this as blocking, the other as a documented tradeoff, so flagging for an explicit call: the scan cap runs before partitioning, so a single conversation holding the 2,000 newest mentions still evicts every older conversation — the starvation guarantee only holds within the scan window. 2000 is 50× the observed pathology so I lean "fine as documented", but the doc comment for FEED_CONVERSATION_EVENT_CAP states the limit / cap guarantee unconditionally — worth scoping it to the scan window (or windowing incrementally if you'd rather make the guarantee unconditional).
| ), keyed AS ( \ | ||
| SELECT c.*, \ | ||
| CASE WHEN ch.channel_type = 'dm' THEN 'dm:' || c.channel_id::text \ | ||
| ELSE encode(COALESCE(tm.root_event_id, c.id), 'hex') END AS conv_key \ |
There was a problem hiding this comment.
🤖 this CASE covers the dm: and thread-root branches of the client's getInboxConversationId, but not its project: branch — see review body: the NIP-34 kinds in the candidate kind list never get thread_metadata rows and fall through to per-event conversations, so a busy PR can still starve the window.
Problem
The Inbox gets emptier the busier your agents are. Morgan reported it (3 rows spanning 12 hours); Thomas hit it himself (~10 rows spanning a day and a half).
Cause
The mentions feed is a flat
ORDER BY created_at DESC LIMIT nover every event p-tagging the user (crates/buzz-db/src/feed.rs). Clients group events into conversation rows after the cut, so the window is event-shaped while the inbox is conversation-shaped: one chatty DM or thread — e.g. an agent posting many consecutive callback mentions — consumes nearly every slot in the window, then grouping collapses them all into a single row. Every conversation older than the window falls off the cliff before the UI ever sees it.Fix
Window the mentions query per conversation at the relay source (
build_mentions_query), where conversation identity already exists server-side (thread_metadata.root_event_id,channels.channel_type = 'dm'). No schema change.CTE pipeline:
event_mentionswalk, newest-first, bounded byFEED_WINDOW_SCAN_CAP(2000)dm:<channel_id>for DM channels, else the NIP-10 thread root (event's own id for top-level), mirroring the client'sgetInboxConversationIdROW_NUMBER()per conversation keeps the newestFEED_CONVERSATION_EVENT_CAP(3) events; conversations are emitted newest-activity-first so the finalLIMITtruncates at a conversation boundaryA 50-event window now spans at least ~17 distinct conversations instead of potentially 1.
Desktop side:
get_feed's mention filter now carriesfeed_types: ["mentions"], routing it through the relay's feed path (which applies the windowing and dedupes with the same access checks) instead of the flat generic/querypath. The kinds list stays as the fallback bound for older relays that ignorefeed_types. The e2e bridge mirrors the same wire shape.Proof
Both shots: same seeded relay state — one chatty DM (60 messages, newest) + 6 standalone mentions in #general (older), viewed as tyler.
Before (flat window — the spec strips
feed_typesfrom the outgoing query, reproducing the old wire shape): the DM burst consumes the whole 50-event window; the inbox collapses to one row and all 6 standalone mentions are gone.After (windowed feed path): all 7 conversations survive — the chatty DM is capped and collapsed to one row, every standalone mention is visible.
The screenshots regenerate from
desktop/tests/e2e/inbox-windowing-screenshots.spec.ts, which is also the regression test: the "before" test asserts the starvation (so we know the seed shape actually starves a flat window) and the "after" test asserts every conversation survives.Verification
mentions_query_windows_per_conversationand Postgres regressionquery_mentions_survives_chatty_conversation_starvation(40-reply chatty thread + 6 standalone mentions, 20-event window → all 7 conversations survive, chatty capped at 3)cargo test -p buzz-db -- --include-ignoredfeed module: 29 passed (5 pre-existing failures elsewhere in the suite — push-matcher/community-limit tests — fail identically on cleanmain, verified via stash)cargo test -p buzz-relay --lib: 879 passedjust desktop-tauri-clippy && just desktop-tauri-test: 2414 passedjust desktop-check,desktop-typecheck,desktop-test: 4775 passedNotes
3events/conversation,2000scan bound) with doc comments explaining the choice; the client only needs a representative event + unread signal per row — opening a row fetches the full thread separately.