Files
buzz/docs/bridge-channel-window.md
Luke Tornquist 0a362eef15 perf(db,relay): bound thread participant scans and coalesce live summaries (#8635)
## Summary

A thread summary's `participants` (up to 10 recent authors, used for the
facepile) was computed by aggregating **every** reply in the thread. The
live `kind:39005` emit runs that aggregate on the writer after every
reply insert and every threaded delete. Each summary therefore cost
O(thread), recomputed after every reply, so one very large thread could
saturate the writer.

### Scale and impact

A single long-running thread can grow to hundreds of thousands of
replies and take several new replies per second. Each of those replies
then pays a whole-thread aggregate on the writer, so writer CPU follows
the reply rate of the largest active threads, and the read-side
participant queries (channel window, thread root summaries) slow down
the same way.

Synthetic reproduction (PostgreSQL 17, the repo's `schema/schema.sql`,
default `shared_buffers`): one channel with 200k background messages and
three threads of 450k, 6k and 300 replies from a few authors with
~800-byte bodies. `EXPLAIN (ANALYZE, BUFFERS)` of the participant query,
warm cache (runs 2–3 of 3):

| Thread | Before (whole-thread aggregate) | After (newest 1000 replies)
|
|---|---|---|
| 450k replies | ~7.6 s, ~117k buffers read, ~16k temp blocks spilled |
~6.8 ms, ~4k buffers |
| 6k replies | ~25 ms, ~24k buffers | ~7 ms, ~4k buffers |
| 300 replies | ~2.6 ms, ~1.2k buffers | ~2.6 ms, ~1.2k buffers |

Cost now stops growing at 1000 replies, and small threads cost the same
as before. With coalescing, a busy thread runs this query at most about
once per second per pod instead of once per reply.

### Change

1. **Bound the participant scan to the newest 1000 replies.** All three
participant readers now share one SQL fragment
(`recent_thread_participants_sql!`):
   - `get_thread_summary` (the live 39005 emit)
   - the channel-window batch
   - `resolve_thread_root_summaries`

The fragment walks `idx_thread_metadata_window` newest-first with `LIMIT
1000`, then joins `events` only for those rows, to get `pubkey` and drop
deleted replies. Deleted replies keep their `thread_metadata` row, so
they count toward the 1000 but contribute no author. It aggregates by
author and returns the 10 most recently active. Cost no longer grows
with thread size. Desktop and mobile show the 3 most recent participants
and the forum view shows up to 10, so an author who hasn't posted in the
last 1000 replies is not visible in any client today. `NIP-CW`,
`docs/bridge-channel-window.md` and the `ThreadSummary::participants`
doc now state the bound, deleted replies included.
2. **Coalesce live summary emits per thread root.**
`emit_live_thread_summary` goes through a new `CoalescedRefresh` keyed
by `(community, root)`:
   - A quiet thread emits immediately, as before.
- On a busy thread, emits are spaced at least 1s apart per pod. A
trailing emit covers the rest of the burst and reads state committed
after its last mutation.
- Because emits are at least 1s apart, consecutive overlays from one pod
always get strictly increasing `created_at`, so the client's newest-wins
replacement is never tied.
- A panicking or cancelled refresh releases its key, including a task
dropped before its first poll: the release guard is built before the
spawn. Each run is bounded by a 30s timeout so a hung refresh can't
wedge it.

### What got simpler

- Three hand-written copies of the participant aggregate are now one
fragment.
- `get_thread_summary` is one statement instead of two round trips.
- The channel-window batch dropped its `ROW_NUMBER()` window function
and the `GROUP BY root, pubkey` over every reply of every root on the
page.

### Related issue

None found.

### Testing

- `participant_summaries_read_only_the_newest_replies` (buzz-db,
Postgres) seeds a thread with 1001 replies:
  - an early author, pushed just outside the window
  - a filler author
  - a deleted reply
  - a latest author

All three readers must return `[latest, filler]`. Mutant check: removing
the `LIMIT` (whole-thread aggregate) turns it red.
- `coalesced_refresh` unit tests (paused tokio clock):
- A 100-request burst runs once immediately and once trailing, then
frees the key.
  - Separate keys run independently.
  - A panicking refresh doesn't wedge its key.
- A hung refresh times out and releases its key. Mutant check: removing
the timeout turns it red.
- A request that arrives while a run is in flight gets one trailing run,
and that run reads the state written mid-run. Mutant check: dropping the
rerun flag turns it red.
- A task dropped before its first poll (runtime shut down) still
releases its key. Mutant check: building the guard inside the task turns
it red.

These run in `just test-unit` (added to the buzz-relay `--lib` filter
and `scripts/run-tests.sh`).
- Blox gate: `cargo fmt --check`, clippy `-D warnings` (buzz-db,
buzz-relay), and the full buzz-db and buzz-relay Postgres nextest lanes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:59:19 -04:00

140 lines
6.4 KiB
Markdown

