Skip to content

fix(feed): window the mentions feed per conversation to stop inbox starvation - #5834

Draft
thomaspblock wants to merge 1 commit into
mainfrom
inbox-feed-windowing
Draft

fix(feed): window the mentions feed per conversation to stop inbox starvation#5834
thomaspblock wants to merge 1 commit into
mainfrom
inbox-feed-windowing

Conversation

@thomaspblock

Copy link
Copy Markdown
Contributor

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 n over 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:

  1. candidates — indexed event_mentions walk, newest-first, bounded by FEED_WINDOW_SCAN_CAP (2000)
  2. keyed — conversation key = dm:<channel_id> for DM channels, else the NIP-10 thread root (event's own id for top-level), mirroring the client's getInboxConversationId
  3. rankedROW_NUMBER() per conversation keeps the newest FEED_CONVERSATION_EVENT_CAP (3) events; conversations are emitted 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.

Desktop side: get_feed's mention filter now carries feed_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 /query path. The kinds list stays as the fallback bound for older relays that ignore feed_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_types from 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.

before

After (windowed feed path): all 7 conversations survive — the chatty DM is capped and collapsed to one row, every standalone mention is visible.

after

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

  • New SQL-shape unit test mentions_query_windows_per_conversation and Postgres regression query_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-ignored feed module: 29 passed (5 pre-existing failures elsewhere in the suite — push-matcher/community-limit tests — fail identically on clean main, verified via stash)
  • cargo test -p buzz-relay --lib: 879 passed
  • just desktop-tauri-clippy && just desktop-tauri-test: 2414 passed
  • just desktop-check, desktop-typecheck, desktop-test: 4775 passed
  • New Playwright spec passes against an isolated relay built from this branch

Notes

  • Caps are constants (3 events/conversation, 2000 scan 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.
  • The related-but-separate Needs-Action gap (open PRs p-tagging you land in Mentions, not Needs Action) is deliberately not in this PR; it composes on the same surface as a client-side follow-up.

…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 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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:

  • unreadCount in 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} \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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 \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 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.

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