diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index c0550e7e22..6c84950a2c 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -17,6 +17,13 @@ use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; +/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is +/// unset — the effective ceiling on any client-requested `limit`. +/// +/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so +/// the advertised ceiling and the enforced one cannot drift. +pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -67,9 +74,9 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, - /// Override the default limit clamp (1000). Used by COUNT fallback path - /// which needs to fetch all matching events for post-filter counting. - /// When None, the default clamp of 1000 applies. + /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by + /// the COUNT fallback path, which needs to fetch all matching events for + /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, /// Shared-gated visibility reader: when set, append an SQL visibility /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so @@ -357,7 +364,7 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } - let clamp = q.max_limit.unwrap_or(1000); + let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 5c60d1a702..50aac1cbaf 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,7 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome}; +pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 678199e734..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3042,6 +3042,27 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } + /// Offsets are sized from the *clamped* limit the DB will honor, not from + /// what the client asked for. `filter_to_query_params` clamps an absent or + /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in + /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) + /// and that clamped value is what arrives here — so page N starts exactly + /// N-1 full pages in. Sizing from an unclamped limit would step past rows + /// the previous page never returned. + #[test] + fn extract_page_offset_sizes_pages_from_clamped_limit() { + let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; + + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)), + Some(clamped) + ); + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)), + Some(clamped * 2) + ); + } + #[test] fn extract_depth_limit_valid() { let raw = serde_json::json!({ "depth_limit": 3 }); diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 35fbf0c892..2aed12cd7f 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -22,7 +22,6 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. @@ -416,10 +415,24 @@ pub async fn handle_req( ); } -/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. -/// Search subscriptions are one-shot — no persistent subscription is registered. +/// FTS candidate hits fetched per page. Pages are always full regardless of +/// the requested limit — post-filtering discards an unpredictable share of +/// hits, so the scan fetches candidates in full pages rather than sizing +/// pages to the request. +const SEARCH_PAGE_SIZE: u32 = 100; + /// Maximum FTS pages to fetch per filter (prevents unbounded loops). -const MAX_SEARCH_PAGES: u32 = 10; +/// +/// Derived from the advertised page ceiling rather than fixed: the scan +/// budget is a resource policy — at most one advertised page ceiling's worth +/// of candidates per filter — and deriving it keeps the budget tracking the +/// ceiling if the ceiling ever moves. This bounds candidates *scanned*, not +/// events *emitted*: post-filtering (NIP-01 match, channel access, reader +/// visibility, dedup) can discard any number of candidates, so a result +/// smaller than the requested limit remains possible and is not a NIP-11 +/// violation — `max_limit` promises a clamp on the request, not a count in +/// the response. +const MAX_SEARCH_PAGES: u32 = (buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32).div_ceil(SEARCH_PAGE_SIZE); /// Resolve request-local channel access, repairing a stale cache-negative. /// @@ -501,6 +514,8 @@ pub(crate) fn build_search_channel_scope_filter( }) } +/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. +/// Search subscriptions are one-shot — no persistent subscription is registered. #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, @@ -535,8 +550,8 @@ async fn handle_search_req( let limit = filter .limit - .map(|l| (l as u32).min(MAX_HISTORICAL_LIMIT as u32)) - .unwrap_or(MAX_HISTORICAL_LIMIT as u32); + .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32); if limit == 0 { continue; // NIP-01: limit 0 means "no results from this filter" @@ -583,13 +598,11 @@ async fn handle_search_req( let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); - // Paginate: keep fetching pages until we've emitted `limit` results - // or exhausted the search result set. This ensures post-filtering - // doesn't silently reduce the result count below the requested limit. + // Paginate: keep fetching pages until we've emitted `limit` results or + // exhausted the search result set. Post-filtering discards an unpredictable + // share of each page, so continuing past short yields gives the scan a + // chance — not a guarantee — of filling the requested limit. let mut emitted: u32 = 0; - // Always fetch full pages (100) regardless of limit — post-filtering - // may discard many hits, so we need headroom to fill the requested limit. - let per_page: u32 = 100; for page in 1..=MAX_SEARCH_PAGES { if emitted >= limit { @@ -605,7 +618,7 @@ async fn handle_search_req( since, until, page, - per_page, + per_page: SEARCH_PAGE_SIZE, mode: buzz_search::SearchMode::FullText, }; @@ -617,9 +630,9 @@ async fn handle_search_req( } }; - // A short page is the last page: FTS returns up to `per_page` hits, - // so fewer than that means the result set is exhausted. - let exhausted = search_result.hits.len() < per_page as usize; + // A short page is the last page: FTS returns up to a full page of + // hits, so fewer than that means the result set is exhausted. + let exhausted = search_result.hits.len() < SEARCH_PAGE_SIZE as usize; let page_empty = search_result.hits.is_empty(); let hit_ids: Vec<[u8; 32]> = @@ -878,8 +891,8 @@ fn filter_to_query_params( .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); let limit = filter .limit - .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT)) - .unwrap_or(MAX_HISTORICAL_LIMIT); + .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT); // Push author filter into SQL. Single-author uses the indexed `pubkey` column; // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. @@ -1418,6 +1431,83 @@ mod tests { ) } + /// NIP-11 `limitation.max_limit` as this relay actually advertises it. + fn advertised_max_limit() -> i64 { + crate::nip11::RelayInfo::build( + None, + None, + false, + crate::config::DEFAULT_MAX_FRAME_BYTES, + None, + ) + .limitation + .expect("limitation") + .max_limit + .expect("max_limit") as i64 + } + + #[test] + fn req_filter_limit_clamps_to_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + // A filter asking for more than the relay advertises is clamped down to + // exactly the advertised ceiling — the NIP-11 document is the promise, + // this is the enforcement. + let greedy = filter_to_query_params( + &Filter::new().limit(advertised as usize * 10), + None, + community, + ); + assert_eq!(greedy.limit, Some(advertised)); + + // A filter with no `limit` gets the same ceiling, not something larger. + let unbounded = filter_to_query_params(&Filter::new(), None, community); + assert_eq!(unbounded.limit, Some(advertised)); + + // Neither sets `max_limit`, so `query_events` applies its own default + // clamp. That default must equal the advertised value too, or the + // clamp above would be undone one layer down. + assert_eq!(greedy.max_limit, None); + assert_eq!(unbounded.max_limit, None); + assert_eq!(buzz_db::DEFAULT_MAX_PAGE_LIMIT, advertised); + + // Under-ceiling requests are honored verbatim. + let modest = filter_to_query_params(&Filter::new().limit(10), None, community); + assert_eq!(modest.limit, Some(10)); + } + + /// The NIP-50 search path clamps its emission target to the advertised + /// ceiling like every other REQ, but the number of candidates it will scan + /// is bounded a second time by the page budget. This pins the resource + /// policy: the budget covers exactly one advertised page ceiling's worth of + /// candidates — no less (a ceiling raise must not silently shrink the scan + /// relative to what clients may request) and no hand-tuned spare (the budget + /// must stay derived, not drift back into a magic number). It deliberately + /// does NOT claim search fills the emitted limit — post-filtering can + /// discard any number of candidates. + #[test] + fn search_scan_capacity_covers_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + let capacity = i64::from(MAX_SEARCH_PAGES) * i64::from(SEARCH_PAGE_SIZE); + + assert!( + capacity >= advertised, + "NIP-50 scans at most {capacity} candidates ({MAX_SEARCH_PAGES} pages of \ + {SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — the scan budget \ + no longer covers the advertised ceiling" + ); + + // The budget is derived, not hand-tuned: one page under the derived + // count must be insufficient, or the ceiling could rise without the + // page count following it. + assert!( + capacity - i64::from(SEARCH_PAGE_SIZE) < advertised, + "scan budget has a spare page of slack — derive it from the ceiling" + ); + } + #[test] fn count_fallback_fetches_one_extra_candidate() { let mut query = diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..2575ddd7ba 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -89,6 +89,11 @@ pub struct RelayLimitation { /// Canonical `RelayLimitation` advertised by this relay. /// +/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the +/// REQ path clamps filter limits to, so the advertised ceiling and the +/// enforced one cannot drift (see +/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`). +/// /// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token @@ -103,7 +108,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), - max_limit: Some(10_000), + max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32), max_subid_length: Some(256), min_pow_difficulty: None, auth_required: true,