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//! Delivery is **exactly-once-ish on id**. Before trying or spooling,
23//! `append_observation` assigns a stable [`uuid::Uuid`] v4 to the observation
24//! when the caller left `input.id` as `None`, so a spooled entry and any later
25//! replay carry the *same* id. A crash after the daemon commits but before the
26//! spool is rewritten can therefore replay an already-stored observation — but
27//! the daemon now **deduplicates** on id: a write whose id already exists is
28//! ignored (the stored row is returned untouched, never overwritten or
29//! re-masked), surfaced via [`AppendResult::deduplicated`](crate::AppendResult).
30//! So a replay is an observable no-op rather than a duplicate row.
31//!
32//! Dedup only protects the **committed-but-not-yet-drained** window: an id was
33//! already assigned, the daemon stored the row, and a crash left a stale spool
34//! entry to replay. It does **not** make delivery durable end-to-end. In
35//! particular, an observation that is lost *before* its id reaches durable
36//! state — e.g. a crash between id assignment and the spool append, or a write
37//! the caller never spooled — is simply lost: there is nothing to replay, so
38//! nothing to dedup. So this is "at-most-once delivery of each *attempt*, with
39//! exactly-once *application* of whatever does reach the daemon", not
40//! at-least-once durability. ("-ish" also because the dedup key is the
41//! observation id; two genuinely distinct writes that reuse an id would
42//! collapse to one.)
43//!
44//! # Which failures spool vs propagate
45//!
46//! Only *connectivity* failures buffer to the spool:
47//!
48//! - [`ClientError::Unavailable`](crate::ClientError::Unavailable) and
49//! [`ClientError::Http`](crate::ClientError::Http) → spool
50//! ([`AppendOutcome::Spooled`]).
51//! - [`ClientError::Api`](crate::ClientError::Api),
52//! [`ClientError::SchemaMismatch`](crate::ClientError::SchemaMismatch),
53//! [`ClientError::Decode`](crate::ClientError::Decode), and
54//! [`ClientError::Io`](crate::ClientError::Io) → propagate
55//! ([`SpoolError::Client`]). The daemon *responded*; a rejected observation
56//! must never be spooled or it would loop forever on every flush.
57//!
58//! # Concurrency
59//!
60//! A spool file is **single-producer**: one [`SpooledClient`] per spool path,
61//! mirroring the daemon's single-writer rule. All read/append/rewrite file ops
62//! are serialized by an in-process [`tokio::sync::Mutex`]. There is no
63//! cross-process lock in v1.
64//!
65//! # Retention
66//!
67//! By default the spool is **unbounded**. A caller that wants a bounded buffer
68//! (e.g. one replacing a rolling size/age-capped sidecar) opts into a cap via
69//! [`SpoolConfig::with_max_bytes`] / [`SpoolConfig::with_max_age_ms`]. When a cap
70//! is set, [`SpooledClient::flush`] trims the **oldest** entries from the
71//! retained remainder — age first, then bytes — under the same file lock as the
72//! rewrite, so trimming never races a drain. Because only a contiguous oldest
73//! prefix is dropped, drain ordering is preserved and an un-drained entry is
74//! never dropped while a strictly newer one is kept ahead of it.
75//!
76//! Trimming is **intentional, bounded retention loss** and is a *different*
77//! contract from the delivery semantics above: under an outage that outlasts the
78//! cap, the oldest un-drained entries are discarded (exactly what the capped
79//! sidecar it replaces did). "Respect at-least-once" here means *do not reorder
80//! and do not drop a newer entry while keeping an older one* — not infinite
81//! retention. Shed entries are counted in
82//! [`SpoolStatus::dropped_count`](crate::SpoolStatus). The append-under-outage
83//! path stays near the cap because `append_observation` opportunistically
84//! `flush`es (and therefore trims) whenever a backlog already exists, so the
85//! spool exceeds the cap by at most the entry appended since the last flush.
86
87use std::path::{Path, PathBuf};
88use std::time::{SystemTime, UNIX_EPOCH};
89
90use serde::{Deserialize, Serialize};
91use tokio::sync::Mutex;
92use uuid::Uuid;
93
94use crate::{AppendResult, Client, ClientError};
95use kindling_types::{Id, ObservationInput};
96
97/// Live + on-disk spool observability snapshot.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct SpoolStatus {
101 /// Count of buffered NDJSON entries not yet replayed into the daemon.
102 pub pending_count: usize,
103 /// Path to the NDJSON spool file this status describes.
104 pub spool_path: PathBuf,
105 /// Epoch milliseconds of the last successful flush.
106 #[serde(default)]
107 pub last_flush_time_ms: Option<i64>,
108 /// Last connectivity or flush error observed for this spool file.
109 #[serde(default)]
110 pub last_error: Option<String>,
111 /// Cumulative replay attempts (each flush try per spooled entry).
112 pub replay_attempts: u64,
113 /// Cumulative entries dropped by retention trimming (size/age caps). Always
114 /// `0` when no cap is configured.
115 #[serde(default)]
116 pub dropped_count: u64,
117}
118
119/// Flush/error/replay counters for a spool file (in-memory + sidecar).
120#[derive(Debug, Default, Clone, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122struct SpoolRuntime {
123 last_flush_time_ms: Option<i64>,
124 last_error: Option<String>,
125 replay_attempts: u64,
126 #[serde(default)]
127 dropped_count: u64,
128}
129
130/// Configuration for a [`SpooledClient`].
131///
132/// `#[non_exhaustive]` so future knobs can be added without breaking callers;
133/// construct via [`SpoolConfig::new`] plus the `with_*` builders rather than a
134/// struct literal.
135///
136/// # Retention
137///
138/// By default the spool is **unbounded** (`max_bytes` and `max_age_ms` are
139/// `None`) — existing behaviour, no surprise eviction on upgrade. Opt into a
140/// rolling cap with [`with_max_bytes`](Self::with_max_bytes) /
141/// [`with_max_age_ms`](Self::with_max_age_ms); see [`SpooledClient::flush`] for
142/// the trim semantics. The cap *values* are the caller's policy (e.g. a
143/// downstream replacing a 7-day / 64 MiB sidecar wires those numbers here);
144/// kindling only provides the mechanism.
145#[derive(Debug, Clone)]
146#[non_exhaustive]
147pub struct SpoolConfig {
148 /// Path to the append-only NDJSON spool file. Created on first spool.
149 pub spool_path: PathBuf,
150 /// Rolling byte cap; `None` = unbounded. When set, [`SpooledClient::flush`]
151 /// trims the oldest entries until the retained spool fits (a lone entry
152 /// larger than the cap is kept — the cap is a high-water target).
153 pub max_bytes: Option<u64>,
154 /// Rolling age cap in milliseconds; `None` = unbounded. Entries whose
155 /// `spooled_at` is older than `now - max_age_ms` are trimmed from the front.
156 pub max_age_ms: Option<i64>,
157}
158
159impl SpoolConfig {
160 /// Build an unbounded config from a spool path.
161 pub fn new(spool_path: impl Into<PathBuf>) -> Self {
162 Self {
163 spool_path: spool_path.into(),
164 max_bytes: None,
165 max_age_ms: None,
166 }
167 }
168
169 /// Set the rolling byte cap (high-water target; oldest entries trimmed first).
170 pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
171 self.max_bytes = Some(max_bytes);
172 self
173 }
174
175 /// Set the rolling age cap in milliseconds (oldest entries trimmed first).
176 pub fn with_max_age_ms(mut self, max_age_ms: i64) -> Self {
177 self.max_age_ms = Some(max_age_ms);
178 self
179 }
180}
181
182/// A single buffered observation request, one per NDJSON line.
183///
184/// Field names are camelCase to match the wire shapes of the wrapped
185/// `append_observation` arguments, so a spool file doubles as a debuggable /
186/// importable record.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct SpoolEntry {
190 /// The observation input (its `id` is always populated by the time it is
191 /// spooled, so replay is idempotent on id).
192 pub input: ObservationInput,
193 /// Optional capsule to attach the observation to.
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub capsule_id: Option<Id>,
196 /// Optional service-side validation toggle.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub validate: Option<bool>,
199 /// Epoch milliseconds when this entry was written to the spool. Used as the
200 /// basis for the age retention cap — distinct from `input.ts` (the
201 /// *observation* time, which can be far in the past for historical replays).
202 /// Legacy entries written before this field existed deserialize to `None`
203 /// and are byte-trimmable but never age-trimmed.
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub spooled_at: Option<i64>,
206}
207
208/// Outcome of [`SpooledClient::append_observation`].
209#[derive(Debug)]
210pub enum AppendOutcome {
211 /// Reached the daemon; carries the [`AppendResult`] (stored observation +
212 /// the daemon's `deduplicated` marker). `deduplicated` is `true` when this
213 /// id was already stored (e.g. a replay of an entry the daemon had already
214 /// committed before a crash) — the stored row is returned unchanged.
215 ///
216 /// The result is boxed so the enum stays small (the `Spooled` variant
217 /// carries no data).
218 Delivered(Box<AppendResult>),
219 /// Daemon unreachable — the request was buffered to the spool.
220 Spooled,
221}
222
223/// Result of [`SpooledClient::flush`].
224#[derive(Debug, PartialEq, Eq)]
225pub struct FlushReport {
226 /// Number of spooled entries successfully replayed into the daemon.
227 pub replayed: usize,
228 /// Number of entries still buffered (kept) after the flush stopped.
229 pub remaining: usize,
230}
231
232/// Errors from [`SpooledClient`] operations.
233///
234/// Note that a daemon *outage* is **not** an error from
235/// [`SpooledClient::append_observation`] — it returns [`AppendOutcome::Spooled`].
236/// [`SpoolError::Client`] only carries the *propagated* client errors (the
237/// daemon responded and rejected the request).
238#[derive(Debug, thiserror::Error)]
239pub enum SpoolError {
240 /// A spool-file I/O failure (open, read, append, temp-write, rename).
241 #[error("spool io error: {0}")]
242 Io(#[from] std::io::Error),
243
244 /// A spool entry could not be (de)serialized.
245 #[error("spool serde error: {0}")]
246 Serde(#[from] serde_json::Error),
247
248 /// The daemon responded with a non-connectivity error that must not be
249 /// spooled (`Api`, `SchemaMismatch`, `Decode`, `Io`).
250 #[error("client error: {0}")]
251 Client(#[from] ClientError),
252}
253
254/// A durable-emit wrapper around a [`crate::Client`].
255///
256/// Holds the client, the spool path, and an in-process [`Mutex`] that serializes
257/// every spool-file operation (single-producer-per-spool-file).
258#[derive(Debug)]
259pub struct SpooledClient {
260 client: Client,
261 spool_path: PathBuf,
262 /// Rolling byte cap; `None` = unbounded. See [`SpoolConfig::max_bytes`].
263 max_bytes: Option<u64>,
264 /// Rolling age cap (ms); `None` = unbounded. See [`SpoolConfig::max_age_ms`].
265 max_age_ms: Option<i64>,
266 /// Serializes read/append/rewrite of the spool file. The mutex guards the
267 /// *file*, not the client (the client is internally `Send + Sync`).
268 file_lock: Mutex<()>,
269 /// Live flush/error/replay counters for this client instance.
270 runtime: Mutex<SpoolRuntime>,
271}
272
273impl SpooledClient {
274 /// Build an **unbounded** durable-emit client over `client`, buffering to
275 /// `spool_path`. Use [`with_config`](Self::with_config) to opt into a
276 /// retention cap.
277 pub fn new(client: Client, spool_path: PathBuf) -> Self {
278 let runtime = load_runtime_sidecar(&spool_path);
279 Self {
280 client,
281 spool_path,
282 max_bytes: None,
283 max_age_ms: None,
284 file_lock: Mutex::new(()),
285 runtime: Mutex::new(runtime),
286 }
287 }
288
289 /// Build from a [`SpoolConfig`], carrying its retention caps.
290 pub fn with_config(client: Client, config: SpoolConfig) -> Self {
291 let runtime = load_runtime_sidecar(&config.spool_path);
292 Self {
293 client,
294 spool_path: config.spool_path,
295 max_bytes: config.max_bytes,
296 max_age_ms: config.max_age_ms,
297 file_lock: Mutex::new(()),
298 runtime: Mutex::new(runtime),
299 }
300 }
301
302 /// Borrow the underlying client for reads / non-spooled ops (retrieve,
303 /// health, pin, capsules, …). Only `append_observation` is durability-wrapped.
304 pub fn client(&self) -> &Client {
305 &self.client
306 }
307
308 /// Append an observation durably.
309 ///
310 /// Assigns a stable v4 id when `input.id` is `None` (so a spooled entry and
311 /// any replay share one id — see the crate-level *Delivery semantics*).
312 /// Then:
313 ///
314 /// 1. If the spool already has buffered entries, opportunistically
315 /// [`flush`](Self::flush) them **first** so the daemon observes them in
316 /// append order ahead of this new one.
317 /// 2. Try the daemon. On success → [`AppendOutcome::Delivered`].
318 /// 3. On a *connectivity* failure (`Unavailable` / `Http`) → buffer to the
319 /// spool and return [`AppendOutcome::Spooled`] (never an error).
320 /// 4. On any other client error → propagate as [`SpoolError::Client`]; the
321 /// observation is **not** spooled.
322 pub async fn append_observation(
323 &self,
324 mut input: ObservationInput,
325 capsule_id: Option<Id>,
326 validate: Option<bool>,
327 ) -> Result<AppendOutcome, SpoolError> {
328 // Stable id BEFORE any try/spool, so replay is idempotent on id.
329 if input.id.is_none() {
330 input.id = Some(Uuid::new_v4().to_string());
331 }
332
333 // Opportunistic drain: if there is a backlog, try to clear it first so
334 // ordering is preserved (backlog lands before this new observation).
335 // If the drain can't reach the daemon, this new entry will spool behind
336 // it below, still in order.
337 if self.pending_count()? > 0 {
338 // Best-effort: a connectivity failure here is fine — we proceed and
339 // (most likely) spool the new entry behind the backlog. A
340 // *propagating* client error from a backlog entry must surface.
341 self.flush().await?;
342 }
343
344 match self
345 .client
346 .append_observation(input.clone(), capsule_id.clone(), validate)
347 .await
348 {
349 Ok(result) => Ok(AppendOutcome::Delivered(Box::new(result))),
350 Err(err) if is_connectivity_error(&err) => {
351 self.record_connectivity_error(&err).await;
352 let entry = SpoolEntry {
353 input,
354 capsule_id,
355 validate,
356 spooled_at: Some(now_ms()),
357 };
358 self.append_to_spool(&entry).await?;
359 Ok(AppendOutcome::Spooled)
360 }
361 Err(err) => Err(SpoolError::Client(err)),
362 }
363 }
364
365 /// Drain the spool into the daemon, in order.
366 ///
367 /// Replays each buffered entry via the client. Stops at the first
368 /// *connectivity* failure (`Unavailable` / `Http`), keeping that entry and
369 /// the remainder. A non-connectivity client error also stops the drain and
370 /// is propagated, but the *un-replayed remainder (including the rejected
371 /// entry) is preserved* — data is never silently dropped.
372 ///
373 /// The spool file is rewritten with exactly the un-replayed remainder via a
374 /// temp-file-then-rename, so a crash mid-flush cannot corrupt the spool.
375 ///
376 /// If a retention cap is configured (see [`SpoolConfig`]), the retained
377 /// remainder is **trimmed** (oldest first) before the rewrite — see the
378 /// crate-level *Retention* docs. This is the only place trimming happens, so
379 /// it can never race a concurrent drain.
380 pub async fn flush(&self) -> Result<FlushReport, SpoolError> {
381 let _guard = self.file_lock.lock().await;
382
383 let entries = read_spool(&self.spool_path)?;
384 let total = entries.len();
385 if total == 0 {
386 return Ok(FlushReport {
387 replayed: 0,
388 remaining: 0,
389 });
390 }
391
392 let mut replayed = 0usize;
393 let mut propagate: Option<ClientError> = None;
394
395 for (idx, entry) in entries.iter().enumerate() {
396 self.bump_replay_attempts().await;
397 match self
398 .client
399 .append_observation(
400 entry.input.clone(),
401 entry.capsule_id.clone(),
402 entry.validate,
403 )
404 .await
405 {
406 Ok(_) => replayed += 1,
407 Err(err) if is_connectivity_error(&err) => {
408 self.record_connectivity_error(&err).await;
409 break;
410 }
411 Err(err) => {
412 // Non-connectivity rejection: stop, keep this entry + the
413 // remainder, and propagate after rewriting the spool. We do
414 // NOT advance `replayed` for this entry, so it stays buffered.
415 let _ = idx;
416 propagate = Some(err);
417 break;
418 }
419 }
420 }
421
422 // Apply the retention cap to the *retained* remainder (the un-replayed
423 // tail), then rewrite. Trimming only the oldest leading prefix keeps the
424 // survivors a contiguous, in-order tail — drain always replays
425 // front-to-back, so an entry is never dropped while a strictly newer one
426 // is kept ahead of it. This is intentional, bounded retention loss under
427 // a sustained outage (the oldest un-drained entries are shed once they
428 // exceed the cap) — distinct from the "never silently drop on a
429 // reachable daemon" guarantee above.
430 let mut remainder: Vec<SpoolEntry> = entries[replayed..].to_vec();
431 let dropped = if self.max_bytes.is_some() || self.max_age_ms.is_some() {
432 let before = remainder.len();
433 remainder = trim_entries(remainder, self.max_bytes, self.max_age_ms, now_ms());
434 before - remainder.len()
435 } else {
436 0
437 };
438 rewrite_spool(&self.spool_path, &remainder)?;
439
440 if dropped > 0 {
441 self.bump_dropped(dropped as u64).await;
442 }
443
444 if let Some(err) = propagate {
445 return Err(SpoolError::Client(err));
446 }
447
448 if replayed > 0 {
449 self.record_successful_flush().await;
450 }
451
452 Ok(FlushReport {
453 replayed,
454 remaining: remainder.len(),
455 })
456 }
457
458 async fn bump_replay_attempts(&self) {
459 let mut rt = self.runtime.lock().await;
460 rt.replay_attempts = rt.replay_attempts.saturating_add(1);
461 persist_runtime_sidecar(&self.spool_path, &rt);
462 }
463
464 async fn bump_dropped(&self, n: u64) {
465 let mut rt = self.runtime.lock().await;
466 rt.dropped_count = rt.dropped_count.saturating_add(n);
467 persist_runtime_sidecar(&self.spool_path, &rt);
468 }
469
470 async fn record_connectivity_error(&self, err: &ClientError) {
471 let mut rt = self.runtime.lock().await;
472 rt.last_error = Some(err.to_string());
473 persist_runtime_sidecar(&self.spool_path, &rt);
474 }
475
476 async fn record_successful_flush(&self) {
477 let mut rt = self.runtime.lock().await;
478 rt.last_flush_time_ms = Some(now_ms());
479 rt.last_error = None;
480 persist_runtime_sidecar(&self.spool_path, &rt);
481 }
482
483 /// Count of pending (un-replayed) spool entries.
484 pub fn pending_count(&self) -> Result<usize, SpoolError> {
485 Ok(read_spool(&self.spool_path)?.len())
486 }
487
488 /// Observability snapshot for this client (pending count + live counters).
489 pub async fn spool_status(&self) -> Result<SpoolStatus, SpoolError> {
490 let runtime = self.runtime.lock().await;
491 Ok(SpoolStatus {
492 pending_count: self.pending_count()?,
493 spool_path: self.spool_path.clone(),
494 last_flush_time_ms: runtime.last_flush_time_ms,
495 last_error: runtime.last_error.clone(),
496 replay_attempts: runtime.replay_attempts,
497 dropped_count: runtime.dropped_count,
498 })
499 }
500
501 /// Passive status from an on-disk spool file and its optional `.status.json`
502 /// sidecar (best-effort: a corrupt sidecar is ignored).
503 pub fn spool_status_from_path(
504 spool_path: impl Into<PathBuf>,
505 ) -> Result<SpoolStatus, SpoolError> {
506 let spool_path = spool_path.into();
507 let pending_count = read_spool(&spool_path)?.len();
508 let runtime = load_runtime_sidecar(&spool_path);
509 Ok(SpoolStatus {
510 pending_count,
511 spool_path,
512 last_flush_time_ms: runtime.last_flush_time_ms,
513 last_error: runtime.last_error,
514 replay_attempts: runtime.replay_attempts,
515 dropped_count: runtime.dropped_count,
516 })
517 }
518
519 /// Append one entry to the spool file (create + append), serialized by the
520 /// file lock.
521 async fn append_to_spool(&self, entry: &SpoolEntry) -> Result<(), SpoolError> {
522 use std::io::Write;
523
524 let _guard = self.file_lock.lock().await;
525 let line = serde_json::to_string(entry)?;
526 let mut file = std::fs::OpenOptions::new()
527 .create(true)
528 .append(true)
529 .open(&self.spool_path)?;
530 file.write_all(line.as_bytes())?;
531 file.write_all(b"\n")?;
532 file.flush()?;
533 Ok(())
534 }
535}
536
537/// Sidecar path for flush/error/replay metadata: `{spool_path}.status.json`.
538fn status_sidecar_path(spool_path: &Path) -> PathBuf {
539 let name = format!(
540 "{}.status.json",
541 spool_path
542 .file_name()
543 .map(|n| n.to_string_lossy().into_owned())
544 .unwrap_or_else(|| "spool".to_string())
545 );
546 match spool_path.parent() {
547 Some(dir) => dir.join(name),
548 None => PathBuf::from(name),
549 }
550}
551
552/// Load sidecar metadata. Best-effort: any I/O or parse failure yields defaults.
553fn load_runtime_sidecar(spool_path: &Path) -> SpoolRuntime {
554 let path = status_sidecar_path(spool_path);
555 let contents = match std::fs::read_to_string(&path) {
556 Ok(c) => c,
557 Err(_) => return SpoolRuntime::default(),
558 };
559 serde_json::from_str(&contents).unwrap_or_default()
560}
561
562/// Persist sidecar metadata. Best-effort: errors are ignored so observability
563/// never masks spool delivery or flush progress.
564fn persist_runtime_sidecar(spool_path: &Path, runtime: &SpoolRuntime) {
565 let _ = (|| -> Result<(), SpoolError> {
566 let path = status_sidecar_path(spool_path);
567 let line = serde_json::to_string(runtime)?;
568 let tmp = temp_sibling(&path);
569 std::fs::write(&tmp, format!("{line}\n"))?;
570 std::fs::rename(&tmp, &path)?;
571 Ok(())
572 })();
573}
574
575fn now_ms() -> i64 {
576 SystemTime::now()
577 .duration_since(UNIX_EPOCH)
578 .unwrap_or_default()
579 .as_millis() as i64
580}
581
582/// Connectivity failures buffer to the spool; everything else propagates.
583fn is_connectivity_error(err: &ClientError) -> bool {
584 matches!(err, ClientError::Unavailable(_) | ClientError::Http(_))
585}
586
587/// Serialized on-disk size of one entry: its NDJSON line plus the newline.
588/// Matches exactly what [`rewrite_spool`] writes, so the byte cap is measured
589/// against real file bytes. A non-serializable entry counts as 0 (it cannot
590/// occur for a valid entry that round-tripped through `read_spool`).
591fn entry_size(entry: &SpoolEntry) -> u64 {
592 serde_json::to_string(entry)
593 .map(|s| s.len() as u64 + 1)
594 .unwrap_or(0)
595}
596
597/// Apply the retention caps by dropping the **oldest leading prefix** only.
598///
599/// Age first, then bytes. Both drop strictly from the front, so the result is
600/// always a contiguous, in-order *suffix* of the input — which is exactly what
601/// preserves drain ordering and the "never drop an un-drained entry ahead of a
602/// kept newer one" invariant.
603///
604/// - **Age:** drop a leading run whose `spooled_at` is `< now - max_age_ms`.
605/// Stop at the first entry that is not age-expired *or* has no `spooled_at`
606/// stamp (legacy entries are never age-trimmed).
607/// - **Bytes:** if the serialized remainder still exceeds `max_bytes`, keep
608/// dropping from the front until it fits — but always keep at least one entry.
609/// A lone entry larger than `max_bytes` is therefore retained: the byte cap is
610/// a high-water target, not a hard ceiling, and a single un-delivered record
611/// is never dropped purely for size (it will still age out).
612fn trim_entries(
613 mut entries: Vec<SpoolEntry>,
614 max_bytes: Option<u64>,
615 max_age_ms: Option<i64>,
616 now: i64,
617) -> Vec<SpoolEntry> {
618 // Age: count the leading expired prefix, then drain it in one shot.
619 if let Some(max_age) = max_age_ms {
620 let cutoff = now.saturating_sub(max_age);
621 let mut drop_n = 0;
622 for entry in &entries {
623 match entry.spooled_at {
624 Some(t) if t < cutoff => drop_n += 1,
625 _ => break,
626 }
627 }
628 entries.drain(0..drop_n);
629 }
630
631 // Bytes: drop oldest until the remainder fits, keeping at least one entry.
632 if let Some(max_bytes) = max_bytes {
633 let sizes: Vec<u64> = entries.iter().map(entry_size).collect();
634 let mut total: u64 = sizes.iter().sum();
635 let mut drop_n = 0;
636 while total > max_bytes && entries.len() - drop_n > 1 {
637 total -= sizes[drop_n];
638 drop_n += 1;
639 }
640 entries.drain(0..drop_n);
641 }
642
643 entries
644}
645
646/// Read all parseable spool entries in order.
647///
648/// A missing file is an empty spool. A torn trailing line (crash mid-write) is
649/// tolerated: only the *last* line is allowed to fail to parse, in which case it
650/// is skipped and the preceding good entries are returned. A malformed line that
651/// is *not* the last is a corruption we surface as an error (it would otherwise
652/// silently drop a buffered observation).
653fn read_spool(path: &Path) -> Result<Vec<SpoolEntry>, SpoolError> {
654 let contents = match std::fs::read_to_string(path) {
655 Ok(c) => c,
656 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
657 Err(e) => return Err(SpoolError::Io(e)),
658 };
659
660 // Split on '\n'. Trailing newline yields a final empty segment we ignore.
661 let lines: Vec<&str> = contents.split('\n').collect();
662 let mut entries = Vec::new();
663 let last_idx = lines.len().saturating_sub(1);
664
665 for (idx, raw) in lines.iter().enumerate() {
666 let line = raw.trim_end_matches('\r');
667 if line.is_empty() {
668 continue;
669 }
670 match serde_json::from_str::<SpoolEntry>(line) {
671 Ok(entry) => entries.push(entry),
672 Err(e) => {
673 // Tolerate a torn trailing line only.
674 if idx == last_idx {
675 break;
676 }
677 return Err(SpoolError::Serde(e));
678 }
679 }
680 }
681 Ok(entries)
682}
683
684/// Rewrite the spool file with exactly `entries`, atomically via temp + rename.
685///
686/// Writing an empty remainder truncates the spool to empty (file removed if it
687/// exists, leaving a clean state).
688fn rewrite_spool(path: &Path, entries: &[SpoolEntry]) -> Result<(), SpoolError> {
689 use std::io::Write;
690
691 if entries.is_empty() {
692 match std::fs::remove_file(path) {
693 Ok(()) => Ok(()),
694 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
695 Err(e) => Err(SpoolError::Io(e)),
696 }
697 } else {
698 let tmp = temp_sibling(path);
699 {
700 let mut file = std::fs::File::create(&tmp)?;
701 for entry in entries {
702 let line = serde_json::to_string(entry)?;
703 file.write_all(line.as_bytes())?;
704 file.write_all(b"\n")?;
705 }
706 file.flush()?;
707 }
708 std::fs::rename(&tmp, path)?;
709 Ok(())
710 }
711}
712
713/// A temp sibling path next to `path` (same directory, so `rename` is atomic on
714/// the same filesystem).
715fn temp_sibling(path: &Path) -> PathBuf {
716 let mut name = path
717 .file_name()
718 .map(|n| n.to_os_string())
719 .unwrap_or_default();
720 name.push(format!(".tmp-{}", Uuid::new_v4()));
721 match path.parent() {
722 Some(dir) => dir.join(name),
723 None => PathBuf::from(name),
724 }
725}
726
727#[cfg(test)]
728mod trim_tests {
729 use super::*;
730 use kindling_types::{ObservationKind, ScopeIds};
731
732 /// A spool entry tagged with `content` (an identity marker) and an optional
733 /// `spooled_at` stamp.
734 fn entry(content: &str, spooled_at: Option<i64>) -> SpoolEntry {
735 SpoolEntry {
736 input: ObservationInput {
737 id: Some(format!("id-{content}")),
738 kind: ObservationKind::Message,
739 content: content.to_string(),
740 provenance: None,
741 ts: None,
742 scope_ids: ScopeIds::default(),
743 redacted: None,
744 },
745 capsule_id: None,
746 validate: None,
747 spooled_at,
748 }
749 }
750
751 fn contents(entries: &[SpoolEntry]) -> Vec<String> {
752 entries.iter().map(|e| e.input.content.clone()).collect()
753 }
754
755 /// Survivors must always be a contiguous *suffix* of the input order.
756 fn assert_is_suffix(original: &[SpoolEntry], kept: &[SpoolEntry]) {
757 let orig = contents(original);
758 let kept = contents(kept);
759 assert!(
760 orig.ends_with(&kept),
761 "kept {kept:?} is not a suffix of {orig:?}"
762 );
763 }
764
765 #[test]
766 fn no_caps_is_a_noop() {
767 let input = vec![entry("a", Some(1)), entry("b", Some(2))];
768 let kept = trim_entries(input.clone(), None, None, 1_000);
769 assert_eq!(contents(&kept), contents(&input));
770 }
771
772 #[test]
773 fn empty_spool_is_a_noop() {
774 let kept = trim_entries(Vec::new(), Some(10), Some(10), 1_000);
775 assert!(kept.is_empty());
776 }
777
778 #[test]
779 fn age_drops_oldest_prefix_only() {
780 // now = 1000, max_age = 100 → cutoff 900; entries older than 900 expire.
781 let input = vec![
782 entry("a", Some(500)), // expired
783 entry("b", Some(800)), // expired
784 entry("c", Some(950)), // kept
785 entry("d", Some(990)), // kept
786 ];
787 let kept = trim_entries(input.clone(), None, Some(100), 1_000);
788 assert_eq!(contents(&kept), vec!["c", "d"]);
789 assert_is_suffix(&input, &kept);
790 }
791
792 #[test]
793 fn age_stops_at_first_unexpired_even_if_later_ones_are_old() {
794 // A newer entry ahead of an older one must NOT be dropped: age trim only
795 // removes a contiguous leading expired run.
796 let input = vec![
797 entry("a", Some(500)), // expired
798 entry("b", Some(950)), // not expired → stop here
799 entry("c", Some(400)), // older, but behind a kept entry → retained
800 ];
801 let kept = trim_entries(input.clone(), None, Some(100), 1_000);
802 assert_eq!(contents(&kept), vec!["b", "c"]);
803 assert_is_suffix(&input, &kept);
804 }
805
806 #[test]
807 fn legacy_entry_without_stamp_is_never_age_trimmed() {
808 let input = vec![
809 entry("a", None), // legacy: blocks age trim at the front
810 entry("b", Some(100)), // would be expired, but is behind "a"
811 ];
812 let kept = trim_entries(input.clone(), None, Some(100), 1_000_000);
813 assert_eq!(contents(&kept), vec!["a", "b"]);
814 }
815
816 #[test]
817 fn bytes_drops_oldest_until_under_cap() {
818 let input = vec![entry("a", None), entry("b", None), entry("c", None)];
819 let each = entry_size(&input[0]);
820 // Cap that fits exactly two entries.
821 let kept = trim_entries(input.clone(), Some(each * 2), None, 0);
822 assert_eq!(contents(&kept), vec!["b", "c"]);
823 assert_is_suffix(&input, &kept);
824 let total: u64 = kept.iter().map(entry_size).sum();
825 assert!(total <= each * 2);
826 }
827
828 #[test]
829 fn lone_entry_larger_than_cap_is_retained() {
830 // One entry whose size exceeds the cap is kept — the cap is a high-water
831 // target, never a reason to drop the only un-delivered record.
832 let big = entry(&"x".repeat(4096), None);
833 let cap = entry_size(&big) / 2;
834 let kept = trim_entries(vec![big.clone()], Some(cap), None, 0);
835 assert_eq!(contents(&kept), vec![big.input.content]);
836 }
837
838 #[test]
839 fn bytes_keeps_at_least_one_even_when_all_oversize() {
840 let input = vec![
841 entry(&"x".repeat(1000), None),
842 entry(&"y".repeat(1000), None),
843 ];
844 // Cap smaller than a single entry: drop down to the newest single entry.
845 let kept = trim_entries(input.clone(), Some(10), None, 0);
846 assert_eq!(contents(&kept), vec!["y".repeat(1000)]);
847 }
848
849 #[test]
850 fn age_then_bytes_compose_into_a_suffix() {
851 let input = vec![
852 entry("a", Some(100)), // age-expired
853 entry("b", Some(200)), // age-expired
854 entry("c", Some(950)), // survives age
855 entry("d", Some(960)), // survives age
856 entry("e", Some(970)), // survives age
857 ];
858 let each = entry_size(&input[0]);
859 // now=1000, max_age=100 → drop a,b by age; then a byte cap of two entries
860 // drops c, leaving d,e.
861 let kept = trim_entries(input.clone(), Some(each * 2), Some(100), 1_000);
862 assert_eq!(contents(&kept), vec!["d", "e"]);
863 assert_is_suffix(&input, &kept);
864 }
865}