mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
pg-fts: swap generated content_tsv column for expression GIN index
Replace the GENERATED ALWAYS ... STORED tsvector column + column-backed
GIN index with a single expression index on to_tsvector('simple', content).
Why: the expression index is maintained by Postgres on every INSERT/UPDATE
exactly like a column index (no write-path work), but avoids the stored
column's ALTER TABLE row rewrite / ACCESS EXCLUSIVE backfill — so the
migration build is online-safe on a fresh/small relay and the same-named
index (idx_events_content_fts) is pre-buildable out of band on a large
populated relay (CREATE INDEX CONCURRENTLY + ATTACH per the live-relay
runbook). IF NOT EXISTS makes the migration idempotent against that path.
The query path renders the identical to_tsvector('simple', content)
expression so the planner uses the index. Rank SQL (ts_rank_cd) is
unchanged in behavior.
- migrations/0004_search_fts.sql: single CREATE INDEX IF NOT EXISTS expr index
- schema/schema.sql: drop generated column; expr index for fresh installs
- crates/buzz-search/src/postgres.rs: query refs -> to_tsvector(...) expr
- crates/buzz-db/src/migration.rs: assertions match new shape
- crates/buzz-search/src/lib.rs, crates/buzz-relay/src/main.rs: doc wording
Tests: cargo test -p buzz-db -p buzz-search (incl. ignored PG tests):
118 passed, 0 failed.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
a4943e53bc
commit
bbf125dab2
@@ -164,12 +164,15 @@ mod tests {
|
||||
assert_eq!(migrations[3].version, 4);
|
||||
assert_eq!(&*migrations[3].description, "search fts");
|
||||
assert!(
|
||||
migrations[3].sql.as_str().contains("content_tsv tsvector")
|
||||
migrations[3]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("idx_events_content_fts")
|
||||
&& migrations[3]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("idx_events_content_tsv"),
|
||||
"fourth migration should add the generated tsvector column and GIN index"
|
||||
.contains("to_tsvector('simple', content)"),
|
||||
"fourth migration should add the expression GIN index for FTS"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,23 +207,21 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Returns `schema/schema.sql` with the NIP-ER reminder DDL and the
|
||||
/// search-FTS DDL removed, so it models a pre-stack deployment whose
|
||||
/// `events` table lacks the reminder columns and the generated tsvector
|
||||
/// column. The strip is asserted: if the snapshot text drifts so these
|
||||
/// search-FTS index removed, so it models a pre-stack deployment whose
|
||||
/// `events` table lacks the reminder columns and the FTS expression
|
||||
/// index. The strip is asserted: if the snapshot text drifts so these
|
||||
/// fragments no longer match, the test fails loudly rather than silently
|
||||
/// loading a snapshot that already carries the columns (which would make
|
||||
/// loading a snapshot that already carries them (which would make
|
||||
/// migration 0003 or 0004 collide on re-add).
|
||||
fn pre_reminder_schema_snapshot() -> String {
|
||||
const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n";
|
||||
const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n";
|
||||
const FTS_COLUMN: &str = " content_tsv tsvector\n GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,\n";
|
||||
const FTS_INDEX: &str =
|
||||
"CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);\n";
|
||||
"CREATE INDEX idx_events_content_fts ON events USING GIN (to_tsvector('simple', content));\n";
|
||||
|
||||
assert!(
|
||||
SCHEMA_SQL.contains(REMINDER_COLUMNS)
|
||||
&& SCHEMA_SQL.contains(REMINDER_INDEX)
|
||||
&& SCHEMA_SQL.contains(FTS_COLUMN)
|
||||
&& SCHEMA_SQL.contains(FTS_INDEX),
|
||||
"schema.sql reminder/FTS DDL drifted; update pre_reminder_schema_snapshot to match"
|
||||
);
|
||||
@@ -228,7 +229,6 @@ mod tests {
|
||||
SCHEMA_SQL
|
||||
.replace(REMINDER_COLUMNS, "")
|
||||
.replace(REMINDER_INDEX, "")
|
||||
.replace(FTS_COLUMN, "")
|
||||
.replace(FTS_INDEX, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
service
|
||||
}
|
||||
SearchBackend::Postgres => {
|
||||
info!("Search backend: postgres (content_tsv generated column)");
|
||||
info!("Search backend: postgres (expression GIN index idx_events_content_fts)");
|
||||
SearchService::with_postgres(db.pool())
|
||||
}
|
||||
SearchBackend::Disabled => {
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
//! `search()` path returns event IDs that the relay then refetches from
|
||||
//! Postgres.
|
||||
//! - [`SearchService::with_postgres`] (Postgres FTS): runs `plainto_tsquery`
|
||||
//! against a generated `content_tsv` column on `events`. No write-path
|
||||
//! indexing needed — the generated stored column auto-populates on INSERT.
|
||||
//! against the `idx_events_content_fts` expression GIN index on `events`. No
|
||||
//! write-path indexing needed — Postgres maintains the expression index on
|
||||
//! every INSERT/UPDATE, exactly like a column index.
|
||||
//! - [`SearchService::disabled`]: returns empty results for every query and
|
||||
//! accepts indexing calls as no-ops. Used when NIP-50 search is intentionally
|
||||
//! off (e.g. for tenants who opted out).
|
||||
@@ -39,7 +40,7 @@ use sqlx::PgPool;
|
||||
pub enum SearchBackend {
|
||||
/// Typesense (current production default).
|
||||
Typesense,
|
||||
/// Postgres full-text search via the `content_tsv` generated column.
|
||||
/// Postgres full-text search via the `idx_events_content_fts` expression GIN index.
|
||||
Postgres,
|
||||
/// NIP-50 search is disabled; every query returns empty.
|
||||
Disabled,
|
||||
@@ -138,8 +139,8 @@ impl SearchService {
|
||||
}
|
||||
|
||||
/// Creates a Postgres FTS `SearchService` backed by the supplied pool.
|
||||
/// No indexing setup is required — the `content_tsv` generated column
|
||||
/// populates on every INSERT.
|
||||
/// No indexing setup is required — the `idx_events_content_fts` expression
|
||||
/// index is maintained by Postgres on every INSERT/UPDATE.
|
||||
pub fn with_postgres(pool: PgPool) -> Self {
|
||||
Self::Postgres(pool)
|
||||
}
|
||||
@@ -174,8 +175,8 @@ impl SearchService {
|
||||
/// Indexes a single event (upsert semantics).
|
||||
///
|
||||
/// - **Typesense**: writes a document to the collection.
|
||||
/// - **Postgres**: no-op — the `content_tsv` generated stored column is
|
||||
/// populated automatically on the original INSERT.
|
||||
/// - **Postgres**: no-op — the `idx_events_content_fts` expression index is
|
||||
/// maintained automatically on the original INSERT.
|
||||
/// - **Disabled**: no-op.
|
||||
pub async fn index_event(&self, event: &StoredEvent) -> Result<(), SearchError> {
|
||||
match self {
|
||||
@@ -236,9 +237,9 @@ impl SearchService {
|
||||
/// Removes an event from the search index by its event ID hex string.
|
||||
///
|
||||
/// - **Typesense**: deletes the document.
|
||||
/// - **Postgres**: no-op — `content_tsv` is tied to the event row;
|
||||
/// removing the row removes the index entry, and the relay's event
|
||||
/// deletion path already handles that.
|
||||
/// - **Postgres**: no-op — the `idx_events_content_fts` entry is tied to the
|
||||
/// event row; removing the row removes the index entry, and the relay's
|
||||
/// event deletion path already handles that.
|
||||
/// - **Disabled**: no-op.
|
||||
pub async fn delete_event(&self, event_id: &str) -> Result<(), SearchError> {
|
||||
match self {
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! `db.get_events_by_ids` → `filters_match` → auth post-filter chain in
|
||||
//! `crates/buzz-relay/src/handlers/req.rs` is unchanged.
|
||||
//!
|
||||
//! Matching uses `plainto_tsquery('simple', $q)` against the `content_tsv`
|
||||
//! generated column added in migration `0004_search_fts.sql`. Pushdowns:
|
||||
//! Matching uses `plainto_tsquery('simple', $q)` against the
|
||||
//! `idx_events_content_fts` expression GIN index added in migration
|
||||
//! `0004_search_fts.sql`. Pushdowns:
|
||||
//!
|
||||
//! - `kinds` → `kind = ANY($kinds)`
|
||||
//! - `authors` → `pubkey = ANY($authors)` (hex-decoded)
|
||||
@@ -69,8 +70,9 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
// hand-rolling a query with a stable parameter ordering is more readable
|
||||
// and easier to audit.
|
||||
//
|
||||
// `simple` matches the tokenizer used by the `content_tsv` generated
|
||||
// column in migration 0004. plainto_tsquery treats the input as a plain
|
||||
// `simple` matches the tokenizer used by the `idx_events_content_fts`
|
||||
// expression index in migration 0004 (`to_tsvector('simple', content)`),
|
||||
// so this query is index-served. plainto_tsquery treats the input as a plain
|
||||
// string (handles spaces, ignores punctuation) — closest analogue to
|
||||
// Typesense's default `query_by=content` behavior.
|
||||
let mut binds = Binds::new();
|
||||
@@ -91,13 +93,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
// terms cluster together — closer match to Typesense's text relevance
|
||||
// than plain `ts_rank`. Returned as `rank REAL`.
|
||||
sql.push_str(&format!(
|
||||
", ts_rank_cd(content_tsv, plainto_tsquery('simple', ${idx})) AS rank"
|
||||
", ts_rank_cd(to_tsvector('simple', content), plainto_tsquery('simple', ${idx})) AS rank"
|
||||
));
|
||||
}
|
||||
sql.push_str(", COUNT(*) OVER () AS total FROM events WHERE deleted_at IS NULL");
|
||||
if let Some(idx) = q_idx {
|
||||
sql.push_str(&format!(
|
||||
" AND content_tsv @@ plainto_tsquery('simple', ${idx})"
|
||||
" AND to_tsvector('simple', content) @@ plainto_tsquery('simple', ${idx})"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
-- Add a generated full-text-search column + GIN index to the events table so
|
||||
-- the relay can serve NIP-50 search directly from Postgres, eliminating the
|
||||
-- Typesense dependency.
|
||||
-- Add a full-text-search GIN index to the events table so the relay can serve
|
||||
-- NIP-50 search directly from Postgres, eliminating the Typesense dependency.
|
||||
--
|
||||
-- `content_tsv` is `GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED`
|
||||
-- so every INSERT/UPDATE populates it automatically — no application-level
|
||||
-- index maintenance needed (matches the pattern Typesense filled today via the
|
||||
-- worker pipeline in `buzz-relay/src/state.rs`).
|
||||
-- Shape: an EXPRESSION index on `to_tsvector('simple', content)` rather than a
|
||||
-- `GENERATED ... STORED` column. Postgres maintains an expression index on every
|
||||
-- INSERT/UPDATE exactly like a column index, so the write path needs no
|
||||
-- application-level index maintenance (this replaces the Typesense worker
|
||||
-- pipeline) — and there is no stored column, so no `ALTER TABLE ... STORED` row
|
||||
-- rewrite and no `ACCESS EXCLUSIVE` backfill. The index build is therefore
|
||||
-- online-safe on a fresh/small relay (this file) and pre-buildable out of band
|
||||
-- on a large populated relay (operator runbook, below).
|
||||
--
|
||||
-- Tokenizer choice: `simple` does no stemming and preserves identifiers like
|
||||
-- agent handles, nip05 strings, and slugs. The `english` config would stem
|
||||
-- ("running" → "run") but mangle handles ("alice42" tokenizes fine, but
|
||||
-- something like "agents" → "agent" would break exact-handle search). Chat
|
||||
-- content is heterogeneous; `simple` is the safer default for v1.
|
||||
-- ("running" -> "run") and mangle handles. Chat content is heterogeneous;
|
||||
-- `simple` is the safer default for v1. The query path in
|
||||
-- `buzz-search/src/postgres.rs` renders the identical `to_tsvector('simple', content)`
|
||||
-- expression so the planner matches this index.
|
||||
--
|
||||
-- kind:0 metadata flattening: the existing Typesense pipeline appends parsed
|
||||
-- display_name/name/nip05 to event content before indexing
|
||||
-- (`buzz-search/src/index.rs::flatten_kind0_for_indexing`). With FTS on raw
|
||||
-- kind:0 metadata: the old Typesense pipeline appended parsed
|
||||
-- display_name/name/nip05 to event content before indexing. With FTS over raw
|
||||
-- `content`, those strings still tokenize because they live in the kind:0 JSON
|
||||
-- body — `to_tsvector('simple', '{"name":"alice"}')` matches `q=alice` after
|
||||
-- json-aware tokenization. Validated by the NIP-50 e2e suite.
|
||||
-- body — `to_tsvector('simple', '{"name":"alice"}')` matches `q=alice`.
|
||||
-- Validated by the NIP-50 e2e suite.
|
||||
--
|
||||
-- `events` is partitioned by RANGE (created_at); ADD COLUMN on the parent
|
||||
-- cascades the generated column to every partition, and CREATE INDEX on the
|
||||
-- parent builds a partitioned GIN index that propagates to each partition.
|
||||
-- Partition pruning on since/until queries narrows the GIN scan further than
|
||||
-- Typesense's full-collection scan does today.
|
||||
-- `events` is partitioned by RANGE (created_at). `CREATE INDEX ... ON events`
|
||||
-- builds a partitioned GIN index whose per-partition child indexes propagate to
|
||||
-- existing and future partitions. Partition pruning on since/until narrows the
|
||||
-- GIN scan further than Typesense's full-collection scan does today.
|
||||
--
|
||||
-- IF NOT EXISTS makes this migration idempotent against the operator runbook:
|
||||
-- on a large relay the index is built per-child with CREATE INDEX CONCURRENTLY
|
||||
-- and ATTACHed to a parent named `idx_events_content_fts` BEFORE this code
|
||||
-- deploys, so this statement is a no-op. See
|
||||
-- GUIDES/BUZZ_POSTGRES_FTS_LIVE_RELAY_RUNBOOK.md.
|
||||
--
|
||||
-- Managed by sqlx migrations.
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN content_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
|
||||
|
||||
CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_content_fts
|
||||
ON events USING GIN (to_tsvector('simple', content));
|
||||
|
||||
+1
-3
@@ -107,8 +107,6 @@ CREATE TABLE events (
|
||||
d_tag TEXT,
|
||||
not_before BIGINT,
|
||||
delivered_at BIGINT,
|
||||
content_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
|
||||
PRIMARY KEY (created_at, id)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
@@ -138,7 +136,7 @@ CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted
|
||||
CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;
|
||||
CREATE INDEX idx_events_not_before ON events (not_before)
|
||||
WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;
|
||||
CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);
|
||||
CREATE INDEX idx_events_content_fts ON events USING GIN (to_tsvector('simple', content));
|
||||
|
||||
-- ── Event mentions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user