Skip to main content

kindling_client/
spool.rs

1//! A thin, opt-in **durable-emit** layer over [`crate`].
2//!
3//! [`crate::Client::append_observation`] returns
4//! [`ClientError::Unavailable`](crate::ClientError::Unavailable) when
5//! the daemon is down. A producer that wants its observations to survive a
6//! daemon outage therefore has to reinvent a local fallback (this is exactly
7//! why anvil grew a `usage.ndjson`). [`SpooledClient`] centralizes that once.
8//!
9//! # The contract
10//!
11//! **The daemon (SQLite) is always the authoritative store. The spool is a
12//! transient, append-only write buffer — never a parallel source of truth.**
13//!
14//! [`SpooledClient::append_observation`] tries the socket; on a connectivity
15//! failure it appends the observation to a local NDJSON spool file. The spool
16//! is drained into the daemon by [`SpooledClient::flush`] (and opportunistically
17//! at the start of the next *successful* `append_observation`). NDJSON exists
18//! only as a fallback buffer and a debuggable / importable wire format.
19//!
20//! # Delivery semantics
21//!
22//! v1 is **at-least-once**. Before trying or spooling, `append_observation`
23//! assigns a stable [`uuid::Uuid`] v4 to the observation when the caller left
24//! `input.id` as `None`, so a spooled entry and any later replay carry the
25//! *same* id. A crash after the daemon commits but before the spool is rewritten
26//! can therefore replay an already-stored observation. Making this
27//! **exactly-once** requires the daemon to ignore (dedup) a write whose id
28//! already exists — a noted follow-up, not yet implemented.
29//!
30//! # Which failures spool vs propagate
31//!
32//! Only *connectivity* failures buffer to the spool:
33//!
34//! - [`ClientError::Unavailable`](crate::ClientError::Unavailable) and
35//!   [`ClientError::Http`](crate::ClientError::Http) → spool
36//!   ([`AppendOutcome::Spooled`]).
37//! - [`ClientError::Api`](crate::ClientError::Api),
38//!   [`ClientError::SchemaMismatch`](crate::ClientError::SchemaMismatch),
39//!   [`ClientError::Decode`](crate::ClientError::Decode), and
40//!   [`ClientError::Io`](crate::ClientError::Io) → propagate
41//!   ([`SpoolError::Client`]). The daemon *responded*; a rejected observation
42//!   must never be spooled or it would loop forever on every flush.
43//!
44//! # Concurrency
45//!
46//! A spool file is **single-producer**: one [`SpooledClient`] per spool path,
47//! mirroring the daemon's single-writer rule. All read/append/rewrite file ops
48//! are serialized by an in-process [`tokio::sync::Mutex`]. There is no
49//! cross-process lock in v1.
50
51use std::path::{Path, PathBuf};
52
53use serde::{Deserialize, Serialize};
54use tokio::sync::Mutex;
55use uuid::Uuid;
56
57use crate::{Client, ClientError};
58use kindling_types::{Id, Observation, ObservationInput};
59
60/// Configuration for a [`SpooledClient`].
61///
62/// Carries just the spool file path today; kept as a struct so additional
63/// knobs (rotation, size caps) can be added without breaking callers.
64#[derive(Debug, Clone)]
65pub struct SpoolConfig {
66    /// Path to the append-only NDJSON spool file. Created on first spool.
67    pub spool_path: PathBuf,
68}
69
70impl SpoolConfig {
71    /// Build a config from a spool path.
72    pub fn new(spool_path: impl Into<PathBuf>) -> Self {
73        Self {
74            spool_path: spool_path.into(),
75        }
76    }
77}
78
79/// A single buffered observation request, one per NDJSON line.
80///
81/// Field names are camelCase to match the wire shapes of the wrapped
82/// `append_observation` arguments, so a spool file doubles as a debuggable /
83/// importable record.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SpoolEntry {
87    /// The observation input (its `id` is always populated by the time it is
88    /// spooled, so replay is idempotent on id).
89    pub input: ObservationInput,
90    /// Optional capsule to attach the observation to.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub capsule_id: Option<Id>,
93    /// Optional service-side validation toggle.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub validate: Option<bool>,
96}
97
98/// Outcome of [`SpooledClient::append_observation`].
99#[derive(Debug)]
100pub enum AppendOutcome {
101    /// Written straight to the daemon; carries the stored [`Observation`].
102    ///
103    /// The `Observation` is boxed so the enum stays small (the `Spooled`
104    /// variant carries no data).
105    Delivered(Box<Observation>),
106    /// Daemon unreachable — the request was buffered to the spool.
107    Spooled,
108}
109
110/// Result of [`SpooledClient::flush`].
111#[derive(Debug, PartialEq, Eq)]
112pub struct FlushReport {
113    /// Number of spooled entries successfully replayed into the daemon.
114    pub replayed: usize,
115    /// Number of entries still buffered (kept) after the flush stopped.
116    pub remaining: usize,
117}
118
119/// Errors from [`SpooledClient`] operations.
120///
121/// Note that a daemon *outage* is **not** an error from
122/// [`SpooledClient::append_observation`] — it returns [`AppendOutcome::Spooled`].
123/// [`SpoolError::Client`] only carries the *propagated* client errors (the
124/// daemon responded and rejected the request).
125#[derive(Debug, thiserror::Error)]
126pub enum SpoolError {
127    /// A spool-file I/O failure (open, read, append, temp-write, rename).
128    #[error("spool io error: {0}")]
129    Io(#[from] std::io::Error),
130
131    /// A spool entry could not be (de)serialized.
132    #[error("spool serde error: {0}")]
133    Serde(#[from] serde_json::Error),
134
135    /// The daemon responded with a non-connectivity error that must not be
136    /// spooled (`Api`, `SchemaMismatch`, `Decode`, `Io`).
137    #[error("client error: {0}")]
138    Client(#[from] ClientError),
139}
140
141/// A durable-emit wrapper around a [`crate::Client`].
142///
143/// Holds the client, the spool path, and an in-process [`Mutex`] that serializes
144/// every spool-file operation (single-producer-per-spool-file).
145#[derive(Debug)]
146pub struct SpooledClient {
147    client: Client,
148    spool_path: PathBuf,
149    /// Serializes read/append/rewrite of the spool file. The mutex guards the
150    /// *file*, not the client (the client is internally `Send + Sync`).
151    file_lock: Mutex<()>,
152}
153
154impl SpooledClient {
155    /// Build a durable-emit client over `client`, buffering to `spool_path`.
156    pub fn new(client: Client, spool_path: PathBuf) -> Self {
157        Self {
158            client,
159            spool_path,
160            file_lock: Mutex::new(()),
161        }
162    }
163
164    /// Build from a [`SpoolConfig`].
165    pub fn with_config(client: Client, config: SpoolConfig) -> Self {
166        Self::new(client, config.spool_path)
167    }
168
169    /// Borrow the underlying client for reads / non-spooled ops (retrieve,
170    /// health, pin, capsules, …). Only `append_observation` is durability-wrapped.
171    pub fn client(&self) -> &Client {
172        &self.client
173    }
174
175    /// Append an observation durably.
176    ///
177    /// Assigns a stable v4 id when `input.id` is `None` (so a spooled entry and
178    /// any replay share one id — see the crate-level *Delivery semantics*).
179    /// Then:
180    ///
181    /// 1. If the spool already has buffered entries, opportunistically
182    ///    [`flush`](Self::flush) them **first** so the daemon observes them in
183    ///    append order ahead of this new one.
184    /// 2. Try the daemon. On success → [`AppendOutcome::Delivered`].
185    /// 3. On a *connectivity* failure (`Unavailable` / `Http`) → buffer to the
186    ///    spool and return [`AppendOutcome::Spooled`] (never an error).
187    /// 4. On any other client error → propagate as [`SpoolError::Client`]; the
188    ///    observation is **not** spooled.
189    pub async fn append_observation(
190        &self,
191        mut input: ObservationInput,
192        capsule_id: Option<Id>,
193        validate: Option<bool>,
194    ) -> Result<AppendOutcome, SpoolError> {
195        // Stable id BEFORE any try/spool, so replay is idempotent on id.
196        if input.id.is_none() {
197            input.id = Some(Uuid::new_v4().to_string());
198        }
199
200        // Opportunistic drain: if there is a backlog, try to clear it first so
201        // ordering is preserved (backlog lands before this new observation).
202        // If the drain can't reach the daemon, this new entry will spool behind
203        // it below, still in order.
204        if self.pending_count()? > 0 {
205            // Best-effort: a connectivity failure here is fine — we proceed and
206            // (most likely) spool the new entry behind the backlog. A
207            // *propagating* client error from a backlog entry must surface.
208            self.flush().await?;
209        }
210
211        match self
212            .client
213            .append_observation(input.clone(), capsule_id.clone(), validate)
214            .await
215        {
216            Ok(observation) => Ok(AppendOutcome::Delivered(Box::new(observation))),
217            Err(err) if is_connectivity_error(&err) => {
218                let entry = SpoolEntry {
219                    input,
220                    capsule_id,
221                    validate,
222                };
223                self.append_to_spool(&entry).await?;
224                Ok(AppendOutcome::Spooled)
225            }
226            Err(err) => Err(SpoolError::Client(err)),
227        }
228    }
229
230    /// Drain the spool into the daemon, in order.
231    ///
232    /// Replays each buffered entry via the client. Stops at the first
233    /// *connectivity* failure (`Unavailable` / `Http`), keeping that entry and
234    /// the remainder. A non-connectivity client error also stops the drain and
235    /// is propagated, but the *un-replayed remainder (including the rejected
236    /// entry) is preserved* — data is never silently dropped.
237    ///
238    /// The spool file is rewritten with exactly the un-replayed remainder via a
239    /// temp-file-then-rename, so a crash mid-flush cannot corrupt the spool.
240    pub async fn flush(&self) -> Result<FlushReport, SpoolError> {
241        let _guard = self.file_lock.lock().await;
242
243        let entries = read_spool(&self.spool_path)?;
244        let total = entries.len();
245        if total == 0 {
246            return Ok(FlushReport {
247                replayed: 0,
248                remaining: 0,
249            });
250        }
251
252        let mut replayed = 0usize;
253        let mut propagate: Option<ClientError> = None;
254
255        for (idx, entry) in entries.iter().enumerate() {
256            match self
257                .client
258                .append_observation(
259                    entry.input.clone(),
260                    entry.capsule_id.clone(),
261                    entry.validate,
262                )
263                .await
264            {
265                Ok(_) => replayed += 1,
266                Err(err) if is_connectivity_error(&err) => break,
267                Err(err) => {
268                    // Non-connectivity rejection: stop, keep this entry + the
269                    // remainder, and propagate after rewriting the spool. We do
270                    // NOT advance `replayed` for this entry, so it stays buffered.
271                    let _ = idx;
272                    propagate = Some(err);
273                    break;
274                }
275            }
276        }
277
278        let remainder = &entries[replayed..];
279        rewrite_spool(&self.spool_path, remainder)?;
280
281        if let Some(err) = propagate {
282            return Err(SpoolError::Client(err));
283        }
284
285        Ok(FlushReport {
286            replayed,
287            remaining: remainder.len(),
288        })
289    }
290
291    /// Count of pending (un-replayed) spool entries.
292    pub fn pending_count(&self) -> Result<usize, SpoolError> {
293        Ok(read_spool(&self.spool_path)?.len())
294    }
295
296    /// Append one entry to the spool file (create + append), serialized by the
297    /// file lock.
298    async fn append_to_spool(&self, entry: &SpoolEntry) -> Result<(), SpoolError> {
299        use std::io::Write;
300
301        let _guard = self.file_lock.lock().await;
302        let line = serde_json::to_string(entry)?;
303        let mut file = std::fs::OpenOptions::new()
304            .create(true)
305            .append(true)
306            .open(&self.spool_path)?;
307        file.write_all(line.as_bytes())?;
308        file.write_all(b"\n")?;
309        file.flush()?;
310        Ok(())
311    }
312}
313
314/// Connectivity failures buffer to the spool; everything else propagates.
315fn is_connectivity_error(err: &ClientError) -> bool {
316    matches!(err, ClientError::Unavailable(_) | ClientError::Http(_))
317}
318
319/// Read all parseable spool entries in order.
320///
321/// A missing file is an empty spool. A torn trailing line (crash mid-write) is
322/// tolerated: only the *last* line is allowed to fail to parse, in which case it
323/// is skipped and the preceding good entries are returned. A malformed line that
324/// is *not* the last is a corruption we surface as an error (it would otherwise
325/// silently drop a buffered observation).
326fn read_spool(path: &Path) -> Result<Vec<SpoolEntry>, SpoolError> {
327    let contents = match std::fs::read_to_string(path) {
328        Ok(c) => c,
329        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
330        Err(e) => return Err(SpoolError::Io(e)),
331    };
332
333    // Split on '\n'. Trailing newline yields a final empty segment we ignore.
334    let lines: Vec<&str> = contents.split('\n').collect();
335    let mut entries = Vec::new();
336    let last_idx = lines.len().saturating_sub(1);
337
338    for (idx, raw) in lines.iter().enumerate() {
339        let line = raw.trim_end_matches('\r');
340        if line.is_empty() {
341            continue;
342        }
343        match serde_json::from_str::<SpoolEntry>(line) {
344            Ok(entry) => entries.push(entry),
345            Err(e) => {
346                // Tolerate a torn trailing line only.
347                if idx == last_idx {
348                    break;
349                }
350                return Err(SpoolError::Serde(e));
351            }
352        }
353    }
354    Ok(entries)
355}
356
357/// Rewrite the spool file with exactly `entries`, atomically via temp + rename.
358///
359/// Writing an empty remainder truncates the spool to empty (file removed if it
360/// exists, leaving a clean state).
361fn rewrite_spool(path: &Path, entries: &[SpoolEntry]) -> Result<(), SpoolError> {
362    use std::io::Write;
363
364    if entries.is_empty() {
365        match std::fs::remove_file(path) {
366            Ok(()) => Ok(()),
367            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
368            Err(e) => Err(SpoolError::Io(e)),
369        }
370    } else {
371        let tmp = temp_sibling(path);
372        {
373            let mut file = std::fs::File::create(&tmp)?;
374            for entry in entries {
375                let line = serde_json::to_string(entry)?;
376                file.write_all(line.as_bytes())?;
377                file.write_all(b"\n")?;
378            }
379            file.flush()?;
380        }
381        std::fs::rename(&tmp, path)?;
382        Ok(())
383    }
384}
385
386/// A temp sibling path next to `path` (same directory, so `rename` is atomic on
387/// the same filesystem).
388fn temp_sibling(path: &Path) -> PathBuf {
389    let mut name = path
390        .file_name()
391        .map(|n| n.to_os_string())
392        .unwrap_or_default();
393    name.push(format!(".tmp-{}", Uuid::new_v4()));
394    match path.parent() {
395        Some(dir) => dir.join(name),
396        None => PathBuf::from(name),
397    }
398}