Skip to content

Commit 9468315

Browse files
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehrTyler Longwell
andcommitted
Merge main into P4 reaction single-tx branch
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2 parents e9ebe92 + a9e752e commit 9468315

2 files changed

Lines changed: 120 additions & 23 deletions

File tree

crates/buzz-relay/src/api/bridge.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,11 @@ pub async fn query_events(
636636
handled.insert(idx);
637637
}
638638

639+
// Phase 1 — pure construction + validation, in filter order. Access-scope
640+
// skips and the `before_id` BAD_REQUEST are decided here, before any DB
641+
// work is issued (validation errors are deterministic client mistakes, so
642+
// surfacing them ahead of transient DB errors is strictly more predictable).
643+
let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new();
639644
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
640645
if handled.contains(&idx) {
641646
continue;
@@ -677,7 +682,24 @@ pub async fn query_events(
677682
query.offset = Some(offset);
678683
}
679684

680-
match state.db.query_events(&query).await {
685+
catchall_queries.push((idx, query));
686+
}
687+
688+
// Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`).
689+
// Phase 3 consumes results in original filter order, so response ordering
690+
// and error semantics match the previous serial loop.
691+
use futures_util::stream::{self, StreamExt};
692+
let db = state.db.clone();
693+
let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| {
694+
let db = db.clone();
695+
async move { (idx, db.query_events(&query).await) }
696+
}))
697+
.buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY);
698+
699+
// Phase 3 — post-processing, strictly in filter order.
700+
while let Some((idx, filter_events)) = catchall_results.next().await {
701+
let filter = &filters[idx];
702+
match filter_events {
681703
Ok(stored_events) => {
682704
for se in stored_events {
683705
if !event_in_accessible_channel(&se, &accessible_channels) {

crates/buzz-relay/src/handlers/req.rs

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ use crate::state::AppState;
2222
const MAX_HISTORICAL_LIMIT: i64 = 2_000;
2323
const MAX_SUBSCRIPTIONS: usize = 1024;
2424

25+
/// Maximum `query_events` calls in flight per multi-filter REQ / bridge query.
26+
///
27+
/// NIP-01 gives each filter its own DB query (OR semantics — see the comment at
28+
/// the historical-delivery loop). Those queries are independent reads, so they
29+
/// may overlap; this bound keeps one request from monopolising the Postgres
30+
/// pool. Post-processing stays strictly in filter order (`buffered`, not
31+
/// `buffer_unordered`), so dedupe/trace/error semantics are unchanged.
32+
pub(crate) const FILTER_QUERY_CONCURRENCY: usize = 4;
33+
34+
// Guard: keep the bound a small fraction of any sane Postgres pool size.
35+
// Raising it past this range requires re-running the relay bench and
36+
// reconsidering pool contention (see docs above). Compile-time — violating
37+
// the range fails the build.
38+
const _: () = assert!(FILTER_QUERY_CONCURRENCY >= 2 && FILTER_QUERY_CONCURRENCY <= 8);
39+
2540
/// Handle a REQ message: register the subscription, deliver historical events, then send EOSE.
2641
pub async fn handle_req(
2742
sub_id: String,
@@ -242,29 +257,55 @@ pub async fn handle_req(
242257
let mut total_sent: usize = 0;
243258
let viewer_hex = hex::encode(&pubkey_bytes);
244259

245-
for filter in &filters {
246-
// Use per-filter #h channel scope when available, falling back to the
247-
// subscription-level channel_id. This prevents unrelated accessible-channel
248-
// rows from consuming the LIMIT when filters target specific channels but
249-
// the subscription is global (multiple distinct #h values across filters).
250-
let per_filter_channel = {
251-
let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
252-
filter
253-
.generic_tags
254-
.get(&h)
255-
.and_then(|vs| {
256-
if vs.len() == 1 {
257-
vs.iter().next()?.parse::<uuid::Uuid>().ok()
258-
} else {
259-
None
260-
}
261-
})
262-
.or(channel_id)
263-
};
264-
let params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community());
265-
266-
let filter_events = state.db.query_events(&params).await;
260+
// Phase 1 — pure query construction, in filter order.
261+
let filter_queries: Vec<(usize, Option<uuid::Uuid>, EventQuery)> = filters
262+
.iter()
263+
.enumerate()
264+
.map(|(idx, filter)| {
265+
// Use per-filter #h channel scope when available, falling back to the
266+
// subscription-level channel_id. This prevents unrelated accessible-channel
267+
// rows from consuming the LIMIT when filters target specific channels but
268+
// the subscription is global (multiple distinct #h values across filters).
269+
let per_filter_channel = {
270+
let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
271+
filter
272+
.generic_tags
273+
.get(&h)
274+
.and_then(|vs| {
275+
if vs.len() == 1 {
276+
vs.iter().next()?.parse::<uuid::Uuid>().ok()
277+
} else {
278+
None
279+
}
280+
})
281+
.or(channel_id)
282+
};
283+
let params =
284+
filter_to_query_params(filter, per_filter_channel, conn.tenant.community());
285+
(idx, per_filter_channel, params)
286+
})
287+
.collect();
288+
289+
// Phase 2 — DB reads, bounded-concurrent. `buffered` (not `buffer_unordered`)
290+
// yields results in input order, so phase 3 observes filters in their
291+
// original order and NIP-01 dedupe / conformance-trace / error semantics are
292+
// byte-identical to the previous serial loop.
293+
use futures_util::stream::{self, StreamExt};
294+
let db = state.db.clone();
295+
let mut results = stream::iter(filter_queries.into_iter().map(
296+
|(idx, per_filter_channel, params)| {
297+
let db = db.clone();
298+
async move {
299+
let filter_events = db.query_events(&params).await;
300+
(idx, per_filter_channel, filter_events)
301+
}
302+
},
303+
))
304+
.buffered(FILTER_QUERY_CONCURRENCY);
267305

306+
// Phase 3 — post-processing, strictly in filter order.
307+
while let Some((idx, per_filter_channel, filter_events)) = results.next().await {
308+
let filter = &filters[idx];
268309
let events = match filter_events {
269310
Ok(evs) => evs,
270311
Err(e) => {
@@ -1110,6 +1151,40 @@ mod tests {
11101151
use super::*;
11111152
use nostr::{Alphabet, Filter, SingleLetterTag};
11121153

1154+
/// S2 invariant: the bounded-concurrency pipeline (phase 2) must yield
1155+
/// per-filter results in original filter order even when an earlier
1156+
/// filter's DB query completes *after* a later one. `buffered` guarantees
1157+
/// this; `buffer_unordered` would not. If this test fails, NIP-01 dedupe
1158+
/// order (`seen_ids` insertion order = filter order), conformance-trace
1159+
/// row order, and first-error-wins semantics are all broken.
1160+
#[tokio::test]
1161+
async fn filter_query_pipeline_preserves_filter_order() {
1162+
use futures_util::stream::{self, StreamExt};
1163+
1164+
// Simulated per-filter DB latencies: the FIRST filter is the SLOWEST.
1165+
let latencies_ms: Vec<u64> = vec![50, 5, 20, 1, 10, 2];
1166+
let n = latencies_ms.len();
1167+
1168+
let mut results = stream::iter(latencies_ms.into_iter().enumerate().map(
1169+
|(idx, delay_ms)| async move {
1170+
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
1171+
idx
1172+
},
1173+
))
1174+
.buffered(FILTER_QUERY_CONCURRENCY);
1175+
1176+
let mut order = Vec::with_capacity(n);
1177+
while let Some(idx) = results.next().await {
1178+
order.push(idx);
1179+
}
1180+
1181+
assert_eq!(
1182+
order,
1183+
(0..n).collect::<Vec<_>>(),
1184+
"buffered pipeline must preserve input (filter) order regardless of completion order"
1185+
);
1186+
}
1187+
11131188
#[test]
11141189
fn request_local_access_cache_positive_no_db_no_repair() {
11151190
let ch = uuid::Uuid::new_v4();

0 commit comments

Comments
 (0)