Files
buzz/crates
Luke Tornquist 298a555e34 perf(db): bind the channel scope as one array (#8946)
## Summary

Event reads scoped to a set of channels pushed one bind parameter per
channel: `channel_id IN ($5, $6, …)`. This applies to a viewer's access
scope, which the relay adds to every REQ and `/query` without an `#h`,
and to explicit multi-value `#h` filters. As a result, the SQL text
depended on the viewer's channel count, and every count was a separate
prepared statement.

That caused two problems in production:

- **Re-planning on every call.** A connection rarely reused a cached
plan for these reads, so Postgres planned each one again. With no
`since`, the plan covers every `events` partition. For `#p` mention
reads (the `event_mentions` join), planning took far longer than
executing the query.
- **Hidden cost.** Monitoring that groups by statement split one query
into one row per channel count, so its total cost never showed up in a
single place.

This PR binds the set as `channel_id = ANY($n)`, so one statement serves
every channel count. On a production replica, the same query prepared
with `ANY($5)` moved to Postgres's cached generic plan by its sixth run.
Planning time dropped to near zero, and that plan was no slower than the
custom one.

## What changed

- `push_channel_scope` in `crates/buzz-db/src/store/event.rs` builds the
channel-scope predicate as one array bind.
- `build_query_events_sql` and `count_events_sql` each had their own
copy of this block. Both now call the helper.
- Semantics are unchanged:
  - Access scope still keeps channel-less global events.
  - Explicit `#h` still matches only the requested channels.
  - An empty set still means "no channels".

## What got simpler

Two duplicated 20-line blocks became one helper. The statement count for
channel-scoped reads no longer grows with channel count.

## Generic-plan exposure

These statements were already persistent prepared statements, so a
cached generic plan was always possible whenever the connection's
statement cache held one. This change makes that reuse consistent; it
doesn't introduce generic plans. Tag-value reads (`has_tag_values`)
still run unnamed, as before.

Plain (non-`#p`) access-scoped reads stay on custom plans. A production
replica, with a viewer in hundreds of channels, ran the plain page read
repeatedly under the default `plan_cache_mode`, and every run used the
custom ordered index scan. Postgres estimated the generic plan as far
more expensive, and when forced, it was orders of magnitude slower.
Postgres switches only when the generic plan is no costlier than the
average custom plan, so it never chose this one. The generic plan's cost
comes from the `LIMIT` and `kind` values it can't see, not from the
array length, and the per-arity `IN (…)` statements had the same blind
spot.

## Tests

- `channel_scope_sql_does_not_depend_on_channel_count` (unit): for the
`#p` join and plain reads, for access scope and explicit `#h`, in both
the page SELECT and the COUNT, the SQL for 1 channel equals the SQL for
40 channels. It also checks that the SQL uses `ANY(` and that global
rows are retained only for access scope.
- `channel_scoped_mention_reads_share_one_prepared_statement`
(Postgres): on one connection, access-scoped `#p` reads with 1, 2 and 3
channels leave exactly one prepared `event_mentions` statement. With one
mention per channel, one global mention, and one in an unscoped channel,
every scope of 0–3 channels, with and without globals, returns and
counts exactly its own rows.
- The existing scope semantics tests still pass:
  - `access_scope_is_applied_before_historical_page_limit`
  - the explicit multi-channel `#h` page and count test
- Mutation: reverting the helper to one bind per channel fails both new
tests.

## Identified debt (not in this PR)

- `get_last_message_at_bulk` still builds an `IN (…)` list per channel.
It's a separate, lower-volume statement.
- Superseded versions of replaceable roster events (kind 39002) keep
their `event_mentions` rows. On large channels, a viewer's roster
mention rows grow with every membership change. That's a follow-up.

🤖 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-03 19:25:34 +00:00
..