Skip to main content

vector_core/community/v2/
volley.rs

1//! Chat-plane volley — the boot paint pass (CONCORD_CHAT_PLANE_VOLLEY_DESIGN.md).
2//!
3//! Paints every channel's CURRENT-epoch chat plane from stored state in a few
4//! batched multi-filter REQs on the shared warm community client. No per-plane
5//! clients, no NIP-42 warmups, no dial storms: a cold `fetch_plane` per plane
6//! pays a connect + per-relay warmup gauntlet, which is how a 47-channel boot
7//! became a 36s crawl. Auth-gating relays contribute nothing to a batch (they
8//! CLOSE REQs whose authors aren't the connection's authed key); the planes
9//! also live on the non-gating relays, and anything held ONLY by a gating
10//! relay still arrives via the verification sweep behind this.
11//!
12//! Non-authoritative by design: chat planes carry nothing that needs verifying
13//! before display. The control plane folds AFTER paint and enforces
14//! retroactively (retro-hide revokes a banned member's painted rows). Epochs
15//! rotated while offline make these filters return silence — never lies — and
16//! the rekey walk behind this repaints the channel under its new epoch.
17
18use std::collections::{HashMap, HashSet};
19
20use futures_util::stream::{FuturesUnordered, StreamExt};
21use nostr_sdk::prelude::*;
22
23use crate::community::transport::{Evidence, LiveTransport, Query, Transport};
24use crate::community::v2::derive::{channel_group_key, GroupKey};
25use crate::community::v2::service::FetchedEvent;
26use crate::community::v2::{chat, stream};
27use crate::community::{ChannelId, CommunityId, Epoch};
28use crate::state::{self, SessionGuard};
29
30/// One channel to paint. `since` = the chat's last held message (seconds,
31/// minus the caller's slack) so the page carries only genuinely-new wraps.
32pub struct PaintTarget {
33    pub community_id: CommunityId,
34    pub channel_hex: String,
35    pub since: Option<u64>,
36}
37
38/// Filters per REQ — comfortably under every relay's max_filters cap.
39const BATCH_FILTERS: usize = 20;
40
41struct Job {
42    channel_hex: String,
43    channel_id: ChannelId,
44    group: GroupKey,
45    epoch: Epoch,
46    since: Option<u64>,
47    relay_set: usize,
48}
49
50/// Learned from live NIP-42 challenges (streamauth records the KV): a gating
51/// relay answers batch REQs with silence no matter what it holds.
52fn relay_auth_gating(url: &str) -> bool {
53    crate::db::get_sql_setting(format!(
54        "auth_gate:{}",
55        crate::inbox_relays::normalize_relay_url(url)
56    ))
57    .ok()
58    .flatten()
59    .is_some()
60}
61
62/// The subset of `relays` currently CONNECTED on the shared client, waiting up
63/// to `allowance` for the first one — cold boots dial these sockets moments
64/// before the volley needs them.
65async fn connected_targets(
66    client: &Client,
67    relays: &[String],
68    allowance: std::time::Duration,
69) -> Vec<String> {
70    let deadline = tokio::time::Instant::now() + allowance;
71    loop {
72        let pool = client.relays().await;
73        let up: Vec<String> = relays
74            .iter()
75            .filter(|r| {
76                RelayUrl::parse(r)
77                    .ok()
78                    .and_then(|u| pool.get(&u).map(|rl| rl.status() == RelayStatus::Connected))
79                    .unwrap_or(false)
80            })
81            .cloned()
82            .collect();
83        if !up.is_empty() || tokio::time::Instant::now() >= deadline {
84            return up;
85        }
86        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
87    }
88}
89
90/// Stage timing + hit counts for the boot log — the volley is a performance
91/// feature, and every regression so far was found by reading these.
92#[derive(Default)]
93pub struct VolleyStats {
94    pub batch_ms: u128,
95    pub fallback_ms: u128,
96    pub batch_events: usize,
97    pub fallback_events: usize,
98}
99
100/// Paint every target's latest page. Returns `(channel_hex, new)` per channel
101/// that gained messages (callers own the logging) plus stage stats.
102pub async fn paint_all(targets: Vec<PaintTarget>) -> (Vec<(String, usize)>, VolleyStats) {
103    let session = SessionGuard::capture();
104    let mut stats = VolleyStats::default();
105    let Some(my_pk) = state::my_public_key() else {
106        return (Vec::new(), stats);
107    };
108
109    // Group targets per community: one DB load each, current-epoch planes only.
110    let mut by_community: Vec<(CommunityId, Vec<PaintTarget>)> = Vec::new();
111    for t in targets {
112        match by_community.iter_mut().find(|(id, _)| *id == t.community_id) {
113            Some((_, v)) => v.push(t),
114            None => by_community.push((t.community_id, vec![t])),
115        }
116    }
117
118    let mut relay_sets: Vec<Vec<String>> = Vec::new();
119    let mut jobs: Vec<Job> = Vec::new();
120    let mut plane_index: HashMap<PublicKey, usize> = HashMap::new();
121    for (cid, ts) in by_community {
122        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
123        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
124            continue;
125        }
126        let Ok(Some(community)) = crate::db::community::load_community_v2(&cid) else {
127            continue;
128        };
129        let mut sorted = community.relays.clone();
130        sorted.sort();
131        let relay_set = match relay_sets.iter().position(|r| {
132            let mut s = r.clone();
133            s.sort();
134            s == sorted
135        }) {
136            Some(i) => i,
137            None => {
138                relay_sets.push(community.relays.clone());
139                relay_sets.len() - 1
140            }
141        };
142        for t in ts {
143            let ch_id = ChannelId(crate::simd::hex::hex_to_bytes_32(&t.channel_hex));
144            let Some(ch) = community.channel(&ch_id) else { continue };
145            // ONE plane per channel, at the MAX HELD epoch: the community
146            // row's epoch fields can lag a rotation the rekey walk already
147            // archived, so "current" means the freshest key the DB holds.
148            // Older epochs stay the history-pagination system's job.
149            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
150            let (group, epoch) = if ch.private {
151                let mut best: Option<(crate::community::Epoch, [u8; 32])> =
152                    ch.key.map(|k| (ch.epoch, k));
153                for (ep, k) in
154                    crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default()
155                {
156                    // A private plane is never derived from the root value.
157                    if k == community.community_root {
158                        continue;
159                    }
160                    if best.map_or(true, |(be, _)| ep.0 > be.0) {
161                        best = Some((ep, k));
162                    }
163                }
164                let Some((ep, key)) = best else { continue };
165                (channel_group_key(&key, &ch_id, ep), ep)
166            } else {
167                let mut best = (community.root_epoch, community.community_root);
168                for (ep, k) in crate::db::community::held_epoch_keys(
169                    &cid_hex,
170                    crate::community::SERVER_ROOT_SCOPE_HEX,
171                )
172                .unwrap_or_default()
173                {
174                    if ep.0 > best.0 .0 {
175                        best = (ep, k);
176                    }
177                }
178                (channel_group_key(&best.1, &ch_id, best.0), best.0)
179            };
180            // A duplicated target would orphan the first job (unroutable) and
181            // burn a fallback dial — first derivation wins.
182            if plane_index.contains_key(&group.pk()) {
183                continue;
184            }
185            plane_index.insert(group.pk(), jobs.len());
186            jobs.push(Job {
187                channel_hex: t.channel_hex,
188                channel_id: ch_id,
189                group,
190                epoch,
191                since: t.since,
192                relay_set,
193            });
194        }
195    }
196    if jobs.is_empty() {
197        return (Vec::new(), stats);
198    }
199
200    // One multi-filter REQ per ≤BATCH_FILTERS jobs per relay, all concurrent,
201    // all on the shared warm client. Callers pass targets recency-first and
202    // job order preserves it, so the hottest channels ride the first batches.
203    let mut by_set: HashMap<usize, Vec<usize>> = HashMap::new();
204    for (i, j) in jobs.iter().enumerate() {
205        by_set.entry(j.relay_set).or_default().push(i);
206    }
207    let batch_start = std::time::Instant::now();
208    // Register every job's plane key BEFORE any relay contact: gating relays
209    // serve a multi-author REQ only when EVERY author is authed on the
210    // connection (proven live: all-authed → EOSE; partial → CLOSED), and the
211    // responder auths exactly the registered set.
212    crate::community::v2::streamauth::register(jobs.iter().map(|j| j.group.keys().clone()));
213    let shared = LiveTransport::warm_client(
214        relay_sets.iter().flat_map(|r| r.iter().cloned()).collect::<Vec<_>>().as_slice(),
215        std::time::Duration::from_secs(4),
216    )
217    .await
218    .ok();
219
220    // Per-set pipelines: each set gates on its own first live socket, then
221    // fires its filter chunks — independent, so a dead-only set's allowance
222    // never holds another set's filters hostage.
223    let fetch_budget = crate::relay_request_timeout(std::time::Duration::from_secs(4));
224    let mut fetches = FuturesUnordered::new();
225    for (set, idxs) in by_set {
226        let chunks: Vec<Vec<Filter>> = idxs
227            .chunks(BATCH_FILTERS)
228            .map(|chunk| {
229                chunk
230                    .iter()
231                    .map(|&i| {
232                        let j = &jobs[i];
233                        Query {
234                            kinds: vec![stream::KIND_WRAP],
235                            authors: vec![j.group.pk_hex()],
236                            since: j.since,
237                            limit: Some(50),
238                            ..Default::default()
239                        }
240                        .to_filter()
241                    })
242                    .collect()
243            })
244            .collect();
245        let relays = relay_sets[set].clone();
246        let client = shared.clone();
247        fetches.push(async move {
248            let Some(client) = client else {
249                return (set, Vec::new(), Vec::new(), false);
250            };
251            let live =
252                connected_targets(&client, &relays, std::time::Duration::from_millis(2500)).await;
253            if live.is_empty() {
254                return (set, live, Vec::new(), false);
255            }
256            // Prime the AUTH gate on live gating relays so the mass batch is
257            // served there too — priming must be COMPLETE before the REQ (one
258            // unauthenticated plane fails the whole filter set).
259            let gating_live: Vec<String> = live
260                .iter()
261                .filter(|r| relay_auth_gating(r))
262                .cloned()
263                .collect();
264            if !gating_live.is_empty() {
265                crate::community::v2::streamauth::prime_auth(&client, &gating_live).await;
266            }
267            let mut evs: Vec<Event> = Vec::new();
268            // Bounded width: chunk×relay all-at-once can blow past a relay's
269            // subscription cap (strfry default 20) alongside the DM walk,
270            // realtime subs, and the hot lane — overflow CLOSEs read as
271            // coverage failure and trigger the very fallback this avoids.
272            let client_ref = &client;
273            let mut per = futures_util::stream::iter(chunks.iter().flat_map(|chunk| {
274                live.iter().map(move |r| {
275                    let c = client_ref.clone();
276                    let f = chunk.clone();
277                    let r = r.clone();
278                    async move {
279                        let res = crate::community::transport::fetch_relay_eose_filters(
280                            &c, &r, f, fetch_budget,
281                        )
282                        .await;
283                        (r, res)
284                    }
285                })
286            }).collect::<Vec<_>>())
287            .buffer_unordered(6);
288            // The set is COVERED only when EVERY live relay EOSE'd every
289            // chunk — then batch silence is authoritative everywhere. One
290            // open relay's EOSE must not mask a gating relay whose priming
291            // failed (its CLOSED hides events only it holds); any shortfall
292            // keeps the per-plane fallback in play for this set.
293            let mut ok_chunks: HashMap<String, usize> = HashMap::new();
294            while let Some((r, res)) = per.next().await {
295                if let Ok(batch) = res {
296                    *ok_chunks.entry(r).or_insert(0) += 1;
297                    evs.extend(batch);
298                }
299            }
300            let covered = !live.is_empty()
301                && live
302                    .iter()
303                    .all(|r| ok_chunks.get(r).copied().unwrap_or(0) == chunks.len());
304            (set, live, evs, covered)
305        });
306    }
307
308    // Route each wrap to its job by plane author as sets complete, and
309    // INGEST each set's pages immediately — the hottest channels paint the
310    // moment their batch lands instead of waiting out the slowest set and
311    // the fallback stage.
312    let mut live_by_set: HashMap<usize, Vec<String>> = HashMap::new();
313    let mut covered_sets: HashSet<usize> = HashSet::new();
314    let mut seen_wraps: HashSet<EventId> = HashSet::new();
315    let mut ingested: HashSet<usize> = HashSet::new();
316    let mut painted: Vec<(String, usize)> = Vec::new();
317    while let Some((set, live, evs, covered)) = fetches.next().await {
318        live_by_set.insert(set, live);
319        if covered {
320            covered_sets.insert(set);
321        }
322        let mut pages: HashMap<usize, Vec<FetchedEvent>> = HashMap::new();
323        for wrap in evs {
324            if !seen_wraps.insert(wrap.id) {
325                continue;
326            }
327            let Some(&job_idx) = plane_index.get(&wrap.pubkey) else { continue };
328            let j = &jobs[job_idx];
329            if let Ok(event) = chat::open_chat_event(&wrap, &j.group, &j.channel_id, j.epoch) {
330                stats.batch_events += 1;
331                pages.entry(job_idx).or_default().push(FetchedEvent { event, epoch: j.epoch });
332            }
333        }
334        for (job_idx, mut page) in pages {
335            if !session.is_valid() {
336                return (painted, stats);
337            }
338            page.sort_by_key(|f| f.event.opened().at_ms);
339            let new = crate::VectorCore::v2_ingest_chat_page(
340                &jobs[job_idx].channel_hex,
341                my_pk,
342                session,
343                page,
344            )
345            .await;
346            ingested.insert(job_idx);
347            if new > 0 {
348                painted.push((jobs[job_idx].channel_hex.clone(), new));
349            }
350        }
351    }
352    stats.batch_ms = batch_start.elapsed().as_millis();
353    let fallback_start = std::time::Instant::now();
354
355    // Second barrel: jobs the batch couldn't see. When the only LIVE relay in
356    // a set is auth-gating (Ditto serves plane reads solely to a connection
357    // authed AS the plane), batches get protocol-correct silence — fetch_plane
358    // pays the per-plane authed connection through its pool instead. Quiet
359    // channels cost one pooled round trip; the breaker keeps dead relays from
360    // taxing the warmups.
361    let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(4));
362    // One KV read per relay, not three per missed job.
363    let gating: HashSet<String> = live_by_set
364        .values()
365        .flat_map(|v| v.iter())
366        .filter(|r| relay_auth_gating(r))
367        .cloned()
368        .collect();
369    let missed: Vec<(usize, nostr_sdk::prelude::Keys, Query, Vec<String>)> = jobs
370        .iter()
371        .enumerate()
372        .filter(|(idx, j)| {
373            if ingested.contains(idx) {
374                return false;
375            }
376            // A covered set's batch silence IS the answer (a relay we authed
377            // against EOSE'd every chunk): quiet channel, nothing to confirm.
378            if covered_sets.contains(&j.relay_set) {
379                return false;
380            }
381            // NO live relay: nothing can answer at any price — the reconnect
382            // catch-up owns the channel when its relays return.
383            let live = live_by_set.get(&j.relay_set).map(Vec::as_slice).unwrap_or(&[]);
384            if live.is_empty() {
385                return false;
386            }
387            // Every live relay gates: batch silence proved nothing.
388            if live.iter().all(|r| gating.contains(r.as_str())) {
389                return true;
390            }
391            // A live OPEN relay answered with silence — usually a quiet
392            // channel, but a flaky relay can hold HOLES (missed publishes),
393            // so recently-active channels still confirm against a live gating
394            // relay. Dormant ones trust the batch.
395            const RECENT_SECS: u64 = 7 * 24 * 3600;
396            let now = std::time::SystemTime::now()
397                .duration_since(std::time::UNIX_EPOCH)
398                .map(|d| d.as_secs())
399                .unwrap_or(0);
400            j.since.is_some_and(|s| now.saturating_sub(s) < RECENT_SECS)
401                && live.iter().any(|r| gating.contains(r.as_str()))
402        })
403        .map(|(idx, j)| {
404            let q = Query {
405                kinds: vec![stream::KIND_WRAP],
406                authors: vec![j.group.pk_hex()],
407                since: j.since,
408                limit: Some(50),
409                // Declared intent — fetch_plane does not consult evidence
410                // yet (#370); its 4s transport bound is the effective limit.
411                evidence: Evidence::Fast,
412                ..Default::default()
413            };
414            // Confirmations go to the gating relays only — the open ones
415            // already answered this job in the batch.
416            let live = live_by_set.get(&j.relay_set).map(Vec::as_slice).unwrap_or(&[]);
417            let gate_targets: Vec<String> =
418                live.iter().filter(|r| gating.contains(r.as_str())).cloned().collect();
419            let targets = if gate_targets.is_empty() { live.to_vec() } else { gate_targets };
420            (idx, j.group.keys().clone(), q, targets)
421        })
422        .collect();
423    let fallback_pages: Vec<(usize, Vec<Event>)> = futures_util::stream::iter(missed)
424        .map(|(idx, keys, q, relays)| {
425            let t = &transport;
426            async move { (idx, t.fetch_plane(&keys, &q, &relays).await.unwrap_or_default()) }
427        })
428        .buffer_unordered(24)
429        .collect()
430        .await;
431    let mut fb_pages: HashMap<usize, Vec<FetchedEvent>> = HashMap::new();
432    for (job_idx, evs) in fallback_pages {
433        let j = &jobs[job_idx];
434        for wrap in evs {
435            if !seen_wraps.insert(wrap.id) {
436                continue;
437            }
438            if wrap.pubkey != j.group.pk() {
439                continue;
440            }
441            if let Ok(event) = chat::open_chat_event(&wrap, &j.group, &j.channel_id, j.epoch) {
442                stats.fallback_events += 1;
443                fb_pages.entry(job_idx).or_default().push(FetchedEvent { event, epoch: j.epoch });
444            }
445        }
446    }
447    for (job_idx, mut page) in fb_pages {
448        if !session.is_valid() {
449            break;
450        }
451        page.sort_by_key(|f| f.event.opened().at_ms);
452        let new = crate::VectorCore::v2_ingest_chat_page(
453            &jobs[job_idx].channel_hex,
454            my_pk,
455            session,
456            page,
457        )
458        .await;
459        if new > 0 {
460            painted.push((jobs[job_idx].channel_hex.clone(), new));
461        }
462    }
463    stats.fallback_ms = fallback_start.elapsed().as_millis();
464    (painted, stats)
465}