@@ -22,6 +22,21 @@ use crate::state::AppState;
2222const MAX_HISTORICAL_LIMIT : i64 = 2_000 ;
2323const 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.
2641pub 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