Skip to main content

bsv_wallet_cli/
chain_feed.rs

1//! The header service's chain-event feed, held by the daemon.
2//!
3//! The header service (`CHAINTRACKS_URL`, required, no default: P0-1c)
4//! journals every tip move, fork, reorg, invalidation, freeze, tip age and
5//! rung split as an envelope with a cursor (chaintracks-cloudflare v0.4.1
6//! `docs/CHAIN-EVENTS.md`, "Delivery and cursors"). The daemon holds one
7//! loop over it:
8//!
9//! 1. On start, replay from the stored cursor (`0` at birth) through the
10//!    page endpoint, `GET /events?since=<cursor>`, until `hasMore` is false.
11//! 2. Then hold the stream, `GET /events/stream?since=<cursor>` (SSE: an
12//!    `id:` per envelope, comment heartbeats).
13//! 3. Every envelope goes through `bsv_tracker::decode_envelope` and
14//!    `Tracker::on_chain` with the daemon's headers
15//!    ([`tracker_host::deliver_chain_event`]); the cursor is stored after
16//!    each delivery, in the same transaction as the rows it moved, in the
17//!    database the tracker's rows live in (`tracker_meta`, key
18//!    `feed_cursor`: one row, one column).
19//! 4. A dropped stream (EOF without an error event) reconnects: the page
20//!    from the stored cursor, then the stream with `Last-Event-ID`.
21//! 5. A decode fault, a cursor ahead of the journal (HTTP 409
22//!    `cursorAhead`), an `event: error` (`feedUnavailable`) or a refused
23//!    request (400, 404) is reported through the daemon's status
24//!    ([`FeedStatus::fault`]: its kind, the cursor the loop stopped at, the
25//!    offending envelope's cursor, the fault text) and the loop stops there:
26//!    no skip, no acknowledgment, no silent reconnect past it (the tracker's
27//!    host rule, bsv-tracker 0.4.0 `docs/CHAIN-EVENTS.md`). A restart of the
28//!    daemon starts again from the stored cursor.
29//!
30//! # Load
31//!
32//! One outbound stream per daemon, plus one page request per (re)connect
33//! (more only while the journal answers `hasMore`). The daemon runs nothing
34//! periodic of its own for this: between envelopes it waits on the stream,
35//! and the header service sends the heartbeats. A transport failure (no
36//! answer, a 5xx, a 429) is retried after a pause that doubles from one
37//! second to five minutes. The header service is read beyond the feed only
38//! when an envelope names a tracked transaction: its tip and the heights of
39//! the named transactions' proofs.
40
41use std::time::Duration;
42
43use anyhow::Result;
44use bsv_tracker::{decode_envelope, Clock};
45use bsv_wallet_toolbox::{StorageSqlx, WalletServices};
46use serde::{Deserialize, Serialize};
47
48use crate::tracker_host::{self, TickOptions};
49
50/// The pauses of a reconnect after a transport failure.
51#[derive(Debug, Clone)]
52pub struct FeedOptions {
53    /// The first pause, and the pause after a stream that delivered.
54    pub min_pause: Duration,
55    /// The pause doubles up to this.
56    pub max_pause: Duration,
57}
58
59impl Default for FeedOptions {
60    fn default() -> Self {
61        Self {
62            min_pause: Duration::from_secs(1),
63            max_pause: Duration::from_secs(300),
64        }
65    }
66}
67
68/// Why the loop stopped. The loop never moves past it.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct FeedFault {
72    /// `decode` (an envelope or page the decoder refused), `cursorAhead`
73    /// (HTTP 409), `feedUnavailable` (an SSE `event: error`), or `refused`
74    /// (HTTP 400 or 404: a malformed cursor, or no feed at this source).
75    pub kind: String,
76    /// The stored cursor: the last envelope delivered, where the loop
77    /// stopped.
78    pub cursor: String,
79    /// The offending envelope's cursor, when it carried one.
80    pub event_cursor: Option<String>,
81    /// The fault, as the decoder or the header service gave it.
82    pub detail: String,
83}
84
85/// The daemon's view of its feed, stored in `tracker_meta` (key
86/// `feed_status`) and served at `GET /chain-feed`. Rewritten at every
87/// change of state and after every delivery.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct FeedStatus {
91    /// The header service (`CHAINTRACKS_URL`).
92    pub source: String,
93    /// `replaying` (pages), `following` (the stream), `reconnecting`
94    /// (after a transport failure or a dropped stream) or `stopped`.
95    pub state: String,
96    /// The stored cursor.
97    pub cursor: String,
98    /// Envelopes delivered since the daemon started.
99    pub delivered: u64,
100    /// Stream connections after the first.
101    pub reconnects: u64,
102    /// Set when the loop stopped: what it stopped on.
103    pub fault: Option<FeedFault>,
104    /// The last transport failure, retried.
105    pub last_error: Option<String>,
106    /// Seconds since the epoch, at the last change or delivery.
107    pub updated_at: u64,
108}
109
110/// The status the feed stored last, if it ever ran here.
111pub async fn read_status(storage: &StorageSqlx) -> Result<Option<FeedStatus>> {
112    let value: Option<String> =
113        sqlx::query_scalar("SELECT value FROM tracker_meta WHERE key = 'feed_status'")
114            .fetch_optional(storage.pool())
115            .await
116            .unwrap_or(None);
117    Ok(match value {
118        Some(v) => Some(serde_json::from_str(&v)?),
119        None => None,
120    })
121}
122
123async fn write_status(storage: &StorageSqlx, status: &FeedStatus) {
124    let written = sqlx::query(
125        "INSERT INTO tracker_meta (key, value) VALUES ('feed_status', ?) \
126         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
127    )
128    .bind(serde_json::to_string(status).unwrap_or_default())
129    .execute(storage.pool())
130    .await;
131    if let Err(e) = written {
132        tracing::warn!(error = %e, "chain feed: the status was not stored");
133    }
134}
135
136/// How one leg (the page replay, or one stream connection) ended.
137enum Leg {
138    /// The stream ended without an error event.
139    Dropped { delivered: bool },
140    /// Stop here and report.
141    Stop(FeedFault),
142    /// Retry after a pause.
143    Transient(String),
144}
145
146struct Feed<'a, V: ?Sized, C> {
147    base: String,
148    client: reqwest::Client,
149    storage: &'a StorageSqlx,
150    services: &'a V,
151    clock: &'a C,
152    opts: &'a TickOptions,
153    status: FeedStatus,
154}
155
156/// Hold the feed at `base` until it stops on a fault; return the status it
157/// stopped with (also stored). See the module docs.
158pub async fn run<V: WalletServices + ?Sized, C: Clock>(
159    base: &str,
160    storage: &StorageSqlx,
161    services: &V,
162    clock: &C,
163    opts: &TickOptions,
164    feed_opts: &FeedOptions,
165) -> FeedStatus {
166    let base = base.trim_end_matches('/').to_string();
167    let cursor = match tracker_host::feed_cursor(storage).await {
168        Ok(c) => c,
169        Err(e) => {
170            let status = FeedStatus {
171                source: base,
172                state: "stopped".into(),
173                cursor: "?".into(),
174                delivered: 0,
175                reconnects: 0,
176                fault: Some(FeedFault {
177                    kind: "decode".into(),
178                    cursor: "?".into(),
179                    event_cursor: None,
180                    detail: format!("the stored cursor: {e}"),
181                }),
182                last_error: None,
183                updated_at: clock.now(),
184            };
185            write_status(storage, &status).await;
186            return status;
187        }
188    };
189    let client = reqwest::Client::builder()
190        .connect_timeout(Duration::from_secs(10))
191        .build()
192        .unwrap_or_default();
193    let mut feed = Feed {
194        status: FeedStatus {
195            source: base.clone(),
196            state: "replaying".into(),
197            cursor: cursor.to_string(),
198            delivered: 0,
199            reconnects: 0,
200            fault: None,
201            last_error: None,
202            updated_at: clock.now(),
203        },
204        base,
205        client,
206        storage,
207        services,
208        clock,
209        opts,
210    };
211    let mut pause = feed_opts.min_pause;
212    let mut connects: u64 = 0;
213    loop {
214        feed.set_state("replaying").await;
215        let leg = match feed.replay().await {
216            Ok(()) => {
217                feed.set_state("following").await;
218                let leg = feed.stream(connects > 0).await;
219                connects += 1;
220                feed.status.reconnects = connects - 1;
221                leg
222            }
223            Err(leg) => leg,
224        };
225        match leg {
226            Leg::Stop(fault) => return feed.stop(fault).await,
227            Leg::Dropped { delivered } => {
228                if delivered {
229                    pause = feed_opts.min_pause;
230                }
231                feed.status.last_error = Some("the stream ended without an error event".into());
232                tracing::info!(
233                    cursor = %feed.status.cursor,
234                    "chain feed: the stream dropped; reconnecting from the stored cursor"
235                );
236            }
237            Leg::Transient(e) => {
238                tracing::warn!(cursor = %feed.status.cursor, error = %e, "chain feed: retrying");
239                feed.status.last_error = Some(e);
240            }
241        }
242        feed.set_state("reconnecting").await;
243        tokio::time::sleep(pause).await;
244        pause = (pause * 2).min(feed_opts.max_pause);
245    }
246}
247
248impl<V: WalletServices + ?Sized, C: Clock> Feed<'_, V, C> {
249    async fn set_state(&mut self, state: &str) {
250        self.status.state = state.into();
251        self.status.updated_at = self.clock.now();
252        write_status(self.storage, &self.status).await;
253    }
254
255    async fn stop(mut self, fault: FeedFault) -> FeedStatus {
256        tracing::error!(
257            kind = %fault.kind,
258            cursor = %fault.cursor,
259            event_cursor = ?fault.event_cursor,
260            detail = %fault.detail,
261            "chain feed stopped at its cursor; nothing past it is delivered until the daemon restarts"
262        );
263        self.status.fault = Some(fault);
264        self.set_state("stopped").await;
265        self.status
266    }
267
268    fn fault(&self, kind: &str, event_cursor: Option<&str>, detail: impl Into<String>) -> Leg {
269        Leg::Stop(FeedFault {
270            kind: kind.into(),
271            cursor: self.status.cursor.clone(),
272            event_cursor: event_cursor.map(str::to_string),
273            detail: detail.into(),
274        })
275    }
276
277    /// Read an HTTP answer's refusals: 409 and 400/404 stop, other
278    /// failures retry.
279    async fn refusal(&self, resp: reqwest::Response) -> Leg {
280        let code = resp.status();
281        let body = resp.text().await.unwrap_or_default();
282        let detail = format!("HTTP {}: {}", code.as_u16(), body.trim());
283        match code.as_u16() {
284            409 => self.fault("cursorAhead", None, detail),
285            400 | 404 | 405 | 410 => self.fault("refused", None, detail),
286            _ => Leg::Transient(detail),
287        }
288    }
289
290    /// Deliver one envelope at `cursor`. A fault stops the loop at the
291    /// stored cursor; a storage failure is retried from it.
292    async fn deliver(&mut self, cursor: &str, bytes: &[u8]) -> std::result::Result<(), Leg> {
293        let Ok(at) = cursor.trim().parse::<u64>() else {
294            return Err(self.fault("decode", Some(cursor), "a cursor that is not a decimal"));
295        };
296        let event = match decode_envelope(bytes) {
297            Ok(event) => event,
298            Err(fault) => return Err(self.fault("decode", Some(cursor), fault.to_string())),
299        };
300        let delivery = tracker_host::deliver_chain_event(
301            self.storage,
302            self.services,
303            self.clock,
304            self.opts,
305            at,
306            event,
307        )
308        .await
309        .map_err(|e| Leg::Transient(format!("the delivery at {cursor} was not stored: {e}")))?;
310        if at > self.status.cursor.parse().unwrap_or(0) {
311            self.status.cursor = at.to_string();
312            self.status.delivered += 1;
313        }
314        // The status is rewritten after every delivery (N = 1), so its
315        // cursor is the stored cursor once this write lands, while
316        // replaying and while following.
317        self.status.updated_at = self.clock.now();
318        write_status(self.storage, &self.status).await;
319        for moved in &delivery.moved {
320            tracing::info!(
321                cursor = at,
322                kind = delivery.kind,
323                txid = %moved.txid,
324                word = %moved.word,
325                reask = ?moved.reask,
326                suspect = moved.suspect,
327                "chain feed: a chain event moved a tracked transaction"
328            );
329        }
330        Ok(())
331    }
332
333    /// The pages from the stored cursor until `hasMore` is false.
334    async fn replay(&mut self) -> std::result::Result<(), Leg> {
335        loop {
336            let since = self.status.cursor.clone();
337            let url = format!("{}/events?since={since}", self.base);
338            let resp = self
339                .client
340                .get(&url)
341                .send()
342                .await
343                .map_err(|e| Leg::Transient(format!("GET /events: {e}")))?;
344            if !resp.status().is_success() {
345                return Err(self.refusal(resp).await);
346            }
347            let page: Page = match resp.json().await {
348                Ok(page) => page,
349                Err(e) => return Err(self.fault("decode", None, format!("a page: {e}"))),
350            };
351            for item in &page.events {
352                let bytes = serde_json::to_vec(&item.event).unwrap_or_default();
353                self.deliver(&item.cursor, &bytes).await?;
354            }
355            if !page.has_more {
356                return Ok(());
357            }
358            if page.events.is_empty() || page.cursor == since {
359                return Err(Leg::Transient(format!(
360                    "a page at {since} said hasMore and moved no cursor"
361                )));
362            }
363        }
364    }
365
366    /// One stream connection from the stored cursor.
367    async fn stream(&mut self, reconnect: bool) -> Leg {
368        let since = self.status.cursor.clone();
369        let url = format!("{}/events/stream?since={since}", self.base);
370        let mut request = self.client.get(&url).header("Accept", "text/event-stream");
371        if reconnect {
372            request = request.header("Last-Event-ID", &since);
373        }
374        let mut resp = match request.send().await {
375            Ok(resp) => resp,
376            Err(e) => return Leg::Transient(format!("GET /events/stream: {e}")),
377        };
378        if !resp.status().is_success() {
379            return self.refusal(resp).await;
380        }
381        let mut buffer: Vec<u8> = Vec::new();
382        let mut delivered = false;
383        loop {
384            let chunk = match resp.chunk().await {
385                Ok(Some(chunk)) => chunk,
386                Ok(None) | Err(_) => return Leg::Dropped { delivered },
387            };
388            buffer.extend(chunk.iter().filter(|b| **b != b'\r'));
389            while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
390                let block: Vec<u8> = buffer.drain(..end + 2).collect();
391                let message = SseMessage::parse(&String::from_utf8_lossy(&block));
392                if message.event.as_deref() == Some("error") {
393                    return self.fault(
394                        "feedUnavailable",
395                        message.id.as_deref(),
396                        message.data.unwrap_or_default(),
397                    );
398                }
399                let Some(data) = message.data else {
400                    continue;
401                };
402                let Some(id) = message.id else {
403                    return self.fault("decode", None, format!("an envelope with no id: {data}"));
404                };
405                if let Err(leg) = self.deliver(&id, data.as_bytes()).await {
406                    return leg;
407                }
408                delivered = true;
409            }
410        }
411    }
412}
413
414/// `GET /events` (the page carrier, version 1).
415#[derive(Debug, Deserialize)]
416#[serde(rename_all = "camelCase")]
417struct Page {
418    cursor: String,
419    events: Vec<PageItem>,
420    has_more: bool,
421}
422
423#[derive(Debug, Deserialize)]
424struct PageItem {
425    cursor: String,
426    event: serde_json::Value,
427}
428
429/// One SSE message: its `id`, `event` and joined `data` lines; comments
430/// (heartbeats) are dropped.
431#[derive(Debug, Default, PartialEq, Eq)]
432struct SseMessage {
433    id: Option<String>,
434    event: Option<String>,
435    data: Option<String>,
436}
437
438impl SseMessage {
439    fn parse(block: &str) -> Self {
440        let mut message = Self::default();
441        for line in block.lines() {
442            if line.is_empty() || line.starts_with(':') {
443                continue;
444            }
445            let (field, value) = line.split_once(':').unwrap_or((line, ""));
446            let value = value.strip_prefix(' ').unwrap_or(value);
447            match field {
448                "id" => message.id = Some(value.to_string()),
449                "event" => message.event = Some(value.to_string()),
450                "data" => {
451                    message.data = Some(match message.data.take() {
452                        Some(previous) => format!("{previous}\n{value}"),
453                        None => value.to_string(),
454                    })
455                }
456                _ => {}
457            }
458        }
459        message
460    }
461}
462
463#[cfg(test)]
464#[path = "chain_feed_tests.rs"]
465mod tests;