Skip to main content

hashtree_cli/fips_transport/
retained_provider.rs

1use super::DaemonNostrCache;
2use crate::{Config, NostrRootResolver};
3use anyhow::{Context, Result};
4use nostr::nips::nip19::ToBech32;
5use nostr::JsonUtil;
6use nostr_pubsub::{
7    EventBus, EventSource, Filter, PublishReport, PubsubProvider, PubsubProviderMode, QueryOptions,
8    QueryReport, VerifiedEvent,
9};
10use nostr_pubsub_fips::FipsPubsubClient;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::task::JoinHandle;
14
15/// Daemon-owned provider; shutdown closes intake and drains owned cache work.
16/// Full daemon shutdown stops replay workers first so already-owned requests
17/// cannot retain the durable namespace through a stopped external handle.
18pub struct DaemonNostrProvider {
19    mode: PubsubProviderMode,
20    state: tokio::sync::RwLock<Option<ProviderState>>,
21}
22
23struct ProviderState {
24    inner: Arc<dyn PubsubProvider>,
25    cache: Option<Arc<DaemonNostrCache>>,
26    client: Option<Arc<FipsPubsubClient>>,
27    intake: Vec<JoinHandle<()>>,
28}
29
30impl Drop for ProviderState {
31    fn drop(&mut self) {
32        for task in &self.intake {
33            task.abort();
34        }
35    }
36}
37
38impl DaemonNostrProvider {
39    pub(super) fn new(
40        inner: Arc<dyn PubsubProvider>,
41        cache: Option<Arc<DaemonNostrCache>>,
42        client: Option<Arc<FipsPubsubClient>>,
43        intake: Vec<JoinHandle<()>>,
44    ) -> Self {
45        Self {
46            mode: inner.mode(),
47            state: tokio::sync::RwLock::new(Some(ProviderState {
48                inner,
49                cache,
50                client,
51                intake,
52            })),
53        }
54    }
55
56    pub async fn shutdown(&self) {
57        let mut state = self.state.write().await;
58        if let Some(mut owned) = state.take() {
59            if let Some(client) = &owned.client {
60                if let Err(error) = client.set_replay_source(None) {
61                    tracing::warn!(%error, "Retained head replay source could not be detached");
62                }
63            }
64            if let Some(cache) = &owned.cache {
65                close_intake(std::mem::take(&mut owned.intake), cache).await;
66            }
67        }
68    }
69}
70
71#[async_trait::async_trait]
72impl EventBus for DaemonNostrProvider {
73    async fn publish(
74        &self,
75        event: VerifiedEvent,
76        source: EventSource,
77    ) -> nostr_pubsub::Result<PublishReport> {
78        // Keep admission alive through the operation. Shutdown waits for any
79        // already admitted read/write before releasing its checkpoint lease.
80        let state = self.state.read().await;
81        let state = state.as_ref().ok_or_else(closed)?;
82        if let Some(cache) = state.cache.as_ref().filter(|_| is_public_head(&event)) {
83            if event.as_event().as_json().len()
84                > nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_FRAME_BYTES
85            {
86                return Err(nostr_pubsub::PubsubError::Validation(
87                    "Signed root exceeds the retained event size limit".into(),
88                ));
89            }
90            let report = cache.publish(event.clone(), source.clone()).await?;
91            if !report.accepted {
92                return Ok(report);
93            }
94        }
95        state.inner.publish(event, source).await
96    }
97
98    async fn query(
99        &self,
100        filters: Vec<Filter>,
101        options: QueryOptions,
102    ) -> nostr_pubsub::Result<QueryReport> {
103        let state = self.state.read().await;
104        state
105            .as_ref()
106            .ok_or_else(closed)?
107            .inner
108            .query(filters, options)
109            .await
110    }
111}
112
113fn closed() -> nostr_pubsub::PubsubError {
114    nostr_pubsub::PubsubError::Storage("Daemon Nostr provider is closed".into())
115}
116
117impl PubsubProvider for DaemonNostrProvider {
118    fn mode(&self) -> PubsubProviderMode {
119        // Provider mode is immutable; held separately from its closeable state.
120        self.mode
121    }
122}
123
124pub(super) fn is_public_head(event: &VerifiedEvent) -> bool {
125    let event = event.as_event();
126    let Some(tree) = event.tags.identifier() else {
127        return false;
128    };
129    let author = event
130        .pubkey
131        .to_bech32()
132        .expect("public key bech32 encoding is infallible");
133    NostrRootResolver::event_matches_key(&format!("{author}/{tree}"), event).unwrap_or(false)
134}
135
136type IntakeEvent = (nostr_pubsub::QueryEvent, bool);
137
138pub(super) async fn start_intake(
139    config: &Config,
140    cache: Arc<DaemonNostrCache>,
141    client: Option<Arc<FipsPubsubClient>>,
142) -> Result<Vec<JoinHandle<()>>> {
143    if config.nostr.retained_roots.len() > 64 {
144        anyhow::bail!("nostr.retained_roots is limited to 64 author/tree keys");
145    }
146    let filters = config
147        .nostr
148        .retained_roots
149        .iter()
150        .map(|key| NostrRootResolver::filter_for_key(key).map(|filter| filter.limit(1)))
151        .collect::<std::result::Result<Vec<_>, _>>()?;
152    if filters.is_empty() {
153        return Ok(Vec::new());
154    }
155    if let Some(client) = &client {
156        let options = client.options();
157        // Reserve a slot for periodic reconciliation in addition to open intake.
158        if filters.len().div_ceil(options.max_filters_per_subscription)
159            >= options.max_active_subscriptions
160        {
161            anyhow::bail!("Retained roots exceed available FIPS subscription capacity");
162        }
163    }
164    // Complete fallible setup before spawning owned tasks.
165    let relay = if config.nostr.relays.is_empty() {
166        None
167    } else {
168        Some(Arc::new(
169            nostr_pubsub_relay::RelayEventBus::new(
170                config.nostr.relays.clone(),
171                Duration::from_secs(5),
172            )
173            .await
174            .context("Retained root relay provider")?,
175        ))
176    };
177    let (sender, receiver) = tokio::sync::mpsc::channel(128);
178    let mut tasks = vec![tokio::spawn(consume_intake(
179        cache,
180        client.clone(),
181        receiver,
182    ))];
183    if let Some(relay) = relay {
184        tasks.push(reconcile(
185            relay.clone(),
186            filters.clone(),
187            sender.clone(),
188            true,
189        ));
190        let filters = filters.clone();
191        let sender = sender.clone();
192        tasks.push(tokio::spawn(async move {
193            loop {
194                let sender = sender.clone();
195                let subscription = relay
196                    .subscribe_with_admission(
197                        filters.clone(),
198                        Arc::new(move |event| {
199                            if !is_public_head(&event.event) {
200                                return true;
201                            }
202                            !matches!(
203                                sender.try_send((event, true)),
204                                Err(tokio::sync::mpsc::error::TrySendError::Full(_))
205                            )
206                        }),
207                    )
208                    .await;
209                if let Ok(subscription) = subscription {
210                    // SDK reconnect keeps this subscription open. Dropping the
211                    // owned task also drops/closes the subscription.
212                    std::future::pending::<()>().await;
213                    let _ = subscription.close().await;
214                }
215                tokio::time::sleep(Duration::from_secs(5)).await;
216            }
217        }));
218    }
219    if let Some(client) = client {
220        tasks.push(reconcile_fips(
221            client.clone(),
222            filters.clone(),
223            sender.clone(),
224        ));
225        for chunk in filters.chunks(client.options().max_filters_per_subscription) {
226            let filters = chunk.to_vec();
227            let client = client.clone();
228            let sender = sender.clone();
229            tasks.push(tokio::spawn(async move {
230                loop {
231                    if let Ok(mut subscription) = client.subscribe(filters.clone()).await {
232                        while let Some(event) = subscription.recv().await {
233                            if sender.send((event, false)).await.is_err() {
234                                return;
235                            }
236                        }
237                    }
238                    tokio::time::sleep(Duration::from_secs(5)).await;
239                }
240            }));
241        }
242    }
243    Ok(tasks)
244}
245
246fn reconcile_fips(
247    client: Arc<FipsPubsubClient>,
248    filters: Vec<Filter>,
249    sender: tokio::sync::mpsc::Sender<IntakeEvent>,
250) -> JoinHandle<()> {
251    tokio::spawn(async move {
252        let mut tick = tokio::time::interval(Duration::from_secs(60) / filters.len() as u32);
253        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
254        for filter in filters.into_iter().cycle() {
255            tick.tick().await;
256            if let Ok(Some(event)) = query_fresh_head(&client, filter).await {
257                if sender.send((event, false)).await.is_err() {
258                    return;
259                }
260            }
261        }
262    })
263}
264
265async fn query_fresh_head(
266    client: &Arc<FipsPubsubClient>,
267    filter: Filter,
268) -> nostr_pubsub::Result<Option<nostr_pubsub::QueryEvent>> {
269    use nostr_pubsub::NostrEventSubscriber;
270    let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
271    let subscription = client
272        .fresh_subscriber()
273        .subscribe(
274            vec![filter],
275            Arc::new(move |event| {
276                let _ = sender.try_send(event);
277            }),
278        )
279        .await?;
280    let event = tokio::time::timeout(client.options().query_timeout, receiver.recv())
281        .await
282        .ok()
283        .flatten();
284    subscription.close().await?;
285    Ok(event)
286}
287
288fn reconcile(
289    source: Arc<dyn EventBus>,
290    filters: Vec<Filter>,
291    sender: tokio::sync::mpsc::Sender<IntakeEvent>,
292    forward: bool,
293) -> JoinHandle<()> {
294    tokio::spawn(async move {
295        // One outstanding one-filter query per transport. A slow relay cannot
296        // block live intake or accumulate an unbounded reconciliation backlog.
297        let mut tick = tokio::time::interval(Duration::from_secs(60) / filters.len() as u32);
298        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
299        for filter in filters.into_iter().cycle() {
300            tick.tick().await;
301            // Results bypass notification dedup, recovering after queue/disk pressure.
302            if let Ok(report) = source
303                .query(vec![filter], QueryOptions { limit: Some(1) })
304                .await
305            {
306                for event in report.events {
307                    if sender.send((event, forward)).await.is_err() {
308                        return;
309                    }
310                }
311            }
312        }
313    })
314}
315
316async fn consume_intake(
317    cache: Arc<DaemonNostrCache>,
318    client: Option<Arc<FipsPubsubClient>>,
319    mut receiver: tokio::sync::mpsc::Receiver<IntakeEvent>,
320) {
321    while let Some((event, forward)) = receiver.recv().await {
322        forward_retained(
323            &cache,
324            if forward { client.as_deref() } else { None },
325            event,
326        )
327        .await;
328    }
329}
330
331async fn close_intake(tasks: Vec<JoinHandle<()>>, cache: &DaemonNostrCache) {
332    for task in &tasks {
333        task.abort();
334    }
335    for task in tasks {
336        let _ = task.await;
337    }
338    // Cancellation cannot abandon an in-flight blocking index commit/read.
339    // The owned operation releases this mutex only after its work finishes.
340    let _ = cache.root_cid().await;
341}
342
343async fn forward_retained(
344    cache: &DaemonNostrCache,
345    client: Option<&FipsPubsubClient>,
346    event: nostr_pubsub::QueryEvent,
347) {
348    if persist(cache, &event).await {
349        if let Some(client) = client {
350            // Announce exactly the signed relay event; no new timestamp or
351            // publisher identity is introduced.
352            let _ = client.publish(event.event, event.source).await;
353        }
354    }
355}
356
357async fn persist(cache: &DaemonNostrCache, event: &nostr_pubsub::QueryEvent) -> bool {
358    if !is_public_head(&event.event) {
359        return false;
360    }
361    if event.event.as_event().as_json().len() > nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_FRAME_BYTES
362    {
363        return false;
364    }
365    match cache
366        .publish(event.event.clone(), event.source.clone())
367        .await
368    {
369        Ok(report) => report.accepted,
370        Err(error) => {
371            tracing::warn!(%error, "Retained signed head could not be committed");
372            false
373        }
374    }
375}
376
377#[cfg(test)]
378#[path = "../../tests/common/mod.rs"]
379mod test_common;
380#[cfg(test)]
381mod tests;