diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index be87faa1ac..021af5d223 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -625,7 +625,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 31); + assert_eq!(migrations.len(), 32); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1057,6 +1057,25 @@ mod tests { .as_str() .contains("error_code")); assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); + + // Thread subtree pagination uses the full tenant/root/keyset tuple. Keep + // the covering index additive so brownfield migration checksums remain + // stable. + assert_eq!(migrations[31].version, 32); + assert!(migrations[31] + .sql + .as_str() + .contains("CREATE INDEX IF NOT EXISTS idx_thread_metadata_root_keyset")); + assert!(migrations[31] + .sql + .as_str() + .contains("(community_id, root_event_id, event_created_at, event_id)")); + assert!(!migrations[0] + .sql + .as_str() + .contains("idx_thread_metadata_root_keyset")); + assert!(include_str!("../../../schema/schema.sql") + .contains("CREATE INDEX idx_thread_metadata_root_keyset")); } #[test] diff --git a/migrations/0032_thread_metadata_keyset_index.sql b/migrations/0032_thread_metadata_keyset_index.sql new file mode 100644 index 0000000000..9142f0bccf --- /dev/null +++ b/migrations/0032_thread_metadata_keyset_index.sql @@ -0,0 +1,21 @@ +-- ── Thread reply keyset pagination index ───────────────────────────────────── +-- Thread subtree reads filter by tenant and root, then page in ascending +-- (event_created_at, event_id) order. Include the keyset columns so PostgreSQL +-- can satisfy both the filter and ordering from one index scan. +-- +-- Lock note: built without CONCURRENTLY because sqlx runs each migration inside +-- a transaction and CREATE INDEX CONCURRENTLY cannot run in one. This takes a +-- SHARE lock on `thread_metadata` (blocking writes, not reads) for the duration +-- of the build. `thread_metadata` is on the event insertion path and may be +-- large on brownfield databases, so operators should pre-build it by hand before +-- deploying this migration: +-- +-- CREATE INDEX CONCURRENTLY idx_thread_metadata_root_keyset +-- ON thread_metadata (community_id, root_event_id, event_created_at, event_id); +-- +-- IF NOT EXISTS then makes this migration a no-op on that database. +-- +-- Additive migration: previously applied files must not change checksum. + +CREATE INDEX IF NOT EXISTS idx_thread_metadata_root_keyset + ON thread_metadata (community_id, root_event_id, event_created_at, event_id); diff --git a/schema/schema.sql b/schema/schema.sql index 9ef7bc0a4b..44bd90bb18 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -531,6 +531,8 @@ CREATE TABLE thread_metadata ( CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id); CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id); +CREATE INDEX idx_thread_metadata_root_keyset + ON thread_metadata (community_id, root_event_id, event_created_at, event_id); CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (community_id, channel_id, depth, event_created_at); CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id);