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};
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    crate::db::scoped(async move {
104        let session = crate::db::current_session();
105        let mut stats = VolleyStats::default();
106        let Some(my_pk) = state::my_public_key() else {
107            return (Vec::new(), stats);
108        };
109
110        // Group targets per community: one DB load each, current-epoch planes only.
111        let mut by_community: Vec<(CommunityId, Vec<PaintTarget>)> = Vec::new();
112        for t in targets {
113            match by_community.iter_mut().find(|(id, _)| *id == t.community_id) {
114                Some((_, v)) => v.push(t),
115                None => by_community.push((t.community_id, vec![t])),
116            }
117        }
118
119        let mut relay_sets: Vec<Vec<String>> = Vec::new();
120        let mut jobs: Vec<Job> = Vec::new();
121        let mut plane_index: HashMap<PublicKey, usize> = HashMap::new();
122        for (cid, ts) in by_community {
123            let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
124            if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
125                continue;
126            }
127            let Ok(Some(community)) = crate::db::community::load_community_v2(&cid) else {
128                continue;
129            };
130            let mut sorted = community.relays.clone();
131            sorted.sort();
132            let relay_set = match relay_sets.iter().position(|r| {
133                let mut s = r.clone();
134                s.sort();
135                s == sorted
136            }) {
137                Some(i) => i,
138                None => {
139                    relay_sets.push(community.relays.clone());
140                    relay_sets.len() - 1
141                }
142            };
143            for t in ts {
144                let ch_id = ChannelId(crate::simd::hex::hex_to_bytes_32(&t.channel_hex));
145                let Some(ch) = community.channel(&ch_id) else { continue };
146                // ONE plane per channel, at the MAX HELD epoch: the community
147                // row's epoch fields can lag a rotation the rekey walk already
148                // archived, so "current" means the freshest key the DB holds.
149                // Older epochs stay the history-pagination system's job.
150                let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
151                let (group, epoch) = if ch.private {
152                    let mut best: Option<(crate::community::Epoch, [u8; 32])> =
153                        ch.key.map(|k| (ch.epoch, k));
154                    for (ep, k) in
155                        crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default()
156                    {
157                        // A private plane is never derived from the root value.
158                        if k == community.community_root {
159                            continue;
160                        }
161                        if best.map_or(true, |(be, _)| ep.0 > be.0) {
162                            best = Some((ep, k));
163                        }
164                    }
165                    let Some((ep, key)) = best else { continue };
166                    (channel_group_key(&key, &ch_id, ep), ep)
167                } else {
168                    let mut best = (community.root_epoch, community.community_root);
169                    for (ep, k) in crate::db::community::held_epoch_keys(
170                        &cid_hex,
171                        crate::community::SERVER_ROOT_SCOPE_HEX,
172                    )
173                    .unwrap_or_default()
174                    {
175                        if ep.0 > best.0 .0 {
176                            best = (ep, k);
177                        }
178                    }
179                    (channel_group_key(&best.1, &ch_id, best.0), best.0)
180                };
181                // A duplicated target would orphan the first job (unroutable) and
182                // burn a fallback dial — first derivation wins.
183                if plane_index.contains_key(&group.pk()) {
184                    continue;
185                }
186                plane_index.insert(group.pk(), jobs.len());
187                jobs.push(Job {
188                    channel_hex: t.channel_hex,
189                    channel_id: ch_id,
190                    group,
191                    epoch,
192                    since: t.since,
193                    relay_set,
194                });
195            }
196        }
197        if jobs.is_empty() {
198            return (Vec::new(), stats);
199        }
200
201        // One multi-filter REQ per ≤BATCH_FILTERS jobs per relay, all concurrent,
202        // all on the shared warm client. Callers pass targets recency-first and
203        // job order preserves it, so the hottest channels ride the first batches.
204        let mut by_set: HashMap<usize, Vec<usize>> = HashMap::new();
205        for (i, j) in jobs.iter().enumerate() {
206            by_set.entry(j.relay_set).or_default().push(i);
207        }
208        let batch_start = std::time::Instant::now();
209        // Register every job's plane key BEFORE any relay contact: gating relays
210        // serve a multi-author REQ only when EVERY author is authed on the
211        // connection (proven live: all-authed → EOSE; partial → CLOSED), and the
212        // responder auths exactly the registered set.
213        crate::community::v2::streamauth::register(jobs.iter().map(|j| j.group.keys().clone()));
214        let shared = LiveTransport::warm_client(
215            relay_sets.iter().flat_map(|r| r.iter().cloned()).collect::<Vec<_>>().as_slice(),
216            std::time::Duration::from_secs(4),
217        )
218        .await
219        .ok();
220
221        // Per-set pipelines: each set gates on its own first live socket, then
222        // fires its filter chunks — independent, so a dead-only set's allowance
223        // never holds another set's filters hostage.
224        let fetch_budget = crate::relay_request_timeout(std::time::Duration::from_secs(4));
225        let mut fetches = FuturesUnordered::new();
226        for (set, idxs) in by_set {
227            let chunks: Vec<Vec<Filter>> = idxs
228                .chunks(BATCH_FILTERS)
229                .map(|chunk| {
230                    chunk
231                        .iter()
232                        .map(|&i| {
233                            let j = &jobs[i];
234                            Query {
235                                kinds: vec![stream::KIND_WRAP],
236                                authors: vec![j.group.pk_hex()],
237                                since: j.since,
238                                limit: Some(50),
239                                ..Default::default()
240                            }
241                            .to_filter()
242                        })
243                        .collect()
244                })
245                .collect();
246            let relays = relay_sets[set].clone();
247            let client = shared.clone();
248            fetches.push(async move {
249                let Some(client) = client else {
250                    return (set, Vec::new(), Vec::new(), false);
251                };
252                let live =
253                    connected_targets(&client, &relays, std::time::Duration::from_millis(2500)).await;
254                if live.is_empty() {
255                    return (set, live, Vec::new(), false);
256                }
257                // Prime the AUTH gate on live gating relays so the mass batch is
258                // served there too — priming must be COMPLETE before the REQ (one
259                // unauthenticated plane fails the whole filter set).
260                let gating_live: Vec<String> = live
261                    .iter()
262                    .filter(|r| relay_auth_gating(r))
263                    .cloned()
264                    .collect();
265                if !gating_live.is_empty() {
266                    crate::community::v2::streamauth::prime_auth(&client, &gating_live).await;
267                }
268                let mut evs: Vec<Event> = Vec::new();
269                // Bounded width: chunk×relay all-at-once can blow past a relay's
270                // subscription cap (strfry default 20) alongside the DM walk,
271                // realtime subs, and the hot lane — overflow CLOSEs read as
272                // coverage failure and trigger the very fallback this avoids.
273                let client_ref = &client;
274                let mut per = futures_util::stream::iter(chunks.iter().flat_map(|chunk| {
275                    live.iter().map(move |r| {
276                        let c = client_ref.clone();
277                        let f = chunk.clone();
278                        let r = r.clone();
279                        async move {
280                            let res = crate::community::transport::fetch_relay_eose_filters(
281                                &c, &r, f, fetch_budget,
282                            )
283                            .await;
284                            (r, res)
285                        }
286                    })
287                }).collect::<Vec<_>>())
288                .buffer_unordered(6);
289                // The set is COVERED only when EVERY live relay EOSE'd every
290                // chunk — then batch silence is authoritative everywhere. One
291                // open relay's EOSE must not mask a gating relay whose priming
292                // failed (its CLOSED hides events only it holds); any shortfall
293                // keeps the per-plane fallback in play for this set.
294                let mut ok_chunks: HashMap<String, usize> = HashMap::new();
295                while let Some((r, res)) = per.next().await {
296                    if let Ok(batch) = res {
297                        *ok_chunks.entry(r).or_insert(0) += 1;
298                        evs.extend(batch);
299                    }
300                }
301                let covered = !live.is_empty()
302                    && live
303                        .iter()
304                        .all(|r| ok_chunks.get(r).copied().unwrap_or(0) == chunks.len());
305                (set, live, evs, covered)
306            });
307        }
308
309        // Route each wrap to its job by plane author as sets complete, and
310        // INGEST each set's pages immediately — the hottest channels paint the
311        // moment their batch lands instead of waiting out the slowest set and
312        // the fallback stage.
313        let mut live_by_set: HashMap<usize, Vec<String>> = HashMap::new();
314        let mut covered_sets: HashSet<usize> = HashSet::new();
315        let mut seen_wraps: HashSet<EventId> = HashSet::new();
316        let mut ingested: HashSet<usize> = HashSet::new();
317        let mut painted: Vec<(String, usize)> = Vec::new();
318        while let Some((set, live, evs, covered)) = fetches.next().await {
319            // Each set is a page to open and persist. Once the user has moved
320            // on there is nobody to paint for, so stop rather than decrypt the
321            // rest of the boot for a screen that is gone.
322            if session.stopped() {
323                break;
324            }
325            live_by_set.insert(set, live);
326            if covered {
327                covered_sets.insert(set);
328            }
329            let mut pages: HashMap<usize, Vec<FetchedEvent>> = HashMap::new();
330            for wrap in evs {
331                if !seen_wraps.insert(wrap.id) {
332                    continue;
333                }
334                let Some(&job_idx) = plane_index.get(&wrap.pubkey) else { continue };
335                let j = &jobs[job_idx];
336                if let Ok(event) = chat::open_chat_event(&wrap, &j.group, &j.channel_id, j.epoch) {
337                    stats.batch_events += 1;
338                    pages.entry(job_idx).or_default().push(FetchedEvent { event, epoch: j.epoch });
339                }
340            }
341            for (job_idx, mut page) in pages {
342                page.sort_by_key(|f| f.event.opened().at_ms);
343                let new = crate::VectorCore::v2_ingest_chat_page(
344                    &jobs[job_idx].channel_hex,
345                    my_pk,
346                    session.clone(),
347                    page,
348                )
349                .await;
350                ingested.insert(job_idx);
351                if new > 0 {
352                    painted.push((jobs[job_idx].channel_hex.clone(), new));
353                }
354            }
355        }
356        stats.batch_ms = batch_start.elapsed().as_millis();
357        let fallback_start = std::time::Instant::now();
358
359        // Second barrel: jobs the batch couldn't see. When the only LIVE relay in
360        // a set is auth-gating (Ditto serves plane reads solely to a connection
361        // authed AS the plane), batches get protocol-correct silence — fetch_plane
362        // pays the per-plane authed connection through its pool instead. Quiet
363        // channels cost one pooled round trip; the breaker keeps dead relays from
364        // taxing the warmups.
365        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(4));
366        // One KV read per relay, not three per missed job.
367        let gating: HashSet<String> = live_by_set
368            .values()
369            .flat_map(|v| v.iter())
370            .filter(|r| relay_auth_gating(r))
371            .cloned()
372            .collect();
373        let missed: Vec<(usize, nostr_sdk::prelude::Keys, Query, Vec<String>)> = jobs
374            .iter()
375            .enumerate()
376            .filter(|(idx, j)| {
377                if ingested.contains(idx) {
378                    return false;
379                }
380                // A covered set's batch silence IS the answer (a relay we authed
381                // against EOSE'd every chunk): quiet channel, nothing to confirm.
382                if covered_sets.contains(&j.relay_set) {
383                    return false;
384                }
385                // NO live relay: nothing can answer at any price — the reconnect
386                // catch-up owns the channel when its relays return.
387                let live = live_by_set.get(&j.relay_set).map(Vec::as_slice).unwrap_or(&[]);
388                if live.is_empty() {
389                    return false;
390                }
391                // Every live relay gates: batch silence proved nothing.
392                if live.iter().all(|r| gating.contains(r.as_str())) {
393                    return true;
394                }
395                // A live OPEN relay answered with silence — usually a quiet
396                // channel, but a flaky relay can hold HOLES (missed publishes),
397                // so recently-active channels still confirm against a live gating
398                // relay. Dormant ones trust the batch.
399                const RECENT_SECS: u64 = 7 * 24 * 3600;
400                let now = std::time::SystemTime::now()
401                    .duration_since(std::time::UNIX_EPOCH)
402                    .map(|d| d.as_secs())
403                    .unwrap_or(0);
404                j.since.is_some_and(|s| now.saturating_sub(s) < RECENT_SECS)
405                    && live.iter().any(|r| gating.contains(r.as_str()))
406            })
407            .map(|(idx, j)| {
408                let q = Query {
409                    kinds: vec![stream::KIND_WRAP],
410                    authors: vec![j.group.pk_hex()],
411                    since: j.since,
412                    limit: Some(50),
413                    // Declared intent — fetch_plane does not consult evidence
414                    // yet (#370); its 4s transport bound is the effective limit.
415                    evidence: Evidence::Fast,
416                    ..Default::default()
417                };
418                // Confirmations go to the gating relays only — the open ones
419                // already answered this job in the batch.
420                let live = live_by_set.get(&j.relay_set).map(Vec::as_slice).unwrap_or(&[]);
421                let gate_targets: Vec<String> =
422                    live.iter().filter(|r| gating.contains(r.as_str())).cloned().collect();
423                let targets = if gate_targets.is_empty() { live.to_vec() } else { gate_targets };
424                (idx, j.group.keys().clone(), q, targets)
425            })
426            .collect();
427        let fallback_pages: Vec<(usize, Vec<Event>)> = futures_util::stream::iter(missed)
428            .map(|(idx, keys, q, relays)| {
429                let t = &transport;
430                async move { (idx, t.fetch_plane(&keys, &q, &relays).await.unwrap_or_default()) }
431            })
432            .buffer_unordered(24)
433            .collect()
434            .await;
435        let mut fb_pages: HashMap<usize, Vec<FetchedEvent>> = HashMap::new();
436        for (job_idx, evs) in fallback_pages {
437            if session.stopped() {
438                break;
439            }
440            let j = &jobs[job_idx];
441            for wrap in evs {
442                if !seen_wraps.insert(wrap.id) {
443                    continue;
444                }
445                if wrap.pubkey != j.group.pk() {
446                    continue;
447                }
448                if let Ok(event) = chat::open_chat_event(&wrap, &j.group, &j.channel_id, j.epoch) {
449                    stats.fallback_events += 1;
450                    fb_pages.entry(job_idx).or_default().push(FetchedEvent { event, epoch: j.epoch });
451                }
452            }
453        }
454        for (job_idx, mut page) in fb_pages {
455            page.sort_by_key(|f| f.event.opened().at_ms);
456            let new = crate::VectorCore::v2_ingest_chat_page(
457                &jobs[job_idx].channel_hex,
458                my_pk,
459                session.clone(),
460                page,
461            )
462            .await;
463            if new > 0 {
464                painted.push((jobs[job_idx].channel_hex.clone(), new));
465            }
466        }
467        stats.fallback_ms = fallback_start.elapsed().as_millis();
468        (painted, stats)
469    })
470    .await
471}