镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(relay): match #a tag filters in SQL before LIMIT - #9339

Draft
jemiahw wants to merge 1 commit into
mainfrom
relay/push-a-tag-filters
Draft

jemiahw wants to merge 1 commit into
mainfrom
relay/push-a-tag-filters

Conversation

@jemiahw

@jemiahw jemiahw commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The relay applied #a (NIP-01 address) filters only after the SQL LIMIT. A selective read like {kinds:[7], "#a":["30175:<pubkey>:<d>"], limit:n} fetched the newest kind:7 rows across the whole community and then dropped the ones that didn't match. When older matches existed, it could return fewer than n of them, or none. COUNT also could not take its exact fast path. Clients therefore cannot count reactions to an addressable event by address, which is the identity that persists when the event is edited.

What changed:

  • EventQuery.a_tags (buzz-db) works like e_tags. It is an exact positional tag match (tag->>0 = 'a' AND tag->>1 = ANY(..)) with JSONB @> as the prefilter on the existing GIN index idx_events_tags_gin.
    • No schema change, migration or backfill.
    • It gets the same MATERIALIZED CTE fence as #e, so the planner cannot walk created_at backward looking for a rare match.
    • It reads the table whole rather than recent partitions first.
    • It is counted in count_events_sql.
    • An empty set matches nothing, as the NIP-01 post-filter does.
  • filter_to_query_params fills a_tags for every filter, and filter_fully_pushable treats #a as pushed. REQ, COUNT and the HTTP bridge /query and /count all go through these two functions.
  • per_channel_scope now skips per-channel reads for every fenced query, through a shared reference_tag_fence helper. Before this, it excluded #e reads explicitly, and #a with a multi-value #h would have wrapped the fence around the per-channel lateral, which is invalid SQL.

Not included: #k and other low-cardinality tags. Postgres's estimate for JSONB @> assumes any match is rare, so pushing a common value could switch to a slower plan. They remain post-filters.

Related issue

None found.

Testing

  • req.rs unit test: #a maps to a_tags (including empty and absent) and is fully pushable; #a + #k is not.
  • buzz-db shape tests: an #a read is fenced, COUNT applies the match, an #a read is never split into recent and older passes, and #a makes a read ineligible for per-channel scope. An empty #a is answered without a database read.
  • buzz-db Postgres: an #a case added to pushed_down_tag_filters_reject_positional_decoys, covering query and COUNT.
  • New handlers/tag_pushdown_postgres_tests.rs:
    • REQ returns an older #a match ahead of a full page of newer reactions to another address. COUNT is exact, and an empty #a counts 0. This test fails when a_tags is removed from filter_to_query_params.
    • REQ and COUNT with #a + a multi-value #h. This test fails, with CLOSED database error, without the per_channel_scope fix.
  • Full buzz-db and buzz-relay suites, unit and Postgres lanes, run locally.
  • Live check against a local relay built from this branch, over WebSocket with NIP-42 auth. Two reactions to one address (each on a different head event, as after an edit) and six newer reactions to another address were published. REQ {kinds:[7], #a:[needle], limit:2} returned exactly the two needle reactions, newest first. COUNT returned 2 for the needle, 6 for the hay and 0 for an empty #a. #a + #e with limit:1 returned the newest needle reaction.

🤖 Generated with Claude Code

`#a` (NIP-01 address) was post-filtered after SQL LIMIT, so a selective
read such as `{kinds:[7], "#a":["30175:<pubkey>:<d>"]}` scanned the newest
rows community-wide and could return too few matches, or none, and COUNT
could not take its exact fast path.

`EventQuery.a_tags` mirrors `e_tags`: an exact positional tag match with
JSONB containment as the GIN prefilter (`idx_events_tags_gin`, no schema
change), the MATERIALIZED CTE fence, no recent-first split, and an empty
set matches nothing, as the post-filter does. `filter_to_query_params`
fills it for every filter and `filter_fully_pushable` treats `#a` as
pushed. `per_channel_scope` now excludes every fenced read through the
shared `reference_tag_fence` helper, so `#a` with a multi-value `#h`
cannot nest the fence around the per-channel lateral.

Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jemiah Westerman <jemiah@squareup.com>

This branch has not been deployed

No deployments
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.

1 participant