Skip to main content

rbt/materializer/
wap.rs

1//! Honest Write-Audit-Publish for lake files (not Iceberg branch theater).
2//!
3//! Protocol:
4//! 1. **Write** model output to a staging path under `.wap/{run_id}/`
5//! 2. **Audit** via streaming assertions (caller); fail leaves production dest untouched
6//! 3. **Publish** atomic rename stage → production dest
7//! 4. Write a small audit log JSON next to stage (kept on failure for debug)
8//!
9//! This is real atomic publish + test gate. It does **not** claim Iceberg WAP branches.
10
11use anyhow::{bail, Context, Result};
12use serde::{Deserialize, Serialize};
13use std::fs::{self, File};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::materializer::stream::atomic_publish;
19use crate::testing::ValidationResult;
20
21/// Status of a WAP run for one model.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum WapPhase {
25    Staged,
26    AuditedOk,
27    AuditedFail,
28    Published,
29    RolledBack,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WapAuditLog {
34    pub run_id: String,
35    pub model: String,
36    pub stage_path: String,
37    pub final_path: String,
38    pub phase: WapPhase,
39    pub rows: usize,
40    pub assertion_errors: Vec<String>,
41    pub published_at_ms: Option<u64>,
42}
43
44/// One model’s WAP paths for a run.
45#[derive(Debug, Clone)]
46pub struct WapModelPaths {
47    pub run_id: String,
48    pub stage_path: PathBuf,
49    pub final_path: PathBuf,
50    pub audit_log_path: PathBuf,
51}
52
53impl WapModelPaths {
54    pub fn for_model(
55        project_dir: &Path,
56        run_id: &str,
57        model_name: &str,
58        final_path: &Path,
59    ) -> Self {
60        let stage_root = project_dir.join(".wap").join(run_id);
61        let ext = final_path
62            .extension()
63            .and_then(|e| e.to_str())
64            .unwrap_or("parquet");
65        let stage_path = stage_root.join(format!("{model_name}.{ext}"));
66        let audit_log_path = stage_root.join(format!("{model_name}.audit.json"));
67        Self {
68            run_id: run_id.to_string(),
69            stage_path,
70            final_path: final_path.to_path_buf(),
71            audit_log_path,
72        }
73    }
74}
75
76pub fn new_wap_run_id() -> String {
77    let ms = SystemTime::now()
78        .duration_since(UNIX_EPOCH)
79        .map(|d| d.as_millis())
80        .unwrap_or(0);
81    format!("wap_{ms}")
82}
83
84/// Publish staged file to final path after successful audit.
85pub fn wap_publish(
86    paths: &WapModelPaths,
87    model: &str,
88    rows: usize,
89    validation: &ValidationResult,
90) -> Result<WapAuditLog> {
91    if validation.failed_assertions > 0 {
92        let log = WapAuditLog {
93            run_id: paths.run_id.clone(),
94            model: model.to_string(),
95            stage_path: paths.stage_path.display().to_string(),
96            final_path: paths.final_path.display().to_string(),
97            phase: WapPhase::AuditedFail,
98            rows,
99            assertion_errors: validation.errors.clone(),
100            published_at_ms: None,
101        };
102        write_audit_log(paths, &log)?;
103        bail!(
104            "E_RBT_WAP_AUDIT: model '{model}' failed {} assertion(s); production dest left unchanged: {}",
105            validation.failed_assertions,
106            validation.errors.join("; ")
107        );
108    }
109
110    if !paths.stage_path.exists() {
111        bail!(
112            "E_RBT_WAP_PUBLISH: stage missing {}",
113            paths.stage_path.display()
114        );
115    }
116    atomic_publish(&paths.stage_path, &paths.final_path).with_context(|| {
117        format!(
118            "E_RBT_WAP_PUBLISH: rename {} → {}",
119            paths.stage_path.display(),
120            paths.final_path.display()
121        )
122    })?;
123
124    let log = WapAuditLog {
125        run_id: paths.run_id.clone(),
126        model: model.to_string(),
127        stage_path: paths.stage_path.display().to_string(),
128        final_path: paths.final_path.display().to_string(),
129        phase: WapPhase::Published,
130        rows,
131        assertion_errors: Vec::new(),
132        published_at_ms: Some(
133            SystemTime::now()
134                .duration_since(UNIX_EPOCH)
135                .map(|d| d.as_millis() as u64)
136                .unwrap_or(0),
137        ),
138    };
139    write_audit_log(paths, &log)?;
140    tracing::info!(
141        "WAP published model '{}' ({} rows) → {}",
142        model,
143        rows,
144        paths.final_path.display()
145    );
146    Ok(log)
147}
148
149fn write_audit_log(paths: &WapModelPaths, log: &WapAuditLog) -> Result<()> {
150    if let Some(parent) = paths.audit_log_path.parent() {
151        fs::create_dir_all(parent)?;
152    }
153    let mut f = File::create(&paths.audit_log_path).with_context(|| {
154        format!(
155            "E_RBT_WAP: write audit log {}",
156            paths.audit_log_path.display()
157        )
158    })?;
159    writeln!(f, "{}", serde_json::to_string_pretty(log)?)?;
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::testing::ValidationResult;
167
168    #[test]
169    fn wap_publish_atomic() -> Result<()> {
170        let temp = tempfile::tempdir()?;
171        let final_p = temp.path().join("out.parquet");
172        let paths = WapModelPaths::for_model(temp.path(), "wap_1", "m", &final_p);
173        fs::create_dir_all(paths.stage_path.parent().unwrap())?;
174        fs::write(&paths.stage_path, b"staged-data")?;
175        fs::write(&final_p, b"old")?;
176        let ok = ValidationResult {
177            total_rows: 1,
178            passed_assertions: 1,
179            failed_assertions: 0,
180            errors: Vec::new(),
181        };
182        wap_publish(&paths, "m", 1, &ok)?;
183        assert_eq!(fs::read(&final_p)?, b"staged-data");
184        assert!(!paths.stage_path.exists());
185        assert!(paths.audit_log_path.exists());
186        Ok(())
187    }
188
189    #[test]
190    fn wap_fail_keeps_production() -> Result<()> {
191        let temp = tempfile::tempdir()?;
192        let final_p = temp.path().join("out.parquet");
193        fs::write(&final_p, b"production")?;
194        let paths = WapModelPaths::for_model(temp.path(), "wap_2", "m", &final_p);
195        fs::create_dir_all(paths.stage_path.parent().unwrap())?;
196        fs::write(&paths.stage_path, b"bad")?;
197        let bad = ValidationResult {
198            total_rows: 1,
199            passed_assertions: 0,
200            failed_assertions: 1,
201            errors: vec!["dup".into()],
202        };
203        let err = wap_publish(&paths, "m", 1, &bad).unwrap_err().to_string();
204        assert!(err.contains("E_RBT_WAP_AUDIT"));
205        assert_eq!(fs::read(&final_p)?, b"production");
206        Ok(())
207    }
208}