# Bridge `/query` Extension: Channel Window
> **Normative spec:** [NIP-CW](nips/NIP-CW.md) is the canonical, standalone
> specification of the channel window (kinds 39005/39006, filter extension,
> cursor and trust semantics). This document remains as the ratified
> engineering contract and internal design record; where wording differs,
> NIP-CW governs.
Status: frozen contract v2 (2026-07-03) — GUI read-model overhaul.
Reviewed by: Mari (relay ground truth), Wren (client core), Quinn (spec
guardian), Perci (NIP landscape). Ratified in
`#buzz-gui-formal-relay-interaction-spec`, thread `a7c68013`.
The channel window is how Buzz clients page a channel timeline by
**top-level rows** instead of raw events. It is a raw-filter extension on
the existing HTTP bridge `POST /query` — the same extension family as
`before_id` and `thread_cursor`. There is no new endpoint, and the wire
carries only signed nostr events.
Vanilla NIP-01 cannot express "messages with no reply e-tag" (filters have
no negation), which is why generic nostr clients page raw events and
reassemble threads client-side. This relay computes `thread_metadata`
(depth, root, reply counts) at ingest, so it can serve the top-level view
directly. The WS REQ path ignores all fields below via `nostr::Filter`'s
unknown-field behavior — generic clients degrade gracefully to a normal
full-event query; they never see a wrong-but-plausible timeline.
## Request
A standard bridge filter plus extension fields:
```json
{
"kinds": [9],
"#h": ["<channel-uuid>"],
"limit": 50,
"top_level": true,
"include_summaries": true,
"include_aux": true,
"until": 1751500000,
"before_id": "<64-hex event id>"
}
```
- `top_level: true` — routes this filter to the top-level SQL view.
Requires exactly one `#h` channel the caller can access.
- `limit` — row budget. Counts **row events only**; summaries, aux, and
bounds overlays never consume it.
- `until` + `before_id` — the composite request cursor `(created_at, id)`
of the last retained row from the previous page. **Both or neither.**
`top_level` with `until` but no `before_id` is rejected (`400`): the
window path has no timestamp-only fallback, ever. Neither = head request.
- `include_summaries` / `include_aux` — opt-in overlay/closure appends.
`page`/OFFSET is not honored on the window path.
## Top-level predicate
A row is top-level iff `depth IS NULL OR depth = 0 OR (depth = 1 AND
broadcast = true)` in `thread_metadata` (v1 ruling: `NULL` — an event
ingested before thread metadata existed — counts as top-level; the harness
`legacyReply` scenario decides whether a backfill migration is needed).
Deleted rows (`deleted_at IS NOT NULL`) are excluded before the limit.
## Ordering and cursor
Rows are ordered `(created_at DESC, id ASC)` — the same composite every
other read path uses. The next-page cursor is the `(created_at, id)` of the
**last retained row**; the server echoes it in the `39006` bounds overlay
as `next_cursor`. Keyset comparison is
`created_at < $ts OR (created_at = $ts AND id > $id)`; dense seconds
paginate without loss or duplication by construction.
`has_more` is a **server fact**: the relay probes `limit + 1` rows after
all predicates (access, deletion, top-level, kinds), returns at most
`limit`, and reports the probe result in `39006`. The sentinel row never
reaches the wire, and no closure is computed for it. Clients must not
infer exhaustion from row count (`rows < limit` does not imply anything on
an exact-multiple final page) — `39006.has_more` is the only authority.
## Response
The existing flat bridge shape: a JSON array of signed nostr events.
Clients **partition by kind before any cursor math**:
1. **Rows** — the top-level events, in keyset order.
2. **Aux closure** (`include_aux`) — reactions (7), deletions (5, 9005),
and edits (40003) targeting the retained rows by `#e`, **plus**
deletions targeting those aux events (the transitive second hop, e.g.
a delete-of-a-reaction). One round trip; no client `#e` fan-out. Each
hop is drained server-side across the DB page clamp, so the closure is
complete rather than newest-1000.
3. **Thread summaries** (`include_summaries`) — one relay-signed
`kind:39005` per row that has replies.
4. **Window bounds** — exactly one relay-signed `kind:39006` per window
response.
### `kind:39005` — thread summary overlay
- tags: `["e", <root-id>]`, `["d", <root-id>]`, `["h", <channel-id>]`
- content: `{"reply_count":n,"descendant_count":n,"last_reply_at":ts|null,"participants":["<hex-pubkey>",...]}`
(participants: up to 10 from the newest 1000 replies, deleted ones included, most recent first)
- Signed by the relay keypair. Synthesized at query time, **never stored**.
Clients treat it as replace-by-target metadata keyed by the `e`/`d` tag
(the `d` tag gives parameterized-replaceable semantics natively); it is
never a row, never a cursor input, never durable timeline history.
### `kind:39006` — window bounds overlay
- tags: `["d", "<channel_id>:<request-cursor-or-head>"]`, `["h", "<channel_id>"]`
- content: `{"has_more": bool, "next_cursor": {"created_at": ts, "id": "<hex>"} | null}`
- `next_cursor = null ⇔ has_more = false`.
- `d`-tag suffix serialization (canonical): `head` for a head request,
else `<created_at>:<event_id>` — decimal unix seconds, then the full
64-char lowercase hex id, colon-delimited. Clients must verify the
suffix equals the cursor they sent and reject the overlay on mismatch.
- Reserved field: `oldest_retained` (retention gap), added without a wire
break if needed.
- Same overlay rules as 39005: relay-signed, query-time, never stored,
never a row or cursor input.
Both kinds are relay-only: client submission is rejected at ingest.
## Client obligations (frozen)
- Pages are immutable authoritative history chained cursor→cursor; live
events land in a separate overlay, never spliced into pages.
- Reconnect refetches page 0 and re-arms the live subscription
(`since: now`); deeper pages need no repair path.
- Replies never enter the channel timeline; the thread panel uses the
existing `thread_cursor` surface (#1418). Thread filters may opt into
`include_aux` to append the same authorized two-hop reactions, edits, and
deletions closure as a channel-window response.
## Siblings
`before_id` (requires `until`), `thread_cursor`/`thread_cursor_id`,
`depth_limit`, `feed_types` — see `bridge.rs`. All are bridge-only raw
filter extensions invisible to vanilla relays and clients.