Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions crates/buzz-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Vec<uuid::Uuid>>,
/// 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<i64>,
/// Persona visibility reader: when set, append an SQL visibility clause
/// for kind 30175 before ORDER/LIMIT so private personas are excluded from
Expand Down Expand Up @@ -344,7 +351,7 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
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);

Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
21 changes: 21 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3022,6 +3022,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 });
Expand Down
111 changes: 96 additions & 15 deletions crates/buzz-relay/src/handlers/req.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -416,10 +415,18 @@ 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 may discard many hits, so the scan needs
/// headroom to fill the limit.
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, so the search
/// path can always reach the limit NIP-11 promises. A bare page count would let
/// a future ceiling change leave search silently emitting fewer events than the
/// relay advertises — the same class of lie this ceiling exists to prevent.
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.
///
Expand Down Expand Up @@ -501,6 +508,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,
Expand Down Expand Up @@ -535,8 +544,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"
Expand Down Expand Up @@ -587,9 +596,6 @@ async fn handle_search_req(
// or exhausted the search result set. This ensures post-filtering
// doesn't silently reduce the result count below 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 {
Expand All @@ -605,7 +611,7 @@ async fn handle_search_req(
since,
until,
page,
per_page,
per_page: SEARCH_PAGE_SIZE,
mode: buzz_search::SearchMode::FullText,
};

Expand All @@ -617,9 +623,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]> =
Expand Down Expand Up @@ -878,8 +884,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.
Expand Down Expand Up @@ -1416,6 +1422,81 @@ 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 its ability to *reach* that target is
/// bounded a second time by how many FTS pages it will scan. If that scan
/// budget falls below the ceiling, search under-delivers against NIP-11
/// while the clamp above still looks correct — so the budget is asserted
/// against the advertised value, not just against the DB constant it is
/// derived from.
#[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} hits ({MAX_SEARCH_PAGES} pages of \
{SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — search \
would silently emit a short page"
);

// 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 =
Expand Down
7 changes: 6 additions & 1 deletion crates/buzz-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading