Files
buzz/crates/buzz-db
Luke Tornquist 0ada1d3707 buzz-db: read explicit #h sets one channel at a time (#9110)
## Summary

Explicit multi-channel `#h` reads whose channels × page limit fit a
budget of 8,192 now read each channel through the channel index and keep
the newest `n` rows overall. Before, Postgres was left to plan
`channel_id = ANY($set) ORDER BY created_at DESC LIMIT n` from a row
estimate.

Stacked on #9091 (both change `build_query_events_sql`). It retargets to
`main` when that merges.

## Problem

The desktop app's cold unread repair reads every joined channel in
chunks, with `kinds [9, 40002, 40008]`, `include_aux`, `limit 500` and
no `since`. It never fills its page.

For that shape, Postgres's custom plans (the first executions of each
prepared statement on a connection) overestimate how many rows a quiet
channel set matches. They walk the partition's primary key backward and
test every community row against the set. The inflated cost also turns
on JIT.

On a load-test database with about 940k events in one partition
(`EXPLAIN ANALYZE`, custom plan):

| Channels in the set | Plan | Rows returned | Time |
|---|---|---|---|
| 3 | channel index | 4 | 0.5 ms |
| 6 | channel index, plus JIT | 254 | 54 ms (1.2 ms scan) |
| 15 | pkey walk, 940k rows filtered | 427 | 546 ms |
| 48 | pkey walk, 299k rows filtered | 863 | 197 ms |
| 128 | channel index | 500 | 5 ms |

Generic plans pick the channel index, but every connection runs custom
plans first. A client-side `since` would not avoid the walk: it still
walks the whole window. It would also change unread semantics, and it
does nothing for app versions already shipped.

## Change

`build_query_events_sql` handles plain explicit `#h` reads like this:

```sql
SELECT e.* FROM (SELECT DISTINCT unnest($set::uuid[]) AS ch) c
CROSS JOIN LATERAL (
  SELECT … FROM events WHERE community_id = $1 … AND channel_id = c.ch …
  ORDER BY created_at DESC, id ASC LIMIT $n
) e
ORDER BY e.created_at DESC, e.id ASC LIMIT $n OFFSET 0
```

- **Same page as the set read:** every row of the overall top `n` is
also in its own channel's top `n`.
- **Bounded candidates:** at most distinct channels × `n` rows reach the
outer sort, whatever the statistics say. How many index entries each
channel scans still depends on the other predicates, such as `kinds`.
- **Same SQL for any number of channels:** the channel set stays one
array bind.
- **Repeated ids:** `DISTINCT` reads a repeated id once.
- **Per pass:** #9091's recent and older passes each use this shape.

These keep today's SQL: scopes that include global events (`channel_id
IS NULL OR ANY`), `#p` mention joins, e-tag fences, offset pages, the
COUNT, and sets whose channels × page limit exceed the budget.

### Candidate budget

A per-channel read fetches up to `n` rows from every channel, where the
set read stops once the page fills. So busy channel sets cost more. On a
synthetic community (920k events; 128 busy channels with 1,000 recent
messages and 1,000 reactions each, 400 more busy channels, 128 quiet
channels with 3 messages each), the repair shape (`kinds [9, 40002,
40008]`, recent pass), JIT off:

| Set × limit | Set read, custom plan | Set read, generic plan |
Per-channel |
|---|---|---|---|
| 128 busy × 500 | 2.3 ms | 238 ms | 114 ms |
| 20 busy + 108 quiet × 500 | 19 ms | 33 ms | 18 ms |
| 128 quiet × 500 | 473 ms | 0.7 ms | 1.3 ms |
| 15 busy × 500 | 22 ms | 23 ms | 13 ms |
| 15 quiet × 500 | 0.2 ms | 0.2 ms | 0.3 ms |
| 128 busy × 100 | 0.7 ms | | 17 ms |
| 128 quiet × 100 | 141 ms | | 1.2 ms |

With JIT on (the default), the per-channel custom plan for 128 channels
× 500 crossed the JIT thresholds: about 0.9 s for the busy set and 0.75
s for the quiet one, nearly all compile time. At 15 × 500 it stayed
under them.

So the per-channel read runs only while channels × page limit ≤ 8,192
(`PER_CHANNEL_CANDIDATE_BUDGET`, the clamped page limit). That bounds it
at about 8k candidate rows, where it was still faster than the set read
here. Larger sets keep the set read, as on `main`. Either way there are
two SQL texts, so plan caching holds.

The desktop repair's full 128-channel chunks at `limit 500` (64,000) are
over the budget and keep the set read. Only its last, partial chunk (16
channels or fewer) and other small explicit sets read per channel. The
load-rig numbers below predate the budget and don't show its effect.

On the same data, the per-channel form took 0.5 ms (6 channels), 0.9 ms
(15) and 4.2 ms (128).

Load rig, cold desktop reconnect scenario (every client runs the unread
repair), #9091 alone vs this PR on top:

| | Pool default: #9091 | Pool default: + this PR | Pool 200: #9091 |
Pool 200: + this PR |
|---|---|---|---|---|
| Repair read mean | 302 ms | 41 ms | 1,990 ms | 93 ms |
| Repair read total DB time | 1,547 s | 245 s | 9,622 s | 561 s |
| Repair reads served | 5,121 | 5,915 | 4,834 | 6,011 |
| Client op failures | 23,475 | 10,966 | 28,797 | 10,829 |
| Unread-load errors | 1,018 | 113 | 1,434 | 3 |
| Relay pool-timeout log lines | 8,762 | 824 | 13,860 | 409 |

The rig stays overloaded in every arm. Most remaining failures are the
per-connection concurrent-request limit and connect/auth under load, not
this read.

## Tests

- `per_channel_reads_match_the_set_read` (Postgres) covers:
- every limit from 1 to total+1, including a page held entirely by one
channel
  - newer rows of another kind or another channel taking no slot
  - a repeated channel id and an id tie across channels
  - keyset paging across the tie, and `since`
  - rows below the recent-pass floor
- a prepared-statement check that the read ran per channel and no `ANY`
read ran
- `per_channel_scope_eligibility` covers which reads keep the current
SQL, including the budget edge (16 × 512 in, 17 × 512 and 128 × 500 out)
and the clamped page limit.
- `channel_scope_sql_does_not_depend_on_channel_count` now also asserts
the per-channel shape for explicit `#h` pages.

## What got simpler

Not much. This adds one SQL shape to the events query builder, behind
one eligibility function and one budget. In exchange, the planner's row
estimate no longer decides whether a small multi-channel read scans a
whole partition. The channel predicate is decided in one place:
`push_event_filters` no longer pushes the channel scope, and each caller
(the page read and the thread route) pushes its own.

🤖 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-11-05 18:25:56 -05:00
..