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}