use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct StepState {
pub sql_hash: String,
pub artifact_hash: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepStatus {
Success,
Failed,
}
impl StepStatus {
pub fn as_str(&self) -> &str {
match self {
StepStatus::Success => "success",
StepStatus::Failed => "failed",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"success" => StepStatus::Success,
_ => StepStatus::Failed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkipReason {
HashClean,
PreconditionFresh,
PreconditionModifiedAfter,
PreconditionTool,
}
impl SkipReason {
pub fn as_str(&self) -> &'static str {
match self {
SkipReason::HashClean => "hash_clean",
SkipReason::PreconditionFresh => "precondition_fresh",
SkipReason::PreconditionModifiedAfter => "precondition_modified_after",
SkipReason::PreconditionTool => "precondition_tool",
}
}
}
pub fn content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
format!("{:x}", hasher.finalize())
}
pub fn hash_directory_contents(dir: &Path) -> Option<String> {
let mut entries: Vec<(String, String)> = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let read_dir = std::fs::read_dir(¤t).ok()?;
for entry in read_dir {
let entry = entry.ok()?;
let path = entry.path();
let file_type = entry.file_type().ok()?;
if file_type.is_dir() {
stack.push(path);
} else if file_type.is_file() {
let rel = path
.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let bytes = std::fs::read(&path).ok()?;
entries.push((rel, content_hash(&bytes)));
}
}
}
entries.sort();
let mut hasher = Sha256::new();
for (rel, hash) in &entries {
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(hash.as_bytes());
hasher.update(b"\n");
}
Some(format!("{:x}", hasher.finalize()))
}
pub trait StateBackend {
fn init(&self) -> Result<()>;
fn get_step_state(&self, step_name: &str) -> Result<Option<StepState>>;
fn record_step(
&self,
step_name: &str,
sql_hash: &str,
artifact_hash: &str,
status: StepStatus,
) -> Result<()>;
fn start_run(&self) -> Result<String>;
fn finish_run(
&self,
run_id: &str,
steps_executed: usize,
outcome: &str,
total_retries: usize,
) -> Result<()>;
}
pub struct DuckDbStateBackend {
db_path: std::path::PathBuf,
protocol_dir: Option<std::path::PathBuf>,
}
impl DuckDbStateBackend {
pub fn new(db_path: &Path) -> Self {
DuckDbStateBackend {
db_path: db_path.to_path_buf(),
protocol_dir: None,
}
}
pub(crate) fn for_protocol(manifest: &crate::manifest::Manifest, dir: &Path) -> Result<Self> {
match manifest.db {
Some(_) => manifest.db_path(dir).map(|path| Self::new(&path)),
None => crate::working_db::locate(&manifest.name, dir).map(|db| DuckDbStateBackend {
db_path: db.path,
protocol_dir: Some(db.protocol_dir),
}),
}
}
fn open(&self) -> Result<duckdb::Connection> {
if let Some(parent) = self.db_path.parent()
&& !parent.as_os_str().is_empty()
{
let _ = std::fs::create_dir_all(parent);
}
duckdb::Connection::open(&self.db_path).map_err(|e| Error::StateBackend(e.to_string()))
}
}
impl StateBackend for DuckDbStateBackend {
fn init(&self) -> Result<()> {
if let Some(dir) = &self.protocol_dir {
crate::working_db::mark(&self.db_path, dir)?;
}
let conn = self.open()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _arcform_state (
step_name TEXT PRIMARY KEY,
sql_hash TEXT NOT NULL,
last_run_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS _arcform_runs (
run_id TEXT PRIMARY KEY,
started_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
finished_at TIMESTAMP,
steps_executed INTEGER,
outcome TEXT
);
-- Migration: a state db created before artifact hashing existed has no
-- such column. Idempotent, so every `init()` can run it unconditionally.
-- The empty-string default never matches a freshly computed hash (see
-- `produced_artifact_hash`), so the first post-upgrade run treats every
-- step as artifact-stale exactly once and reseeds it — safe because it
-- fails toward re-running, never toward skipping.
ALTER TABLE _arcform_state ADD COLUMN IF NOT EXISTS artifact_hash TEXT DEFAULT '';",
)
.map_err(|e| Error::StateBackend(e.to_string()))?;
Ok(())
}
fn get_step_state(&self, step_name: &str) -> Result<Option<StepState>> {
let conn = self.open()?;
let mut stmt = conn
.prepare(
"SELECT sql_hash, artifact_hash, status FROM _arcform_state WHERE step_name = ?1",
)
.map_err(|e| Error::StateBackend(e.to_string()))?;
let result = stmt.query_row([step_name], |row| {
let hash: String = row.get(0)?;
let artifact_hash: String = row.get(1)?;
let status: String = row.get(2)?;
Ok(StepState {
sql_hash: hash,
artifact_hash,
status: StepStatus::from_str(&status),
})
});
match result {
Ok(state) => Ok(Some(state)),
Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(Error::StateBackend(e.to_string())),
}
}
fn record_step(
&self,
step_name: &str,
sql_hash: &str,
artifact_hash: &str,
status: StepStatus,
) -> Result<()> {
let conn = self.open()?;
conn.execute(
"INSERT OR REPLACE INTO _arcform_state (step_name, sql_hash, artifact_hash, last_run_at, status)
VALUES (?1, ?2, ?3, current_timestamp, ?4)",
duckdb::params![step_name, sql_hash, artifact_hash, status.as_str()],
)
.map_err(|e| Error::StateBackend(e.to_string()))?;
Ok(())
}
fn start_run(&self) -> Result<String> {
let run_id = format!("{}-{}", timestamp_id(), &uuid_simple());
let conn = self.open()?;
conn.execute(
"INSERT INTO _arcform_runs (run_id, started_at) VALUES (?1, current_timestamp)",
[&run_id],
)
.map_err(|e| Error::StateBackend(e.to_string()))?;
Ok(run_id)
}
fn finish_run(
&self,
run_id: &str,
steps_executed: usize,
outcome: &str,
total_retries: usize,
) -> Result<()> {
let conn = self.open()?;
conn.execute(
"UPDATE _arcform_runs SET finished_at = current_timestamp, steps_executed = ?1, outcome = ?2 WHERE run_id = ?3",
duckdb::params![steps_executed as i64, outcome, run_id],
)
.map_err(|e| Error::StateBackend(e.to_string()))?;
let _ = total_retries;
Ok(())
}
}
fn timestamp_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = days_to_date(days);
format!("{year:04}{month:02}{day:02}-{hours:02}{minutes:02}{seconds:02}")
}
fn days_to_date(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn uuid_simple() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
format!("{:08x}", nanos)
}
#[cfg(test)]
pub mod mock {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
type MockRun = (String, Option<(usize, String)>);
pub struct MockStateBackend {
pub states: RefCell<HashMap<String, StepState>>,
pub runs: RefCell<Vec<MockRun>>,
pub init_called: RefCell<bool>,
pub total_retries: std::cell::Cell<usize>,
}
impl MockStateBackend {
pub fn new() -> Self {
MockStateBackend {
states: RefCell::new(HashMap::new()),
runs: RefCell::new(Vec::new()),
init_called: RefCell::new(false),
total_retries: std::cell::Cell::new(0),
}
}
}
impl StateBackend for MockStateBackend {
fn init(&self) -> Result<()> {
*self.init_called.borrow_mut() = true;
Ok(())
}
fn get_step_state(&self, step_name: &str) -> Result<Option<StepState>> {
Ok(self.states.borrow().get(step_name).cloned())
}
fn record_step(
&self,
step_name: &str,
sql_hash: &str,
artifact_hash: &str,
status: StepStatus,
) -> Result<()> {
self.states.borrow_mut().insert(
step_name.to_string(),
StepState {
sql_hash: sql_hash.to_string(),
artifact_hash: artifact_hash.to_string(),
status,
},
);
Ok(())
}
fn start_run(&self) -> Result<String> {
let id = format!("run-{}", self.runs.borrow().len() + 1);
self.runs.borrow_mut().push((id.clone(), None));
Ok(id)
}
fn finish_run(
&self,
run_id: &str,
steps_executed: usize,
outcome: &str,
total_retries: usize,
) -> Result<()> {
if let Some(run) = self
.runs
.borrow_mut()
.iter_mut()
.find(|(id, _)| id == run_id)
{
run.1 = Some((steps_executed, outcome.to_string()));
}
self.total_retries.set(total_retries);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_directory_contents_is_order_independent() {
let dir_a = std::env::temp_dir().join(format!("arc-hdc-a-{}", std::process::id()));
let dir_b = std::env::temp_dir().join(format!("arc-hdc-b-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir_a);
let _ = std::fs::remove_dir_all(&dir_b);
std::fs::create_dir_all(dir_a.join("sub")).unwrap();
std::fs::create_dir_all(dir_b.join("sub")).unwrap();
std::fs::write(dir_a.join("a.txt"), b"one").unwrap();
std::fs::write(dir_a.join("sub/b.txt"), b"two").unwrap();
std::fs::write(dir_b.join("sub/b.txt"), b"two").unwrap();
std::fs::write(dir_b.join("a.txt"), b"one").unwrap();
let hash_a = hash_directory_contents(&dir_a);
let hash_b = hash_directory_contents(&dir_b);
assert!(hash_a.is_some());
assert_eq!(
hash_a, hash_b,
"directory hash must not depend on readdir order"
);
std::fs::remove_dir_all(&dir_a).unwrap();
std::fs::remove_dir_all(&dir_b).unwrap();
}
#[test]
fn hash_directory_contents_changes_when_emptied() {
let dir = std::env::temp_dir().join(format!("arc-hdc-empty-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("part-0.parquet"), b"partition bytes").unwrap();
let populated = hash_directory_contents(&dir);
std::fs::remove_file(dir.join("part-0.parquet")).unwrap();
let emptied = hash_directory_contents(&dir);
assert!(populated.is_some());
assert!(emptied.is_some());
assert_ne!(
populated, emptied,
"emptying a directory while keeping it present must change the hash"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn hash_directory_contents_none_when_absent() {
let dir = std::env::temp_dir().join(format!("arc-hdc-absent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(hash_directory_contents(&dir), None);
}
#[test]
fn test_mock_state_backend() {
let backend = mock::MockStateBackend::new();
backend.init().unwrap();
assert!(*backend.init_called.borrow());
assert!(backend.get_step_state("foo").unwrap().is_none());
backend
.record_step("foo", "abc123", "", StepStatus::Success)
.unwrap();
let state = backend.get_step_state("foo").unwrap().unwrap();
assert_eq!(state.sql_hash, "abc123");
assert_eq!(state.status, StepStatus::Success);
}
#[test]
fn test_mock_run_tracking() {
let backend = mock::MockStateBackend::new();
let run_id = backend.start_run().unwrap();
backend.finish_run(&run_id, 3, "success", 0).unwrap();
let runs = backend.runs.borrow();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].1.as_ref().unwrap().0, 3);
assert_eq!(runs[0].1.as_ref().unwrap().1, "success");
}
#[test]
fn test_duckdb_backend_init() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.duckdb");
let backend = DuckDbStateBackend::new(&db_path);
backend.init().unwrap();
let conn = duckdb::Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT count(*) FROM _arcform_state", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
let count: i64 = conn
.query_row("SELECT count(*) FROM _arcform_runs", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
backend.init().unwrap();
}
#[test]
fn init_creates_missing_db_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("build").join("x.db");
assert!(!db_path.parent().unwrap().exists());
let backend = DuckDbStateBackend::new(&db_path);
backend.init().unwrap();
assert!(
db_path.exists(),
"state db (and its parent) should be created"
);
backend
.record_step("s", "h", "", StepStatus::Success)
.unwrap();
assert_eq!(backend.get_step_state("s").unwrap().unwrap().sql_hash, "h");
}
#[test]
fn test_content_hash_stored() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.duckdb");
let backend = DuckDbStateBackend::new(&db_path);
backend.init().unwrap();
let sql = "CREATE TABLE foo (id INT);";
let hash = content_hash(sql.as_bytes());
backend
.record_step("load", &hash, "", StepStatus::Success)
.unwrap();
let state = backend.get_step_state("load").unwrap().unwrap();
assert_eq!(state.sql_hash, hash);
assert_eq!(state.status, StepStatus::Success);
}
#[test]
fn test_run_history() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.duckdb");
let backend = DuckDbStateBackend::new(&db_path);
backend.init().unwrap();
let run_id = backend.start_run().unwrap();
backend.finish_run(&run_id, 5, "success", 0).unwrap();
let conn = duckdb::Connection::open(&db_path).unwrap();
let (steps, outcome): (i64, String) = conn
.query_row(
"SELECT steps_executed, outcome FROM _arcform_runs WHERE run_id = ?1",
[&run_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(steps, 5);
assert_eq!(outcome, "success");
}
#[test]
fn test_content_hash_deterministic() {
let hash1 = content_hash(b"SELECT 1;");
let hash2 = content_hash(b"SELECT 1;");
assert_eq!(hash1, hash2);
let hash3 = content_hash(b"SELECT 2;");
assert_ne!(hash1, hash3);
}
}