This commit is contained in:
2026-09-22 14:50:15 +07:00
parent 8914685c3d
commit 04fb70e657
7 changed files with 926 additions and 256 deletions
+38
View File
@@ -82,6 +82,44 @@ pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp)
Ok(purged) Ok(purged)
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Observed {
pub author: PublicKey,
pub at_ms: u64,
}
/// The cached rumors of `channel`, keyed by the wrap they were opened from.
pub async fn wrapper_index(
client: &Client,
channel: &ChannelId,
) -> Result<BTreeMap<EventId, Observed>> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut index = BTreeMap::new();
for event in client.database().query(filter).await? {
let (Some(wrapper_id), Some(author)) = (
event.tags.event_ids().next(),
event.tags.public_keys().next(),
) else {
continue;
};
index.insert(
wrapper_id,
Observed {
author,
at_ms: event.created_at.as_secs().saturating_mul(1000),
},
);
}
Ok(index)
}
/// Cached rumors for `channel`, newest first, deduplicated by rumor id. /// Cached rumors for `channel`, newest first, deduplicated by rumor id.
pub async fn query_rumors( pub async fn query_rumors(
client: &Client, client: &Client,
+31 -10
View File
@@ -13,7 +13,7 @@ use nostr_sdk::prelude::*;
use state::NostrRegistry; use state::NostrRegistry;
use crate::cache; use crate::cache;
use crate::history::{self, Window, WrapPage}; use crate::history::{self, PageRegistry, Window, WrapPage};
use crate::sync::{self, Snapshot}; use crate::sync::{self, Snapshot};
/// Wraps one relay returns for one page request. /// Wraps one relay returns for one page request.
@@ -102,7 +102,6 @@ pub struct Community {
state: CommunityState, state: CommunityState,
control: ControlFold, control: ControlFold,
members: BTreeSet<PublicKey>, members: BTreeSet<PublicKey>,
/// The channel the sidebar and panel show, once the user has picked one
active: Option<ChannelId>, active: Option<ChannelId>,
icon: Option<PathBuf>, icon: Option<PathBuf>,
icon_ref: Option<ImageRef>, icon_ref: Option<ImageRef>,
@@ -113,12 +112,13 @@ pub struct Community {
icon_task: Option<Task<Result<()>>>, icon_task: Option<Task<Result<()>>>,
banner_task: Option<Task<Result<()>>>, banner_task: Option<Task<Result<()>>>,
rounds: HashMap<ChannelId, Round>, rounds: HashMap<ChannelId, Round>,
pages: PageRegistry,
} }
impl EventEmitter<CommunityEvent> for Community {} impl EventEmitter<CommunityEvent> for Community {}
impl Community { impl Community {
pub fn new(state: CommunityState) -> Self { pub fn new(state: CommunityState, pages: PageRegistry) -> Self {
Self { Self {
state, state,
control: ControlFold::default(), control: ControlFold::default(),
@@ -133,6 +133,7 @@ impl Community {
icon_task: None, icon_task: None,
banner_task: None, banner_task: None,
rounds: HashMap::new(), rounds: HashMap::new(),
pages,
} }
} }
@@ -262,6 +263,7 @@ impl Community {
let client = NostrRegistry::global(cx).read(cx).client(); let client = NostrRegistry::global(cx).read(cx).client();
let held = self.held_keys(&channel); let held = self.held_keys(&channel);
let relays = self.state.relays.clone(); let relays = self.state.relays.clone();
let pages = self.pages.clone();
let saved = self let saved = self
.state .state
@@ -276,7 +278,7 @@ impl Community {
} }
let round = cx.background_spawn(async move { let round = cx.background_spawn(async move {
sync_round(&client, &channel, &held, &relays, saved, intent).await sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await
}); });
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
@@ -605,6 +607,7 @@ type RoundOutcome = (Progress, ChannelCursor);
/// Read a channel's history from the community's relays, in three passes. /// Read a channel's history from the community's relays, in three passes.
async fn sync_round( async fn sync_round(
client: &Client, client: &Client,
pages: &PageRegistry,
channel: &ChannelId, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], held: &[(Epoch, [u8; 32])],
relays: &[RelayUrl], relays: &[RelayUrl],
@@ -614,14 +617,25 @@ async fn sync_round(
let mut progress = Progress::default(); let mut progress = Progress::default();
let mut round = ChannelCursor::default(); let mut round = ChannelCursor::default();
// Expired rows drop at fold time otherwise, never from disk.
let purged = cache::purge_expired(client, channel, Timestamp::now()).await?;
if purged > 0 {
log::debug!(
"community: purged {purged} expired rumor(s) from {}",
channel.to_hex()
);
}
let newest = match intent { let newest = match intent {
Intent::CatchUp => { Intent::CatchUp => {
let page = history::page( let page = history::page(
client, client,
pages,
channel, channel,
held, held,
relays, relays,
Window::newest(), Window::opening(saved),
1, 1,
PAGE_WRAPS, PAGE_WRAPS,
) )
@@ -636,6 +650,7 @@ async fn sync_round(
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => { (Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
let page = history::page( let page = history::page(
client, client,
pages,
channel, channel,
held, held,
relays, relays,
@@ -659,27 +674,33 @@ async fn sync_round(
Intent::Older { .. } => saved.oldest_ms, Intent::Older { .. } => saved.oldest_ms,
}; };
let pages = match intent { let budget = match intent {
Intent::CatchUp => CATCH_UP_PAGES, Intent::CatchUp => CATCH_UP_PAGES,
Intent::Older { pages } => pages, Intent::Older { pages } => pages,
}; };
let older = match resume { // A channel already swept to the bottom has nothing older to ask for.
Some(until) => { let older = match (saved.exhausted, resume) {
(true, _) => WrapPage {
exhausted: true,
..WrapPage::default()
},
(false, Some(until)) => {
let page = history::page( let page = history::page(
client, client,
pages,
channel, channel,
held, held,
relays, relays,
Window::older_than(until), Window::older_than(until),
pages, budget,
PAGE_WRAPS, PAGE_WRAPS,
) )
.await?; .await?;
absorb(&mut progress, &page); absorb(&mut progress, &page);
page page
} }
None => WrapPage::default(), (false, None) => WrapPage::default(),
}; };
if intent == Intent::CatchUp { if intent == Intent::CatchUp {
+213 -67
View File
@@ -1,19 +1,24 @@
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::future::Future; use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{Result, anyhow}; use anyhow::Result;
use concord::cord01::KIND_WRAP_EPHEMERAL; use concord::cord01::KIND_WRAP_EPHEMERAL;
use concord::cord03::{self, ChatRumor, plane_keys}; use concord::cord03::{self, ChatRumor, plane_keys};
use concord::state::ChannelCursor;
use concord::{ChannelId, Epoch}; use concord::{ChannelId, Epoch};
use futures::future::{Either, join_all, select}; use futures::future::{Either, select};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cache::cache_rumor; use crate::cache::cache_rumor;
use crate::sync::connect_relays;
/// How long one relay is given to answer one page of history. /// How long one relay is given to answer one page of history.
const PAGE_TIMEOUT: Duration = Duration::from_secs(10); const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// How far below a cursor a warm window reaches back.
pub const CURSOR_OVERLAP_MS: u64 = 60_000;
/// The region of history to read. /// The region of history to read.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -43,6 +48,87 @@ impl Window {
since_ms: Some(since_ms), since_ms: Some(since_ms),
} }
} }
/// The window a channel is opened with.
pub fn opening(cursor: ChannelCursor) -> Self {
match cursor.newest_ms {
Some(newest_ms) => Self {
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
},
None => Self::default(),
}
}
}
/// What a relay said about one page subscription.
#[derive(Debug, Clone)]
pub enum Settled {
Replayed,
Refused(String),
}
/// One relay's verdict on one page subscription.
#[derive(Debug, Clone)]
pub struct PageReport {
pub relay: RelayUrl,
pub outcome: Settled,
}
/// The page subscriptions in flight, by subscription id.
pub struct PageRegistry {
pages: Arc<Mutex<HashMap<SubscriptionId, flume::Sender<PageReport>>>>,
}
impl Default for PageRegistry {
fn default() -> Self {
Self {
pages: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Clone for PageRegistry {
fn clone(&self) -> Self {
Self {
pages: Arc::clone(&self.pages),
}
}
}
impl PageRegistry {
pub fn register(&self, id: SubscriptionId, sender: flume::Sender<PageReport>) {
self.lock().insert(id, sender);
}
pub fn unregister(&self, id: &SubscriptionId) {
self.lock().remove(id);
}
/// Forget every page still in flight, because its round is gone.
pub fn clear(&self) {
self.lock().clear();
}
/// Hand a relay's verdict to the page that owns `id`, when it is still waiting.
pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) {
let sender = self.lock().get(id).cloned();
let Some(sender) = sender else {
return;
};
if let Err(error) = sender.try_send(PageReport { relay, outcome }) {
log::debug!("community: a page report was not delivered: {error}");
}
}
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, flume::Sender<PageReport>>> {
match self.pages.lock() {
Ok(pages) => pages,
Err(poisoned) => poisoned.into_inner(),
}
}
} }
/// What one paged fetch saw. /// What one paged fetch saw.
@@ -58,8 +144,10 @@ pub struct WrapPage {
} }
/// Walks a channel's history back over the community's own relays. /// Walks a channel's history back over the community's own relays.
#[allow(clippy::too_many_arguments)]
pub async fn page( pub async fn page(
client: &Client, client: &Client,
pages: &PageRegistry,
channel: &ChannelId, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], held: &[(Epoch, [u8; 32])],
relays: &[RelayUrl], relays: &[RelayUrl],
@@ -77,6 +165,9 @@ pub async fn page(
}); });
} }
// A REQ can only target a relay the pool already knows about.
connect_relays(client, relays).await;
let mut walk = Walk::new(relays, window); let mut walk = Walk::new(relays, window);
let mut opened = Vec::new(); let mut opened = Vec::new();
@@ -91,18 +182,20 @@ pub async fn page(
.map(|index| (index, walk.url(index).clone())) .map(|index| (index, walk.url(index).clone()))
.collect(); .collect();
let answers = join_all( let answers = ask_page(client, pages, &asked, &filter).await;
asked
.iter()
.map(|(_, url)| ingest_page(client, url, &filter)),
)
.await;
for ((index, url), answer) in asked.iter().zip(answers) { for (index, url) in &asked {
if let Err(error) = answer { match answers.get(url) {
log::warn!("community: relay {url} did not answer a history page: {error}"); Some(Settled::Replayed) => {}
Some(Settled::Refused(reason)) => {
log::warn!("community: relay {url} refused a history page: {reason}");
walk.reject(*index); walk.reject(*index);
} }
None => {
log::warn!("community: relay {url} did not finish a history page");
walk.reject(*index);
}
}
} }
let answered = client.database().query(filter).await?; let answered = client.database().query(filter).await?;
@@ -144,88 +237,99 @@ fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
filter filter
} }
/// Ask one relay for one page and wait until it has answered. /// One page's verdicts, by relay.
async fn ingest_page(client: &Client, url: &RelayUrl, filter: &Filter) -> Result<()> { type PageAnswers = BTreeMap<RelayUrl, Settled>;
let relay = match client.relay(url).await? {
Some(relay) => relay, async fn ask_page(
None => { client: &Client,
client.add_relay(url).and_connect().await?; pages: &PageRegistry,
client asked: &[(usize, RelayUrl)],
.relay(url) filter: &Filter,
.await? ) -> PageAnswers {
.ok_or_else(|| anyhow!("the relay was not added to the pool"))? let mut answers = PageAnswers::new();
let distinct: BTreeSet<RelayUrl> = asked.iter().map(|(_, url)| url.clone()).collect();
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(distinct.len());
for url in &distinct {
match client.relay(url).await {
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
Ok(None) => {
log::warn!("community: relay {url} is not in the pool for a history page");
answers.insert(
url.clone(),
Settled::Refused("not in the relay pool".to_owned()),
);
}
Err(error) => {
log::warn!("community: relay {url} could not be looked up: {error}");
answers.insert(url.clone(), Settled::Refused(error.to_string()));
}
}
}
if targets.is_empty() {
return answers;
} }
};
let id = history_subscription(); let id = history_subscription();
let mut notifications = relay.notifications(); let (sender, receiver) = flume::bounded(distinct.len());
pages.register(id.clone(), sender);
// The SDK closes and unregisters the subscription itself once the relay says let options = SubscribeAutoCloseOptions::default()
// it is done, so nothing has to be unwound here.
relay
.subscribe(vec![filter.clone()])
.with_id(id.clone())
.close_on(
SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(PAGE_TIMEOUT)), .timeout(Some(PAGE_TIMEOUT));
)
.await?; match client
.subscribe(ReqTarget::manual(targets))
.with_id(id.clone())
.close_on(options)
.await
{
Ok(output) => {
for (url, reason) in output.failed {
answers.insert(url, Settled::Refused(reason));
}
// One deadline for the whole page, so a relay that keeps talking cannot
// extend the wait past it.
let deadline = Instant::now() + PAGE_TIMEOUT; let deadline = Instant::now() + PAGE_TIMEOUT;
loop { while answers.len() < distinct.len() {
let remaining = deadline.saturating_duration_since(Instant::now()); let remaining = deadline.saturating_duration_since(Instant::now());
let Some(notification) = within(remaining, notifications.next()).await else { let Some(report) = within(remaining, receiver.recv_async()).await else {
return Err(anyhow!("the relay did not finish the page")); break;
}; };
let Some(notification) = notification else { match report {
return Err(anyhow!("the relay's notification stream ended")); Ok(report) => {
}; answers.insert(report.relay, report.outcome);
}
let RelayNotification::Message { message } = notification else { Err(error) => {
continue; log::warn!("community: a history page's reports were lost: {error}");
}; break;
match *message {
RelayMessage::EndOfStoredEvents(ended) => {
if ended.as_ref() == &id {
return Ok(());
} }
} }
RelayMessage::Closed {
subscription_id,
message,
} if subscription_id.as_ref() == &id && !auth_required(&message) => {
return Err(anyhow!("the relay closed the page: {message}"));
} }
_ => {}
} }
Err(error) => {
log::warn!("community: a history page REQ was refused: {error}");
} }
} }
/// Whether a CLOSED reason is the NIP-42 `auth-required` one, the only reason the pages.unregister(&id);
/// SDK recovers from on its own.
fn auth_required(reason: &str) -> bool { answers
}
pub(crate) fn auth_required(reason: &str) -> bool {
matches!( matches!(
MachineReadablePrefix::parse(reason), MachineReadablePrefix::parse(reason),
Some(MachineReadablePrefix::AuthRequired) Some(MachineReadablePrefix::AuthRequired)
) )
} }
/// A subscription id for one page of one relay, unique so a page's answer is
/// never confused with the community's standing subscription or another page's.
fn history_subscription() -> SubscriptionId { fn history_subscription() -> SubscriptionId {
static NEXT: AtomicU64 = AtomicU64::new(0); static NEXT: AtomicU64 = AtomicU64::new(0);
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
SubscriptionId::new(format!(
"concord-history-{}",
NEXT.fetch_add(1, Ordering::Relaxed)
))
} }
/// Await `future`, giving up after `limit`. /// Await `future`, giving up after `limit`.
@@ -513,4 +617,46 @@ mod tests {
.finalize(&keys) .finalize(&keys)
.expect("signs") .expect("signs")
} }
/// A cold channel asks wide; a warm one resumes at the overlap above its
/// cursor, and `older_than` never includes the boundary event itself.
#[test]
fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() {
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
let warm = Window::opening(ChannelCursor {
newest_ms: Some(2_000_000),
oldest_ms: Some(1_000),
exhausted: false,
});
assert_eq!(
warm,
Window {
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
until_ms: None,
}
);
assert_eq!(
Window::older_than(1_000),
Window {
since_ms: None,
until_ms: Some(999),
}
);
}
/// A page whose round has moved on is simply a report nobody reads.
#[test]
fn a_page_that_moved_on_receives_nothing() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-9");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
pages.unregister(&id);
pages.deliver(&id, relay_url("history"), Settled::Replayed);
assert!(receiver.try_recv().is_err());
}
} }
+293 -34
View File
@@ -1,16 +1,19 @@
use std::collections::HashMap; use std::collections::{BTreeSet, HashMap};
use std::time::{Duration, Instant};
use anyhow::Result; use anyhow::Result;
use concord::cord01::KIND_WRAP;
pub use concord::cord02::CommunityMetadata; pub use concord::cord02::CommunityMetadata;
pub use concord::cord03::{ChatMessage, ReplyRef}; pub use concord::cord03::{ChatMessage, ReplyRef};
use concord::state::CommunityState; use concord::state::CommunityState;
pub use concord::{ChannelId, CommunityId}; pub use concord::{ChannelId, CommunityId};
use futures::future::{Either, select};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
use crate::history::{PageRegistry, Settled, auth_required};
pub mod cache; pub mod cache;
mod community; mod community;
pub mod history; pub mod history;
@@ -19,6 +22,9 @@ mod sync;
pub use community::*; pub use community::*;
pub use sync::*; pub use sync::*;
/// How long a burst of relay notifications is collected before it is folded.
const PUMP_WINDOW: Duration = Duration::from_millis(200);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
} }
@@ -33,6 +39,83 @@ enum Signal {
List, List,
} }
/// Which standing subscription an id belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Route {
List,
Community(CommunityId),
}
fn route_of(id: &SubscriptionId) -> Option<Route> {
if sync::is_list_subscription(id) {
return Some(Route::List);
}
sync::community_of(id).map(Route::Community)
}
/// What a window of relay notifications saw, waiting to be folded once.
#[derive(Default)]
struct Batch {
list: bool,
communities: BTreeSet<CommunityId>,
}
/// Whether the pump should keep listening.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Flow {
Continue,
Stop,
}
/// Fold one notification into the window, or settle a page it belongs to.
fn route(notification: ClientNotification, pages: &PageRegistry, batch: &mut Batch) -> Flow {
match notification {
ClientNotification::Event {
subscription_id, ..
} => match route_of(&subscription_id) {
Some(Route::List) => batch.list = true,
// Only the database is told here; the fold reads it when the window closes.
Some(Route::Community(id)) => {
batch.communities.insert(id);
}
None => {}
},
ClientNotification::Message { relay_url, message } => match *message {
RelayMessage::EndOfStoredEvents(id) => {
pages.deliver(&id, relay_url, Settled::Replayed);
}
RelayMessage::Closed {
subscription_id,
message,
} if !auth_required(&message) => {
pages.deliver(
&subscription_id,
relay_url,
Settled::Refused(message.into_owned()),
);
}
_ => {}
},
ClientNotification::Shutdown => return Flow::Stop,
}
Flow::Continue
}
/// Hand the window's signals to the foreground consumer, one per community.
async fn flush(tx: &flume::Sender<Signal>, batch: &mut Batch) -> Result<()> {
for id in std::mem::take(&mut batch.communities) {
tx.send_async(Signal::Event(id)).await?;
}
if std::mem::take(&mut batch.list) {
tx.send_async(Signal::List).await?;
}
Ok(())
}
impl EventEmitter<CommunityEvent> for CommunityRegistry {} impl EventEmitter<CommunityEvent> for CommunityRegistry {}
pub struct CommunityRegistry { pub struct CommunityRegistry {
@@ -44,6 +127,8 @@ pub struct CommunityRegistry {
observers: HashMap<CommunityId, Subscription>, observers: HashMap<CommunityId, Subscription>,
signal_tx: flume::Sender<Signal>, signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>, signal_rx: flume::Receiver<Signal>,
/// The page subscriptions in flight, shared with the notification pump.
pages: PageRegistry,
tasks: SmallVec<[Task<Result<()>>; 2]>, tasks: SmallVec<[Task<Result<()>>; 2]>,
/// Notification listener task (cancelled on signer change) /// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<()>>>, notification_listener: Option<Task<Result<()>>>,
@@ -95,6 +180,7 @@ impl CommunityRegistry {
observers: HashMap::new(), observers: HashMap::new(),
signal_tx: tx, signal_tx: tx,
signal_rx: rx, signal_rx: rx,
pages: PageRegistry::default(),
tasks: smallvec![], tasks: smallvec![],
notification_listener: None, notification_listener: None,
signal_consumer: None, signal_consumer: None,
@@ -169,6 +255,7 @@ impl CommunityRegistry {
self.signal_consumer = None; self.signal_consumer = None;
self.tasks.clear(); self.tasks.clear();
self.observers.clear(); self.observers.clear();
self.pages.clear();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -256,7 +343,7 @@ impl CommunityRegistry {
community community
} }
None => { None => {
let community = cx.new(|_| Community::new(state)); let community = cx.new(|_| Community::new(state, self.pages.clone()));
self.observers.insert( self.observers.insert(
id, id,
@@ -334,7 +421,7 @@ impl CommunityRegistry {
}; };
let subscription = sync::subscription_id(&id); let subscription = sync::subscription_id(&id);
let filter = sync::subscription_filter(&planes); let filter = sync::live_filter(&planes, sync::live_window(&state));
let relays = key.relays().to_vec(); let relays = key.relays().to_vec();
self.synced.insert(id, key); self.synced.insert(id, key);
@@ -360,34 +447,51 @@ impl CommunityRegistry {
let tx = self.signal_tx.clone(); let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone(); let rx = self.signal_rx.clone();
let pages = self.pages.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move { self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut batch = Batch::default();
while let Some(notification) = notifications.next().await { 'outer: loop {
let ClientNotification::Event { match notifications.next().await {
subscription_id, Some(notification) => {
event, if route(notification, &pages, &mut batch) == Flow::Stop {
.. flush(&tx, &mut batch).await?;
} = notification break 'outer;
else { }
continue; }
}; None => break 'outer,
if sync::is_list_subscription(&subscription_id) {
tx.send_async(Signal::List).await?;
continue;
} }
if event.kind != Kind::from(KIND_WRAP) { let deadline = Instant::now() + PUMP_WINDOW;
continue;
loop {
let now = Instant::now();
if now >= deadline {
break;
} }
let Some(id) = sync::community_of(&subscription_id) else { let timer = executor.timer(deadline - now);
continue; let next = notifications.next();
}; futures::pin_mut!(timer);
futures::pin_mut!(next);
tx.send_async(Signal::Event(id)).await?; match select(next, timer).await {
Either::Left((Some(notification), _)) => {
if route(notification, &pages, &mut batch) == Flow::Stop {
flush(&tx, &mut batch).await?;
break 'outer;
}
}
Either::Left((None, _)) => break 'outer,
Either::Right(_) => break,
}
}
flush(&tx, &mut batch).await?;
} }
Ok(()) Ok(())
@@ -413,24 +517,34 @@ async fn subscribe(
) -> Result<()> { ) -> Result<()> {
client.unsubscribe(id).await?; client.unsubscribe(id).await?;
if relays.is_empty() {
log::warn!("community {id}: no relay to subscribe to");
return Ok(());
}
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(relays.len());
for url in relays { for url in relays {
if let Err(error) = client.add_relay(url).and_connect().await { if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community {id}: failed to add relay {url}: {error}"); log::warn!("community {id}: failed to add relay {url}: {error}");
} }
match client.relay(url).await {
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"),
Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"),
}
} }
let target = if relays.is_empty() { if targets.is_empty() {
ReqTarget::auto(vec![filter]) log::warn!("community {id}: no relay accepted the standing subscription");
} else { return Ok(());
ReqTarget::manual( }
relays
.iter()
.map(|url| (url.clone(), vec![filter.clone()]))
.collect::<Vec<_>>(),
)
};
let output = client.subscribe(target).with_id(id.clone()).await?; let output = client
.subscribe(ReqTarget::manual(targets))
.with_id(id.clone())
.await?;
if !output.failed.is_empty() { if !output.failed.is_empty() {
log::warn!( log::warn!(
@@ -441,3 +555,148 @@ async fn subscribe(
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
fn relay() -> RelayUrl {
RelayUrl::parse("wss://relay.example").expect("a url")
}
fn event(subscription_id: SubscriptionId) -> ClientNotification {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "hi")
.finalize(&keys)
.expect("signs");
ClientNotification::Event {
relay_url: relay(),
subscription_id,
event: Box::new(event),
}
}
fn message(message: RelayMessage<'static>) -> ClientNotification {
ClientNotification::Message {
relay_url: relay(),
message: Box::new(message),
}
}
#[test]
fn only_the_list_and_a_community_id_have_a_route() {
let id = CommunityId::from_bytes([0x42; 32]);
assert_eq!(route_of(&sync::list_subscription_id()), Some(Route::List));
assert_eq!(
route_of(&sync::subscription_id(&id)),
Some(Route::Community(id))
);
assert_eq!(route_of(&SubscriptionId::new("concord-history-3")), None);
assert_eq!(
route_of(&SubscriptionId::new("some/other/subscription")),
None
);
}
/// A burst within one window folds each community once, and the list once,
/// however many events arrived.
#[test]
fn a_burst_collapses_to_one_signal_per_community() {
let pages = PageRegistry::default();
let community = CommunityId::from_bytes([0x42; 32]);
let mut batch = Batch::default();
let plane = event(sync::subscription_id(&community));
for _ in 0..50 {
assert_eq!(route(plane.clone(), &pages, &mut batch), Flow::Continue);
}
route(event(sync::list_subscription_id()), &pages, &mut batch);
// A page's event is folded from the database later, not routed here.
route(
event(SubscriptionId::new("concord-history-7")),
&pages,
&mut batch,
);
assert!(batch.list);
assert_eq!(batch.communities, BTreeSet::from([community]));
}
#[test]
fn an_eose_settles_only_the_page_that_owns_the_id() {
let pages = PageRegistry::default();
let mine = SubscriptionId::new("concord-history-1");
let other = SubscriptionId::new("concord-history-2");
let (mine_tx, mine_rx) = flume::bounded(1);
let (other_tx, other_rx) = flume::bounded(1);
pages.register(mine.clone(), mine_tx);
pages.register(other, other_tx);
let mut batch = Batch::default();
let flow = route(message(RelayMessage::eose(mine)), &pages, &mut batch);
assert_eq!(flow, Flow::Continue);
assert!(matches!(
mine_rx.try_recv().expect("a report").outcome,
Settled::Replayed
));
assert!(other_rx.try_recv().is_err());
}
#[test]
fn a_refused_page_settles_its_relay_as_refused() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-1");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
let mut batch = Batch::default();
route(
message(RelayMessage::closed(id, "blocked: not allowed")),
&pages,
&mut batch,
);
match receiver.try_recv().expect("a report").outcome {
Settled::Refused(reason) => assert!(reason.contains("blocked")),
other => panic!("expected a refusal, got {other:?}"),
}
}
/// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so
/// the page keeps waiting rather than writing the relay off.
#[test]
fn an_auth_required_close_settles_nothing() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-1");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
let mut batch = Batch::default();
route(
message(RelayMessage::closed(
id,
"auth-required: please authenticate",
)),
&pages,
&mut batch,
);
assert!(receiver.try_recv().is_err());
}
#[test]
fn a_shutdown_stops_the_pump() {
let pages = PageRegistry::default();
let mut batch = Batch::default();
assert_eq!(
route(ClientNotification::Shutdown, &pages, &mut batch),
Flow::Stop
);
}
}
+120 -21
View File
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use concord::cord01::KIND_WRAP; use concord::cord01::KIND_WRAP_EPHEMERAL;
use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
use concord::cord02::{self, ControlFold, ImageRef}; use concord::cord02::{self, ControlFold, ImageRef};
use concord::cord04::AuthorityCitation; use concord::cord04::AuthorityCitation;
@@ -16,7 +16,11 @@ use gpui::AsyncApp;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::UniversalSigner; use state::UniversalSigner;
use crate::cache; use crate::cache::{self, Observed};
use crate::history::{CURSOR_OVERLAP_MS, Window};
/// How much of what a relay stores a cold subscription replays per relay.
const LIVE_REPLAY: usize = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlaneKind { pub enum PlaneKind {
@@ -63,11 +67,13 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
}); });
for channel in &state.channels { for channel in &state.channels {
if channel.private { let secret = match (channel.private, channel.key) {
continue; (true, None) => continue,
} (true, Some(key)) => key,
(false, _) => state.community_root,
};
let group = channel_group_key(&state.community_root, &channel.id, channel.epoch)?; let group = channel_group_key(&secret, &channel.id, channel.epoch)?;
planes.push(Plane { planes.push(Plane {
kind: PlaneKind::Channel(channel.id, channel.epoch), kind: PlaneKind::Channel(channel.id, channel.epoch),
address: group.pk(), address: group.pk(),
@@ -78,12 +84,43 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
Ok(planes) Ok(planes)
} }
pub fn subscription_filter(planes: &[Plane]) -> Filter { pub fn plane_filter(planes: &[Plane]) -> Filter {
Filter::new() Filter::new()
.kinds([Kind::from(KIND_WRAP)]) .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(planes.iter().map(|plane| plane.address)) .authors(planes.iter().map(|plane| plane.address))
} }
pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
let mut filter = plane_filter(planes);
if let Some(until_ms) = window.until_ms {
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
}
if let Some(since_ms) = window.since_ms {
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
}
filter.limit(LIVE_REPLAY)
}
/// The window a community's standing subscription opens with.
pub fn live_window(state: &CommunityState) -> Window {
let floor = state
.channels
.iter()
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
.min();
match floor {
Some(floor) => Window {
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
},
None => Window::default(),
}
}
/// The subscription id carrying a community's planes. /// The subscription id carrying a community's planes.
pub fn subscription_id(id: &CommunityId) -> SubscriptionId { pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(id.to_hex()) SubscriptionId::new(id.to_hex())
@@ -429,14 +466,20 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
return Ok(None); return Ok(None);
} }
let wraps = client let wraps = client.database().query(plane_filter(&planes)).await?;
.database()
.query(subscription_filter(&planes))
.await?;
let mut editions = Vec::new(); let mut editions = Vec::new();
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new(); let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
let mut guestbook_rumors = Vec::new(); let mut guestbook_rumors = Vec::new();
let mut cached: BTreeMap<ChannelId, BTreeMap<EventId, Observed>> = BTreeMap::new();
for plane in &planes {
if let PlaneKind::Channel(channel, _) = plane.kind
&& !cached.contains_key(&channel)
{
cached.insert(channel, cache::wrapper_index(client, &channel).await?);
}
}
for wrap in &wraps { for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else { let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else {
@@ -457,6 +500,11 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
} }
} }
PlaneKind::Channel(channel, epoch) => { PlaneKind::Channel(channel, epoch) => {
if let Some(row) = cached.get(&channel).and_then(|index| index.get(&wrap.id)) {
observe(&mut observed, row.author, row.at_ms);
continue;
}
if let Ok((opened, rumor)) = if let Ok((opened, rumor)) =
concord::cord03::open(wrap, &plane.group, &channel, epoch) concord::cord03::open(wrap, &plane.group, &channel, epoch)
{ {
@@ -496,6 +544,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
let now_ms = Timestamp::now().as_secs().saturating_mul(1000); let now_ms = Timestamp::now().as_secs().saturating_mul(1000);
let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick); let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick);
let mut members = cord02::guestbook::complete_memberlist( let mut members = cord02::guestbook::complete_memberlist(
&coalesced, &coalesced,
&observed, &observed,
@@ -538,10 +587,11 @@ mod tests {
} }
#[test] #[test]
fn planes_address_the_control_guestbook_and_only_public_channels() { fn planes_address_the_control_guestbook_and_every_readable_channel() {
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key(); let control_pk = Keys::generate().public_key();
let general = ChannelId::from_bytes([0x9c; 32]); let general = ChannelId::from_bytes([0x9c; 32]);
let staff = ChannelId::from_bytes([0x9d; 32]);
let state = CommunityState { let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]), id: CommunityId::from_bytes([0x42; 32]),
@@ -561,12 +611,19 @@ mod tests {
key: None, key: None,
}, },
concord::state::ChannelKeyRef { concord::state::ChannelKeyRef {
id: ChannelId::from_bytes([0x9d; 32]), id: staff,
name: "staff".to_owned(), name: "staff".to_owned(),
private: true, private: true,
epoch: Epoch(0), epoch: Epoch(0),
key: Some([0x04; 32]), key: Some([0x04; 32]),
}, },
concord::state::ChannelKeyRef {
id: ChannelId::from_bytes([0x9e; 32]),
name: "locked".to_owned(),
private: true,
epoch: Epoch(0),
key: None,
},
], ],
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")], relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
heads: Vec::new(), heads: Vec::new(),
@@ -578,7 +635,7 @@ mod tests {
let planes = planes(&state).expect("planes"); let planes = planes(&state).expect("planes");
assert_eq!(planes.len(), 3); assert_eq!(planes.len(), 4);
assert!(planes.iter().any(|plane| plane.address == control_pk)); assert!(planes.iter().any(|plane| plane.address == control_pk));
assert!( assert!(
planes planes
@@ -590,11 +647,53 @@ mod tests {
.iter() .iter()
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general))
); );
assert!(
planes
.iter()
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == staff)),
"a private channel whose key is held is subscribed"
);
let filter = subscription_filter(&planes); let filter = plane_filter(&planes);
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect(); let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
assert_eq!(filter.authors, Some(addresses)); assert_eq!(filter.authors, Some(addresses));
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); assert_eq!(
filter.kinds,
Some(BTreeSet::from([
Kind::GiftWrap,
Kind::Custom(KIND_WRAP_EPHEMERAL)
])),
"the standing subscription asks for both wrap kinds"
);
}
/// A cold subscription asks wide; a warm one resumes at the oldest held
/// cursor, minus the overlap, so no channel's new region is skipped.
#[test]
fn the_live_window_is_wide_cold_and_resumes_at_the_oldest_cursor_warm() {
let mut state = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let channel = state.channels[0].id;
assert_eq!(live_window(&state), Window::default());
state.cursors.insert(
channel,
concord::state::ChannelCursor {
newest_ms: Some(2_000_000),
oldest_ms: Some(1_000),
exhausted: false,
},
);
assert_eq!(
live_window(&state),
Window {
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
until_ms: None,
}
);
} }
fn metadata(name: &str) -> cord02::CommunityMetadata { fn metadata(name: &str) -> cord02::CommunityMetadata {
@@ -661,16 +760,16 @@ mod tests {
.expect("loads"); .expect("loads");
assert_eq!(loaded, vec![created.clone()]); assert_eq!(loaded, vec![created.clone()]);
// The subscription filter must address the genesis wraps, or the registry // The plane filter must address the genesis wraps, or a fold would
// would listen to a plane nothing is ever published on. // read a plane nothing is ever published on.
let planes = planes(&created).expect("planes"); let planes = planes(&created).expect("planes");
let wraps = client let wraps = client
.database() .database()
.query(subscription_filter(&planes)) .query(plane_filter(&planes))
.await .await
.expect("queries"); .expect("queries");
assert_eq!(wraps.len(), created.heads.len()); assert_eq!(wraps.len(), created.heads.len());
assert!(wraps.iter().all(|wrap| wrap.kind == Kind::from(KIND_WRAP))); assert!(wraps.iter().all(|wrap| wrap.kind == Kind::GiftWrap));
let snapshot = fold(&client, &created) let snapshot = fold(&client, &created)
.await .await
+180 -96
View File
@@ -35,28 +35,35 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's `RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
wait, and it is reported rather than rendered as an empty channel. wait, and it is reported rather than rendered as an empty channel.
Read path today (phase 1 landed the walk in `concord/src/store.rs`; phase 2a has Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
since moved each layer to where it belongs — the paths here are the current ones): swapped the transport and put the pump in charge of settling pages):
``` ```
CommunityPanel::load community_ui/src/lib.rs:192 CommunityPanel::load community_ui/src/lib.rs:192
-> Community::sync_channel community/src/community.rs:218 -> Community::sync_channel community/src/community.rs:220
-> sync_round community/src/community.rs:604 -> sync_round community/src/community.rs (purge_expired,
-> history::page community/src/history.rs (per-relay Window::opening)
-> ingest_page community/src/history.rs subscribe + -> history::page community/src/history.rs (one REQ per page
notification wait) -> ask_page community/src/history.rs over the community's
relays, registered
in the page registry)
-> PageRegistry community/src/history.rs (EOSE / CLOSED arrive
through the pump)
-> client.database().query(filter) (the page) -> client.database().query(filter) (the page)
-> cache::cache_rumor community/src/cache.rs -> cache::cache_rumor community/src/cache.rs
-> Community::timeline community/src/community.rs:368 -> Community::timeline community/src/community.rs:370
-> cache::query_rumors community/src/cache.rs -> cache::query_rumors community/src/cache.rs
-> cord03::fold concord/src/cords/cord03.rs -> cord03::fold concord/src/cords/cord03.rs
live wire community/src/lib.rs:307 (sync_subscriptions) live wire community/src/lib.rs:309 (sync_subscriptions)
-> sync::subscription_filter community/src/sync.rs:79 (kind 1059 only, -> sync::live_filter community/src/sync.rs (both wrap kinds,
no since, no limit) private channels,
-> pump community/src/lib.rs:352 (drops every Window::opening,
Message and every limit LIVE_REPLAY)
non-1059 event) -> pump community/src/lib.rs:433 (routes by subscription
id, batches within
PUMP_WINDOW, settles
pages by EOSE/CLOSED)
``` ```
## What is wrong today ## What is wrong today
@@ -67,16 +74,16 @@ live wire community/src/lib.rs:307 (sync_subscriptions)
| 2 | That one round was one shallow page with no continuation. | fixed in phase 1 (paged walk + cursors) | `community/src/history.rs` | | 2 | That one round was one shallow page with no continuation. | fixed in phase 1 (paged walk + cursors) | `community/src/history.rs` |
| 3 | The timeline was a fixed 200-row window with no way to ask for older. | fixed in phase 1 (`timeline` + `has_more` + load-older) | `community_ui/src/lib.rs:263` | | 3 | The timeline was a fixed 200-row window with no way to ask for older. | fixed in phase 1 (`timeline` + `has_more` + load-older) | `community_ui/src/lib.rs:263` |
| 4 | Cursors existed but nothing used them; no "has more" signal. | fixed in phase 1 | `community/src/community.rs:337` | | 4 | Cursors existed but nothing used them; no "has more" signal. | fixed in phase 1 | `community/src/community.rs:337` |
| 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | open (phase 2b) | `community/src/sync.rs:63-66` | | 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | fixed in phase 2b (`planes` derives a private channel's plane from the held key; it is subscribed, paged and folded like a public one) | `community/src/sync.rs` |
| 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | open (phase 2b) | `community/src/sync.rs:79-83`, `community/src/lib.rs:380-383` | | 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | fixed in phase 2b (`live_filter`/`plane_filter` ask for both kinds; the pump routes by subscription id and never inspects the kind) | `community/src/sync.rs`, `community/src/lib.rs` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | open (phase 3) | `community/src/sync.rs:331-368`, `community/src/community.rs:199-216` | | 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | open (phase 3) | `community/src/sync.rs:331-368`, `community/src/community.rs:199-216` |
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` | | 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` |
| 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` | | 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` |
| 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` | | 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` |
| 11 | The local cache document is authored by a **process-random key**, so the same rumor cached in two runs is a different event id and the store keeps both copies; `fold` then re-reads all of it on every inbound wrap. | fixed in phase 2a (one fixed cache key) | `community/src/cache.rs` | | 11 | The local cache document is authored by a **process-random key**, so the same rumor cached in two runs is a different event id and the store keeps both copies; `fold` then re-reads all of it on every inbound wrap. | fixed in phase 2a (one fixed cache key) | `community/src/cache.rs` |
| 12 | `cache::purge_expired` is never called, so expired rows only drop at fold time, never from disk. | open (phase 2b) | `community/src/cache.rs` (no call sites) | | 12 | `cache::purge_expired` is never called, so expired rows only drop at fold time, never from disk. | fixed in phase 2b (`sync_round` sweeps the channel before it starts) | `community/src/community.rs` |
| 13 | **Every relay and database operation lived in `concord`**: `fetch_page` installed its own subscriptions and read the client database, `cache_rumor`/`query_rumors`/`save_state`/`load_states` wrote and read it. Consequences: the pump could not see a page's REQ (so the panel's live view and the history path were two unrelated worlds), each page hand-rolled a per-relay notification loop, and the protocol crate could not be built or tested without a `Client`. | fixed in phase 2a (split into `concord/src/state.rs`, `community/src/cache.rs` and `community/src/history.rs`; the page REQ still waits on its own loop — §2 moves it into the pump) | `concord/src/state.rs`, `community/src/{cache,history}.rs` | | 13 | **Every relay and database operation lived in `concord`**: `fetch_page` installed its own subscriptions and read the client database, `cache_rumor`/`query_rumors`/`save_state`/`load_states` wrote and read it. Consequences: the pump could not see a page's REQ (so the panel's live view and the history path were two unrelated worlds), each page hand-rolled a per-relay notification loop, and the protocol crate could not be built or tested without a `Client`. | fixed in phase 2a (split into `concord/src/state.rs`, `community/src/cache.rs` and `community/src/history.rs`; the page REQ still waits on its own loop — §2 moves it into the pump) | `concord/src/state.rs`, `community/src/{cache,history}.rs` |
| 14 | **The fold is O(history)**: `sync::fold` re-reads every wrap in the community's planes and NIP-44-opens each one on every inbound wrap, purely to cache channel rumors and observe their authors for the member list. The live REQ replays the plane on every start, so this happens on every app run and every burst of messages. | open (phase 2b) | `community/src/sync.rs:423-517` | | 14 | **The fold is O(history)**: `sync::fold` re-reads every wrap in the community's planes and NIP-44-opens each one on every inbound wrap, purely to cache channel rumors and observe their authors for the member list. The live REQ replays the plane on every start, so this happens on every app run and every burst of messages. | fixed in phase 2b (channel wraps are opened once, on the way in; `fold` reads already-cached authors and times from `cache::wrapper_index` instead of opening them again) | `community/src/sync.rs`, `community/src/cache.rs` |
Findings 1-4 and 9-10 are the user-visible symptom and were phase 1; 13 is where Findings 1-4 and 9-10 are the user-visible symptom and were phase 1; 13 is where
that work landed in the wrong crate; 5-7 are why some channels look empty forever; that work landed in the wrong crate; 5-7 are why some channels look empty forever;
@@ -167,64 +174,69 @@ Notes:
| Subscription | Id | Owner | Lifetime | | Subscription | Id | Owner | Lifetime |
| ------------ | -- | ----- | -------- | | ------------ | -- | ----- | -------- |
| Community List | `concord-list/<self pk>` (existing) | registry | signer lifetime | | Community List | `concord/list` (existing) | registry | signer lifetime |
| Live planes | `<community hex>` (existing `sync::subscription_id`) | community | until the signer or the plane set changes; **kept alive** | | Live planes | `<community hex>` (existing `sync::subscription_id`) | community | until the signer or the plane set changes; **kept alive** |
| History page | `concord-history/<community hex>/<channel hex>/<n>` | the round | one page; auto-closes on EOSE | | History page | `concord-history-<n>` (opaque, unique) | the round | one page; auto-closes on EOSE |
| Rekey watch (phase 3) | `concord-rekey/<community hex>/<n>` | community | kept alive while the community is tracked | | Rekey watch (phase 3) | `concord-rekey/<community hex>/<n>` | community | kept alive while the community is tracked |
A single `route_of(&SubscriptionId) -> Option<Route>` parses the id back into `route_of(&SubscriptionId) -> Option<Route>` parses the two **standing** ids into
`Route::{List, Community(CommunityId), History { community, channel }, Rekey(community)}`, `Route::{List, Community(CommunityId)}`, so the pump routes an event by
so the pump routes by subscription id first and never has to guess from an event. subscription id and never has to guess from the event itself (which is what keeps
another crate's subscription out of a community's fold).
A page id cannot be parsed this way, and that is deliberate: a NIP-01 subscription
id is capped at 64 characters, which a community hex alone fills, so an id cannot
carry both a community and a channel. A page's id is therefore an opaque
`concord-history-<n>` and the pump resolves it through the **page registry**
instead — `PageRegistry`, a `HashMap<SubscriptionId, flume::Sender<PageReport>>`
shared between the registry and the rounds. `PageReport { relay, outcome }`
carries facts; the walk decides what they mean. A report for an id nobody is
waiting on is dropped at `debug`, which is the ordinary case when a round is
cancelled. Phase 3 can add a `Rekey` route the same way, by id.
#### The pump #### The pump
`CommunityRegistry::handle_notifications` becomes the only consumer of `CommunityRegistry::handle_notifications` becomes the only consumer of
`client.notifications()`, and it handles the message variants it drops today: `client.notifications()`, and it handles the message variants it drops today
(`route` and the window loop live in `community/src/lib.rs`):
```rust ```rust
loop { fn route(notification, pages, batch) -> Flow {
match notifications.next().await { match notification {
Some(ClientNotification::Event { subscription_id, event, .. }) => { ClientNotification::Event { subscription_id, .. } => match route_of(&subscription_id) {
match route_of(&subscription_id) {
Some(Route::List) => batch.list = true, Some(Route::List) => batch.list = true,
// Only the database is told; the fold reads it when the window closes. // Only the database is told; the fold reads it when the window closes.
Some(Route::Community(id)) => batch.events.insert(id), Some(Route::Community(id)) => { batch.communities.insert(id); }
// A page and a rekey watch are read from the database later: // A page's event needs no routing at all: the subscription already put
// the page when its relays settle, the rekey when the batch closes. // it in the database, and the round reads it back from there.
Some(Route::History { .. }) => {}
Some(Route::Rekey(id)) => batch.rekeys.insert(id),
None => {} None => {}
} },
} ClientNotification::Message { relay_url, message } => match *message {
Some(ClientNotification::Message { relay_url, message }) => match *message { RelayMessage::EndOfStoredEvents(id) => pages.deliver(&id, relay_url, Settled::Replayed),
RelayMessage::EndOfStoredEvents(id) => pages.settled(&id, relay_url, Settled::Replayed),
RelayMessage::Closed { subscription_id, message } RelayMessage::Closed { subscription_id, message }
if !auth_required(&message) => if !auth_required(&message) =>
{ {
pages.settled(&subscription_id, relay_url, Settled::Refused(message)); pages.deliver(&subscription_id, relay_url, Settled::Refused(message.into_owned()));
} }
// auth-required: the SDK re-issues this REQ under the same id after // auth-required: the SDK re-issues this REQ under the same id after
// AUTH, so the page keeps waiting for the resubscribed answer. // AUTH, so the page keeps waiting for the resubscribed answer.
_ => {} _ => {}
}, },
Some(ClientNotification::Shutdown) | None => break, ClientNotification::Shutdown => return Flow::Stop,
_ => {}
} }
Flow::Continue
} }
``` ```
- **Kind 1059 and 21059 both route** (finding 6); the previous revision's - **Kind 1059 and 21059 both route** (finding 6); the previous revision's
"drops every non-1059" check goes away entirely. "drops every non-1059" check goes away entirely.
- The batch is closed after `PUMP_WINDOW` (200 ms, the reference app's value) of - The window is a **fixed** `PUMP_WINDOW` (200 ms, the reference app's value) from
quiet, and what it produces is one `Signal::Event(id)` per community that saw the first notification of a burst, not a debounce that a steady stream can keep
an event plus one `Signal::List` — so a burst of fifty messages costs **one** extending. What it produces is one `Signal::Event(id)` per community that saw an
fold, not fifty. `Community::refresh` keeps its own `dirty` follow-up. event plus one `Signal::List` — so a burst of fifty messages costs **one** fold,
- `pages` is the registry's page registry: not fifty. `Community::refresh` keeps its own `dirty` follow-up.
`HashMap<(CommunityId, ChannelId), flume::Sender<PageReport>>`, registered by a - Page verdicts (`Message`) are delivered immediately, inside the window, so an
round before it subscribes and keyed off the id `route_of` parses back. EOSE is never held up by a burst on another subscription.
`PageReport { id, relay, outcome }` carries facts; the walk decides what they
mean. Registration happens once per round (not per page), and the round matches
reports by subscription id.
#### The live REQ stays #### The live REQ stays
@@ -235,7 +247,7 @@ reconnect and after a successful AUTH, which is exactly the "resync on socket
reopen" behaviour the reference client implements by hand. reopen" behaviour the reference client implements by hand.
What changes is the filter: it now asks for both wrap kinds, includes private What changes is the filter: it now asks for both wrap kinds, includes private
channels (§5), and carries a **window**: channels (§5), and carries a **window** and a bound:
```rust ```rust
/// What a community with no history in the database asks for: everything a relay /// What a community with no history in the database asks for: everything a relay
@@ -257,12 +269,36 @@ impl Window {
} }
} }
} }
/// The window a community's standing subscription opens with.
///
/// One REQ covers every channel, so the floor is the **oldest** held cursor:
/// starting any newer would skip a channel's new region. A channel with no
/// cursor is left to its own round. `since = None` only when nothing is held.
pub fn live_window(state: &CommunityState) -> Window {
let floor = state
.channels
.iter()
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
.min();
match floor {
Some(floor) => Window {
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
},
None => Window::default(),
}
}
``` ```
The floor is computed **when the REQ is installed** (on open, on a plane change, The floor is computed **when the REQ is installed** (on open, on a plane change,
on a signer change) and is not recomputed as cursors advance: a live REQ that is on a signer change) and is not recomputed as cursors advance: a live REQ that is
re-subscribed on every cursor merge would replay the seam on every message, and re-subscribed on every cursor merge would replay the seam on every message, and
the region it did not cover is the round's job, not the live REQ's. the region it did not cover is the round's job, not the live REQ's. Note that a
`since` bounds only the *stored replay*; live events arrive whatever it is, so a
channel with no cursor still receives new messages and gets its history from its
own round.
### 3. A channel round over page subscriptions ### 3. A channel round over page subscriptions
@@ -282,14 +318,21 @@ pub enum Intent {
What changes is the transport. A page is now: What changes is the transport. A page is now:
1. the round registers its `PageReport` sender for `(community, channel)`; 1. the round registers a `flume::Sender<PageReport>` in the page registry under
2. it installs **one REQ per page** over `state.relays` the page's id, before the REQ goes out;
(`client.subscribe(manual(relays, filter)).with_id(page_id).close_on(ExitOnEOSE + PAGE_TIMEOUT)`), 2. it installs **one REQ per page** over the community's relays
tagged with a unique `history_subscription_id`; (`client.subscribe(ReqTarget::manual(..)).with_id(page_id).close_on(ExitOnEOSE + PAGE_TIMEOUT)`),
3. it awaits the pump's reports for that id until every relay it asked has after checking each relay is in the pool (the pool refuses a target it does not
settled (EOSE, or a non-auth CLOSED) or `PAGE_TIMEOUT` passes; hold, and would fail the whole REQ);
3. the pump delivers one report per relay that settles (EOSE, or a non-auth
CLOSED); the round waits until every relay it asked has settled or
`PAGE_TIMEOUT` passes, then unregisters;
4. it reads the page **from the database** with the same filter the REQ used. 4. it reads the page **from the database** with the same filter the REQ used.
A relay the pool does not hold is settled as refused up front rather than failing
the page: its region was never read, so it blocks exhaustion like any other dead
relay.
Rules carried over from phase 1, none of which are negotiable: Rules carried over from phase 1, none of which are negotiable:
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive - **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
@@ -451,22 +494,30 @@ Still deferred: per-channel timeline state (switching back re-reads), and the
`MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which `MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which
prepends at the same end — the reader would oscillate). prepends at the same end — the reader would oscillate).
### 9. The fold stops being O(history) (phase 2b) ### 9. The fold stops being O(history) (phase 2b) — **landed**
`sync::fold` currently opens every wrap in the community's planes on every inbound `sync::fold` used to open every wrap in the community's planes on every inbound
wrap: channel wraps are re-opened only to `cache_rumor` them again and to observe wrap: channel wraps were re-opened only to `cache_rumor` them again and to observe
their authors, which the database already holds as decoded rows (the cached row their authors, which the database already holds as decoded rows (the cached row
carries the author in a `p` tag and the message time in `created_at`). Finding 14. carries the author in a `p` tag, the message time in `created_at`, and the wrap it
came from in an `e` tag). Finding 14.
The shape of the fix, in the same phase because it is the same code path: What landed is smaller than the first sketch and needs no new queue:
- Channel wraps are opened **once, on the way in** — the round does it for pages, - `cache::wrapper_index(client, channel)` reads the cached rows back as
the pump does it for live wraps through a per-channel work queue — instead of `EventId -> Observed { author, at_ms }`, keyed by the wrap they were opened from.
every fold. - `fold` opens a channel wrap only when its id is **not** in that index; an
- The fold keeps reading the control and guestbook planes from wraps (they are already-cached wrap is observed from the row instead. A wrap is therefore opened
small, and `cord02::fold_control` is a fold over all editions by construction). **once, on the way in** — by a round for a page, by the first fold after it
- Member observation comes from the cached rows or from an incrementally arrives for the live wire — and never again.
maintained per-channel map, not from re-opening history. - The control and guestbook planes are still read from wraps (they are small, and
`cord02::fold_control` is a fold over all editions by construction).
The fold still *reads* every wrap from the database (`plane_filter`, which is now
its own unbounded filter separate from the live one); what it no longer does is
NIP-44-open them all. The plan's per-channel work queue in the pump turned out to
be unnecessary for that: the first fold after a live wrap already opens exactly
the uncached wraps.
## Order of work ## Order of work
@@ -488,7 +539,7 @@ The shape of the fix, in the same phase because it is the same code path:
touched files), `cargo check -p workspace --all-targets`. No behaviour changed touched files), `cargo check -p workspace --all-targets`. No behaviour changed
beyond item 2. beyond item 2.
### Phase 2b — subscribe, then read the database ### Phase 2b — subscribe, then read the database — **landed**
1. The pump (§2): route by subscription id for events, EOSE and CLOSED; batch 1. The pump (§2): route by subscription id for events, EOSE and CLOSED; batch
within `PUMP_WINDOW`; both wrap kinds; `auth-required` never a failure. within `PUMP_WINDOW`; both wrap kinds; `auth-required` never a failure.
@@ -497,13 +548,35 @@ The shape of the fix, in the same phase because it is the same code path:
`PAGE_TIMEOUT`, reads the page from the database, and keeps the walk's rules `PAGE_TIMEOUT`, reads the page from the database, and keeps the walk's rules
(`exhausted` earned, all-empty is `failed`, `newest_ms` only on a complete (`exhausted` earned, all-empty is `failed`, `newest_ms` only on a complete
round, skip the older pass when `saved.exhausted`). round, skip the older pass when `saved.exhausted`).
3. The live REQ (§2, §5): both kinds, private channels, `Window::opening(saved)` 3. The live REQ (§2, §5): both kinds, private channels, the window rule with
with `LIVE_REPLAY = 500`, kept alive across reconnects. `LIVE_REPLAY = 500`, kept alive across reconnects.
4. `cache::purge_expired` on the open and round cadence. 4. `cache::purge_expired` on the round cadence (every round is an open).
5. The fold issue (§9). 5. The fold issue (§9).
6. Gate: the phase-1 manual bar, plus a check against a dev relay that a warm open 6. Gate: `cargo test -p concord -p community` (50 + 23),
sends one REQ per relay carrying a `since`, and a cold open sends one with no `cargo clippy -p concord -p community -p community_ui --all-targets`,
`since` and pages down. `cargo +nightly fmt --all --check`, `cargo check -p workspace --all-targets`;
plus the phase-1 manual bar and a dev-relay check that a warm open sends one REQ
per relay carrying a `since`, and a cold open sends one with no `since` and pages
down.
Three deviations from the revision-2 sketch, each forced by a constraint it did
not account for:
- **A page id is opaque and resolved through a registry, not parsed.** A NIP-01 id
is capped at 64 characters and a community hex fills it, so
`concord-history/<community>/<channel>/<n>` could never be sent. `route_of`
therefore only parses the two standing ids, and `PageRegistry` maps a page id
back to the round waiting on it.
- **The live window is per community and aggregates cursors.** There is one REQ
per community, so `Window::opening(cursor)` applies to a channel's newest pass
while `sync::live_window(state)` derives the community floor as the *oldest*
held cursor (a channel with no cursor forces `since = None`).
- **The fold needs no pump work queue.** Skipping already-cached wraps through
`cache::wrapper_index` already makes a wrap open once, on the way in, because the
first fold after a live wrap is the one that opens it. See §9.
Also landed as the cheap win §3 promised: the older pass is skipped entirely when
`saved.exhausted` is already set.
### Phase 3 — epochs and rekeys ### Phase 3 — epochs and rekeys
@@ -519,8 +592,9 @@ scheduler, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
supports negentropy; "negentropy unsupported" means "fall back to the paged supports negentropy; "negentropy unsupported" means "fall back to the paged
walk", never "exhausted"). walk", never "exhausted").
Each phase leaves the client consistent on its own. Phase 2a is invisible; Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
phase 2b is what makes "open a community" a subscription and a database read. 2b is what makes "open a community" a subscription and a database read. Phase 3
(§6) is next: it is what keeps a rekey from stranding history.
## Phase 1 status (landed) ## Phase 1 status (landed)
@@ -543,36 +617,46 @@ What phase 1 delivered, and what phase 2 replaces:
... the next round retries"). The SDK re-issues the REQ under the same id after ... the next round retries"). The SDK re-issues the REQ under the same id after
AUTH — for auto-closing subscriptions too — so the honest statement is: the AUTH — for auto-closing subscriptions too — so the honest statement is: the
relay stays in the walk and the page waits for its resubscribed answer. relay stays in the walk and the page waits for its resubscribed answer.
- Held epoch handling, rekeys, private planes, the second wrap kind, honest empty - Held epoch handling and rekeys are still open in §6, and honest empty states in
states, the expired-row sweep, the stable cache key and the fold cost were all §7. Private planes, the second wrap kind, the expired-row sweep, the stable
open before phase 1 and are still open in §5, §6, §7 and §9. cache key and the fold cost were open before phase 1 and are now landed (§5,
§9).
## Tests ## Tests
Following the existing `MemoryDatabase` and `Walk`/`serve_page` test style: Following the existing `MemoryDatabase` and `Walk`/`serve_page` test style. The
2b pieces are covered by unit tests that need no relay; the rows still marked
outstanding are the ones that need a GPUI harness or two live accounts.
- The walk: `history::page` continues past a page whose wraps none of them open; - The walk: `history::page` continues past a page whose wraps none of them open;
`exhausted` requires a short page *after* history; an all-empty round is `exhausted` requires a short page *after* history; an all-empty round is
`failed` and the next round re-asks the same region; `until` is exclusive and `failed` and the next round re-asks the same region; `until` is exclusive and
the walk terminates; a channel that already holds its newest page still pages the walk terminates; a channel that already holds its newest page still pages
older history; the bridge heals a hole; history pages across a rekey using older history; the bridge heals a hole; history pages across a rekey using
retained prior keys. retained prior keys. (Phase 1 landed the `Walk` half; the page-REQ half needs a
- The pump: an `EndOfStoredEvents(id)` settles exactly the page that owns `id`; relay.)
a non-auth CLOSED settles its relay as refused; an `auth-required` CLOSED - The pump — **landed in 2b** (`community/src/lib.rs` tests): an
settles nothing; a 21059 event routes by subscription id; a burst within one `EndOfStoredEvents(id)` settles exactly the page that owns `id`; a non-auth
window produces one fold. CLOSED settles its relay as refused; an `auth-required` CLOSED settles nothing;
- The windows: `Window::opening(default)` has no `since`; `Window::opening(saved)` a page's event routes to nothing; a burst collapses to one signal per
starts at `newest - CURSOR_OVERLAP`; `Window::older_than` never includes the community; a shutdown stops the pump.
boundary event. - The windows — **landed in 2b**: `Window::opening(default)` has no `since`;
- The subscription plan: a private channel's plane appears when the key is held `Window::opening(saved)` starts at `newest - CURSOR_OVERLAP`; `Window::older_than`
and is absent when it is not; the live filter asks for both wrap kinds; the never includes the boundary event; `sync::live_window` is wide cold and resumes
live REQ targets the community's relays only. at the oldest held cursor warm.
- The subscription plan — **landed in 2b**: a private channel's plane appears when
the key is held and is absent when it is not; `plane_filter` asks for both wrap
kinds and addresses every readable plane. The page-REQ half (a warm open's REQ
carries a `since`, a cold open's does not) still needs a dev relay.
- The page registry — **landed in 2b**: a page that has already unregistered
receives nothing.
- The read path: the side-event budget folds an edit/delete/reaction older than - The read path: the side-event budget folds an edit/delete/reaction older than
the row window onto its message. the row window onto its message.
- The fold: a new live wrap costs one decrypt, and a fold over a community with - The fold: a new live wrap costs one decrypt, and a fold over a community with
5,000 cached rows does not re-open them. 5,000 cached rows does not re-open them. (The skip is in place and structurally
tested by `wrapper_index`; counting decrypts needs a harness.)
- A stable cache key: caching the same rumor twice leaves one row (the phase-1 - A stable cache key: caching the same rumor twice leaves one row (the phase-1
regression that duplicates a community's history per app run). regression that duplicates a community's history per app run). **Landed in 2a.**
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a - GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
live message does not scroll a reader who is scrolled up; `has_more == false` live message does not scroll a reader who is scrolled up; `has_more == false`
disables the load-older row. (No GPUI test harness exists in the repo yet.) disables the load-older row. (No GPUI test harness exists in the repo yet.)
+35 -12
View File
@@ -222,19 +222,39 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
Relay history pages through the local cache: Relay history pages through the local cache:
```rust ```rust
use community::history::{self, Window}; use community::history::{self, PageRegistry, Window};
let page = history::page(client, &channel, &held, &relays, Window::newest(), 20, 50).await?; // The same registry `CommunityRegistry` shares with its notification pump: it is
// what carries each page's EOSE and CLOSED back to the round waiting on it.
let page = history::page(
client,
&pages,
&channel,
&held,
&relays,
Window::opening(cursor),
20,
50,
)
.await?;
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?; let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
``` ```
A relay is only ever *asked*: `history::page` subscribes each one to the page's A relay is only ever *asked*: `history::page` installs **one REQ per page** over
filter (which is what makes the client verify a wrap, deduplicate it and persist `ReqTarget::manual` for the community's own relays (which is what makes the client
it), waits for EOSE, and then reads the page back out of the local database — verify a wrap, deduplicate it and persist it), waits for every relay to settle,
no wrap is ever consumed straight off the wire, and `fetch_events` is not used and then reads the page back out of the local database — no wrap is ever consumed
anywhere. The wrap lands in the client's shared event store through that straight off the wire, and `fetch_events` is not used anywhere. The wrap lands in
ordinary ingest path; `cache_rumor` then puts the decrypted rumor beside it, and the client's shared event store through that ordinary ingest path; `cache_rumor`
only the rumor is ever served to a reader. then puts the decrypted rumor beside it, and only the rumor is ever served to a
reader.
EOSE and CLOSED are **not** watched inside the page. The registry's notification
pump is the only consumer of `client.notifications()`; it delivers one
`PageReport` per settling relay into the `PageRegistry` under the page's
subscription id, and the page waits on that channel with `PAGE_TIMEOUT`. The id
is opaque (`concord-history-<n>`) because a NIP-01 id is capped at 64 characters,
and the registry is what maps it back to the waiting round.
A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's
`auth-required`: the SDK re-issues that REQ under the same subscription id once `auth-required`: the SDK re-issues that REQ under the same subscription id once
@@ -246,11 +266,14 @@ reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
`exhausted` is earned only by a short page *after* history was seen, and an `exhausted` is earned only by a short page *after* history was seen, and an
all-empty answer sets `failed` so a later round re-asks instead of sealing the all-empty answer sets `failed` so a later round re-asks instead of sealing the
channel at "no more history". Page down with `Window::older_than(seen.oldest_ms)`, channel at "no more history". Page down with `Window::older_than(seen.oldest_ms)`,
open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm),
and read the region between two cursors with `Window::between(..)`. `query_rumors` and read the region between two cursors with `Window::between(..)`. `query_rumors`
is the read path when the group keys are gone; pass `kinds` to budget rows apart is the read path when the group keys are gone; pass `kinds` to budget rows apart
from the events that only decorate them. Run `cache::purge_expired(client, &channel, now)` from the events that only decorate them. `cache::wrapper_index` reads the cached
on the same cadence as any other local sweep — the timer is cooperative, so the rows back keyed by the wrap they came from, which is what lets `sync::fold` observe
local store is the artifact that has to forget. author and message times without re-opening a wrap it already cached, and
`cache::purge_expired(client, &channel, now)` runs at the top of every round — the
timer is cooperative, so the local store is the artifact that has to forget.
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it `ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
as an inline row only when its author passes as an inline row only when its author passes