use super::ledger::LocalOutputObservation;
use crate::serve::history::RunHistory;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct RecordContext {
pub dataset_uri: String,
pub kind: String,
pub pipeline: String,
pub row: String,
pub run_id: String,
pub retention_days: Option<u32>,
pub observed_at: DateTime<Utc>,
}
pub async fn record(
store: &dyn RunHistory,
outputs: &[faucet_core::LocalOutput],
ctx: &RecordContext,
) -> usize {
if outputs.is_empty() {
return 0;
}
let dataset_id = crate::serve::history::catalog::dataset_id(&ctx.dataset_uri);
let mut written = 0;
for out in outputs {
let obs = LocalOutputObservation {
path: out.path.clone(),
dataset_uri: ctx.dataset_uri.clone(),
dataset_id: dataset_id.clone(),
kind: ctx.kind.clone(),
pipeline: ctx.pipeline.clone(),
row: ctx.row.clone(),
run_id: ctx.run_id.clone(),
pre_existing: out.pre_existing,
replaced: out.replaced,
retention_days: ctx.retention_days,
observed_at: ctx.observed_at,
};
match store.local_output_record(&obs).await {
Ok(()) => written += 1,
Err(e) => {
tracing::warn!(
path = %out.path.display(),
pipeline = %ctx.pipeline,
error = %e,
"could not record a local sink output — the file will not be \
reclaimed by the retention GC; run unaffected"
);
}
}
}
super::metrics::recorded(&ctx.kind, written);
written
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local_outputs::{LocalOutputFilter, LocalOutputState};
use crate::serve::history::memory::MemoryHistory;
use faucet_core::LocalOutput;
use std::time::Duration;
fn ctx() -> RecordContext {
RecordContext {
dataset_uri: "file:///tmp/out.jsonl".into(),
kind: "jsonl".into(),
pipeline: "demo".into(),
row: "default".into(),
run_id: "run-1".into(),
retention_days: None,
observed_at: Utc::now(),
}
}
#[tokio::test]
async fn records_one_row_per_file() {
let store = MemoryHistory::new(Duration::from_secs(60));
let n = record(
&store,
&[
LocalOutput::created("/tmp/a.jsonl"),
LocalOutput::created("/tmp/b.jsonl"),
],
&ctx(),
)
.await;
assert_eq!(n, 2);
let rows = store
.local_output_list(&LocalOutputFilter::default())
.await
.unwrap();
assert_eq!(rows.len(), 2);
let expected = crate::serve::history::catalog::dataset_id("file:///tmp/out.jsonl");
assert!(rows.iter().all(|r| r.dataset_id == expected));
}
#[tokio::test]
async fn nothing_to_record_is_not_a_write() {
let store = MemoryHistory::new(Duration::from_secs(60));
assert_eq!(record(&store, &[], &ctx()).await, 0);
assert!(
store
.local_output_list(&LocalOutputFilter::default())
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn a_pre_existing_file_is_recorded_as_external() {
let store = MemoryHistory::new(Duration::from_secs(60));
record(
&store,
&[LocalOutput::pre_existing("/tmp/theirs.jsonl")],
&ctx(),
)
.await;
let rows = store
.local_output_list(&LocalOutputFilter::default())
.await
.unwrap();
assert_eq!(rows[0].state(), LocalOutputState::External);
}
#[tokio::test]
async fn re_recording_the_same_path_updates_rather_than_duplicates() {
let store = MemoryHistory::new(Duration::from_secs(60));
let mut first = ctx();
first.observed_at = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
record(&store, &[LocalOutput::created("/tmp/a.jsonl")], &first).await;
let mut second = ctx();
second.run_id = "run-2".into();
second.observed_at = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
record(
&store,
&[LocalOutput::pre_existing("/tmp/a.jsonl")],
&second,
)
.await;
let rows = store
.local_output_list(&LocalOutputFilter::default())
.await
.unwrap();
assert_eq!(rows.len(), 1, "upsert by path, not a second row");
assert_eq!(rows[0].run_id, "run-2");
assert_eq!(rows[0].first_written_at, first.observed_at);
assert_eq!(rows[0].last_written_at, second.observed_at);
assert!(!rows[0].pre_existing, "still faucet's own file");
}
#[tokio::test]
async fn a_store_failure_never_fails_the_run() {
struct BrokenStore;
#[async_trait::async_trait]
impl RunHistory for BrokenStore {
async fn claim_idempotency(
&self,
_: &str,
_: &str,
_: &str,
_: Duration,
) -> Result<crate::serve::history::Claim, crate::serve::history::HistoryError>
{
unreachable!("not exercised")
}
async fn upsert(
&self,
_: &crate::serve::history::RunRecord,
) -> Result<(), crate::serve::history::HistoryError> {
unreachable!("not exercised")
}
async fn get(
&self,
_: &str,
) -> Result<Option<crate::serve::history::RunRecord>, crate::serve::history::HistoryError>
{
unreachable!("not exercised")
}
async fn list(
&self,
_: &crate::serve::history::ListFilter,
) -> Result<crate::serve::history::ListPage, crate::serve::history::HistoryError>
{
unreachable!("not exercised")
}
async fn delete(
&self,
_: &str,
) -> Result<crate::serve::history::DeleteOutcome, crate::serve::history::HistoryError>
{
unreachable!("not exercised")
}
async fn purge_expired(
&self,
_: Duration,
) -> Result<usize, crate::serve::history::HistoryError> {
unreachable!("not exercised")
}
async fn recover_orphans(&self) -> Result<usize, crate::serve::history::HistoryError> {
unreachable!("not exercised")
}
fn degraded(&self) -> bool {
true
}
async fn local_output_record(
&self,
_: &LocalOutputObservation,
) -> Result<(), crate::serve::history::HistoryError> {
Err(crate::serve::history::HistoryError::Backend(
"ledger unavailable".into(),
))
}
}
let written = record(
&BrokenStore,
&[
LocalOutput::created("/tmp/a.jsonl"),
LocalOutput::created("/tmp/b.jsonl"),
],
&ctx(),
)
.await;
assert_eq!(written, 0, "nothing was recorded…");
}
#[tokio::test]
async fn a_retention_override_lands_on_the_row() {
let store = MemoryHistory::new(Duration::from_secs(60));
let mut c = ctx();
c.retention_days = Some(2);
record(&store, &[LocalOutput::created("/tmp/a.jsonl")], &c).await;
let rows = store
.local_output_list(&LocalOutputFilter::default())
.await
.unwrap();
assert_eq!(rows[0].retention_days, Some(2));
assert_eq!(rows[0].effective_retention_days(7), Some(2));
}
}