perf(db): split explicit kinds for author-only reads

The author-only mask `kind IN (..) AND (kind NOT IN (private) OR
pubkey = reader)` looked broad to the Postgres planner. When foreign
private rows dominated the requested kinds, it walked the community's
primary key backward, so a mixed read such as kinds [30300, 30078]
scanned every row in the community: 1.4-1.8 s on a 2.45M-event
community, against about 6 ms before the pushdown.

With explicit kinds, emit `kind IN (public) OR (kind IN (private) AND
pubkey = reader)` instead. Postgres can estimate and index each side,
and the same reads now take 4-9 ms. Kindless reads keep the mask.

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Larry
2026-10-05 12:54:55 -04:00
parent 0e8bcdc5fd
commit 5925759cfe

View File

@@ -125,8 +125,9 @@ pub struct EventQuery {
/// defense-in-depth catches any residual mismatch.
pub shared_gated_reader: Option<Vec<u8>>,
/// Author-only visibility reader for [`query_events`]: exclude foreign
/// [`AUTHOR_ONLY_KINDS`] before ordering, offset and limit. This does not
/// affect `count_events`; callers must retain its existing fallback gate.
/// [`AUTHOR_ONLY_KINDS`] before ordering, offset and limit. The SQL
/// `count_events` path ignores it, so callers must keep their existing
/// fallback gate; COUNT fallbacks built on `query_events` do apply it.
pub author_only_reader: Option<Vec<u8>>,
}
@@ -679,13 +680,62 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
}
}
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
qb.push(format!(" AND {col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
for k in ks {
sep.push_bind(*k);
let kinds = q.kinds.as_deref().filter(|k| !k.is_empty());
match (kinds, q.author_only_reader.as_ref()) {
// Author-only visibility belongs before pagination, like shared-gated
// visibility; relay result checks remain defense in depth. With
// explicit kinds, split them so Postgres sees `public kinds OR the
// reader's own private rows` and can estimate and index each side.
// The equivalent `kind IN (..) AND (kind NOT IN (private) OR pubkey =
// reader)` looks broad to the planner: when foreign private rows
// dominate the kinds, it walks the community's primary key backward.
(Some(ks), Some(reader_bytes)) => {
let (private, public): (Vec<i32>, Vec<i32>) = ks
.iter()
.partition(|k| AUTHOR_ONLY_KINDS.iter().any(|a| *a as i32 == **k));
qb.push(" AND (");
if !public.is_empty() {
qb.push(format!("{col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
for k in &public {
sep.push_bind(*k);
}
qb.push(")");
}
if !private.is_empty() {
if !public.is_empty() {
qb.push(" OR ");
}
qb.push(format!("({col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
for k in &private {
sep.push_bind(*k);
}
qb.push(format!(") AND {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(")");
}
qb.push(")");
}
qb.push(")");
(Some(ks), None) => {
qb.push(format!(" AND {col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
for k in ks {
sep.push_bind(*k);
}
qb.push(")");
}
(None, Some(reader_bytes)) => {
qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
let mut sep = qb.separated(", ");
for kind in AUTHOR_ONLY_KINDS {
sep.push_bind(*kind as i32);
}
qb.push(format!(") OR {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(")");
}
(None, None) => {}
}
if let Some(ref pk) = q.pubkey {
@@ -802,19 +852,6 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
qb.push(")");
}
// Author-only visibility belongs before pagination, just like shared-gated
// visibility. Keep relay result checks as defense in depth.
if let Some(ref reader_bytes) = q.author_only_reader {
qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
let mut sep = qb.separated(", ");
for kind in AUTHOR_ONLY_KINDS {
sep.push_bind(*kind as i32);
}
qb.push(format!(") OR {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(")");
}
// Composite ordering for deterministic pagination across ALL callers of
// query_events (WebSocket REQ, REST endpoints, canvas, notes, etc.).
// The `id ASC` tiebreaker ensures stable results when events share the
@@ -2597,6 +2634,38 @@ mod e_tag_filter_shape_tests {
assert!(sql[fence_end..].contains("ORDER BY created_at DESC, id ASC LIMIT "));
}
/// Explicit kinds must reach SQL as `public OR own private`, not as a
/// `NOT IN .. OR pubkey` mask the planner cannot estimate: on a large
/// community that mask made a sparse mixed-kind read walk every row.
#[test]
fn author_only_reader_splits_explicit_kinds() {
let shape = |kinds: Option<Vec<i32>>| {
let mut q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil()));
q.kinds = kinds;
q.author_only_reader = Some(vec![1; 32]);
build_query_events_sql(&q).sql().as_str().to_owned()
};
let mixed = shape(Some(vec![30300, 1, 30350]));
assert!(
mixed.contains("AND (kind IN ($2) OR (kind IN ($3, $4) AND pubkey = $5))"),
"{mixed}"
);
assert!(!mixed.contains("NOT IN"), "{mixed}");
let private = shape(Some(vec![30300]));
assert!(
private.contains("AND ((kind IN ($2) AND pubkey = $3))"),
"{private}"
);
let kindless = shape(None);
assert!(
kindless.contains("AND (kind NOT IN ($2, $3, $4) OR pubkey = $5)"),
"{kindless}"
);
}
#[test]
fn non_e_tag_query_is_not_fenced() {
let q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil()));