use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{Client, ClientError};
use kindling_types::{Id, Observation, ObservationInput};
#[derive(Debug, Clone)]
pub struct SpoolConfig {
pub spool_path: PathBuf,
}
impl SpoolConfig {
pub fn new(spool_path: impl Into<PathBuf>) -> Self {
Self {
spool_path: spool_path.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpoolEntry {
pub input: ObservationInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capsule_id: Option<Id>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validate: Option<bool>,
}
#[derive(Debug)]
pub enum AppendOutcome {
Delivered(Box<Observation>),
Spooled,
}
#[derive(Debug, PartialEq, Eq)]
pub struct FlushReport {
pub replayed: usize,
pub remaining: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum SpoolError {
#[error("spool io error: {0}")]
Io(#[from] std::io::Error),
#[error("spool serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("client error: {0}")]
Client(#[from] ClientError),
}
#[derive(Debug)]
pub struct SpooledClient {
client: Client,
spool_path: PathBuf,
file_lock: Mutex<()>,
}
impl SpooledClient {
pub fn new(client: Client, spool_path: PathBuf) -> Self {
Self {
client,
spool_path,
file_lock: Mutex::new(()),
}
}
pub fn with_config(client: Client, config: SpoolConfig) -> Self {
Self::new(client, config.spool_path)
}
pub fn client(&self) -> &Client {
&self.client
}
pub async fn append_observation(
&self,
mut input: ObservationInput,
capsule_id: Option<Id>,
validate: Option<bool>,
) -> Result<AppendOutcome, SpoolError> {
if input.id.is_none() {
input.id = Some(Uuid::new_v4().to_string());
}
if self.pending_count()? > 0 {
self.flush().await?;
}
match self
.client
.append_observation(input.clone(), capsule_id.clone(), validate)
.await
{
Ok(observation) => Ok(AppendOutcome::Delivered(Box::new(observation))),
Err(err) if is_connectivity_error(&err) => {
let entry = SpoolEntry {
input,
capsule_id,
validate,
};
self.append_to_spool(&entry).await?;
Ok(AppendOutcome::Spooled)
}
Err(err) => Err(SpoolError::Client(err)),
}
}
pub async fn flush(&self) -> Result<FlushReport, SpoolError> {
let _guard = self.file_lock.lock().await;
let entries = read_spool(&self.spool_path)?;
let total = entries.len();
if total == 0 {
return Ok(FlushReport {
replayed: 0,
remaining: 0,
});
}
let mut replayed = 0usize;
let mut propagate: Option<ClientError> = None;
for (idx, entry) in entries.iter().enumerate() {
match self
.client
.append_observation(
entry.input.clone(),
entry.capsule_id.clone(),
entry.validate,
)
.await
{
Ok(_) => replayed += 1,
Err(err) if is_connectivity_error(&err) => break,
Err(err) => {
let _ = idx;
propagate = Some(err);
break;
}
}
}
let remainder = &entries[replayed..];
rewrite_spool(&self.spool_path, remainder)?;
if let Some(err) = propagate {
return Err(SpoolError::Client(err));
}
Ok(FlushReport {
replayed,
remaining: remainder.len(),
})
}
pub fn pending_count(&self) -> Result<usize, SpoolError> {
Ok(read_spool(&self.spool_path)?.len())
}
async fn append_to_spool(&self, entry: &SpoolEntry) -> Result<(), SpoolError> {
use std::io::Write;
let _guard = self.file_lock.lock().await;
let line = serde_json::to_string(entry)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.spool_path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
file.flush()?;
Ok(())
}
}
fn is_connectivity_error(err: &ClientError) -> bool {
matches!(err, ClientError::Unavailable(_) | ClientError::Http(_))
}
fn read_spool(path: &Path) -> Result<Vec<SpoolEntry>, SpoolError> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(SpoolError::Io(e)),
};
let lines: Vec<&str> = contents.split('\n').collect();
let mut entries = Vec::new();
let last_idx = lines.len().saturating_sub(1);
for (idx, raw) in lines.iter().enumerate() {
let line = raw.trim_end_matches('\r');
if line.is_empty() {
continue;
}
match serde_json::from_str::<SpoolEntry>(line) {
Ok(entry) => entries.push(entry),
Err(e) => {
if idx == last_idx {
break;
}
return Err(SpoolError::Serde(e));
}
}
}
Ok(entries)
}
fn rewrite_spool(path: &Path, entries: &[SpoolEntry]) -> Result<(), SpoolError> {
use std::io::Write;
if entries.is_empty() {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SpoolError::Io(e)),
}
} else {
let tmp = temp_sibling(path);
{
let mut file = std::fs::File::create(&tmp)?;
for entry in entries {
let line = serde_json::to_string(entry)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
}
file.flush()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
}
fn temp_sibling(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(format!(".tmp-{}", Uuid::new_v4()));
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}