Skip to main content

harn_vm/
checkpoint.rs

1//! Checkpoint system for resilient pipeline execution.
2//!
3//! Provides `checkpoint`, `checkpoint_get`, and `checkpoint_clear` builtins.
4//! Checkpoints are persisted to `<state-root>/checkpoints/<pipeline>.json`
5//! and survive pipeline crashes/timeouts. On resume, a pipeline can skip
6//! already-processed items by checking `checkpoint_get`.
7//!
8//! The per-pipeline state is per-thread: `register_checkpoint_builtins`
9//! installs a fresh [`CheckpointState`] into a thread-local cell so the
10//! `#[harn_builtin]`-emitted handler fns can read/mutate it without
11//! capturing closures (which would block the macro path). Each VM that
12//! registers checkpoint builtins overrides the cell for its thread —
13//! since the Harn VM is single-threaded per execution, this is the
14//! intended scoping (each pipeline run installs its own state once at
15//! VM setup, then the handlers see it for the duration of the run).
16
17use std::cell::RefCell;
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
22use crate::value::{VmError, VmValue};
23use crate::vm::Vm;
24
25struct CheckpointState {
26    data: BTreeMap<String, serde_json::Value>,
27    path: PathBuf,
28    loaded: bool,
29}
30
31impl CheckpointState {
32    fn new(base_dir: &Path, pipeline_name: &str) -> Self {
33        Self {
34            data: BTreeMap::new(),
35            path: crate::runtime_paths::checkpoint_dir(base_dir)
36                .join(format!("{pipeline_name}.json")),
37            loaded: false,
38        }
39    }
40
41    fn ensure_loaded(&mut self) {
42        if self.loaded {
43            return;
44        }
45        self.loaded = true;
46        if let Ok(contents) = std::fs::read_to_string(&self.path) {
47            if let Ok(serde_json::Value::Object(map)) =
48                serde_json::from_str::<serde_json::Value>(&contents)
49            {
50                for (k, v) in map {
51                    self.data.insert(k, v);
52                }
53            }
54        }
55    }
56
57    fn save(&self) -> Result<(), String> {
58        let obj: serde_json::Map<String, serde_json::Value> = self
59            .data
60            .iter()
61            .map(|(k, v)| (k.clone(), v.clone()))
62            .collect();
63        let json = serde_json::to_string_pretty(&serde_json::Value::Object(obj))
64            .map_err(|e| format!("checkpoint save error: {e}"))?;
65        crate::atomic_io::atomic_write(&self.path, json.as_bytes())
66            .map_err(|e| format!("checkpoint write error: {e}"))?;
67        Ok(())
68    }
69
70    fn get(&mut self, key: &str) -> VmValue {
71        self.ensure_loaded();
72        match self.data.get(key) {
73            Some(v) => json_to_vm(v),
74            None => VmValue::Nil,
75        }
76    }
77
78    fn set(&mut self, key: String, value: serde_json::Value) -> Result<(), String> {
79        self.ensure_loaded();
80        self.data.insert(key, value);
81        self.save()
82    }
83
84    fn clear(&mut self) -> Result<(), String> {
85        self.data.clear();
86        if self.path.exists() {
87            std::fs::remove_file(&self.path).map_err(|e| format!("checkpoint clear error: {e}"))?;
88        }
89        Ok(())
90    }
91
92    fn list(&mut self) -> Vec<String> {
93        self.ensure_loaded();
94        self.data.keys().cloned().collect()
95    }
96
97    fn exists(&mut self, key: &str) -> bool {
98        self.ensure_loaded();
99        self.data.contains_key(key)
100    }
101
102    fn delete(&mut self, key: &str) -> Result<(), String> {
103        self.ensure_loaded();
104        self.data.remove(key);
105        self.save()
106    }
107}
108
109thread_local! {
110    /// Active checkpoint state for the current thread's pipeline run.
111    /// Set by `register_checkpoint_builtins`; read by the
112    /// `#[harn_builtin]` handler fns. `None` before the first install
113    /// (i.e. when the checkpoint builtins haven't been registered for
114    /// this VM context) — in that case the handlers return a clear
115    /// runtime error rather than a panic.
116    static CHECKPOINT_STATE: RefCell<Option<CheckpointState>> = const { RefCell::new(None) };
117}
118
119fn with_state<R>(
120    fn_name: &'static str,
121    f: impl FnOnce(&mut CheckpointState) -> Result<R, VmError>,
122) -> Result<R, VmError> {
123    CHECKPOINT_STATE.with(|cell| {
124        let mut guard = cell.borrow_mut();
125        let state = guard.as_mut().ok_or_else(|| {
126            VmError::Runtime(format!(
127                "{fn_name}: checkpoint builtins not registered for this VM"
128            ))
129        })?;
130        f(state)
131    })
132}
133
134use crate::value::vm_to_storage_json as vm_to_json;
135
136fn json_to_vm(jv: &serde_json::Value) -> VmValue {
137    match jv {
138        serde_json::Value::Null => VmValue::Nil,
139        serde_json::Value::Bool(b) => VmValue::Bool(*b),
140        serde_json::Value::Number(n) => {
141            if let Some(i) = n.as_i64() {
142                VmValue::Int(i)
143            } else {
144                VmValue::Float(n.as_f64().unwrap_or(0.0))
145            }
146        }
147        serde_json::Value::String(s) => VmValue::String(arcstr::ArcStr::from(s.as_str())),
148        serde_json::Value::Array(arr) => {
149            VmValue::List(std::sync::Arc::new(arr.iter().map(json_to_vm).collect()))
150        }
151        serde_json::Value::Object(map) => {
152            let mut m = BTreeMap::new();
153            for (k, v) in map {
154                m.insert(k.clone(), json_to_vm(v));
155            }
156            VmValue::dict(m)
157        }
158    }
159}
160
161/// Sanitize a pipeline name for use as a filename.
162/// Rejects path traversal attempts and invalid characters.
163fn sanitize_pipeline_name(name: &str) -> String {
164    let base = std::path::Path::new(name)
165        .file_name()
166        .and_then(|f| f.to_str())
167        .unwrap_or("default");
168    if base.is_empty() || base == "." || base == ".." {
169        return "default".to_string();
170    }
171    base.to_string()
172}
173
174/// Register checkpoint builtins on a VM.
175///
176/// The pipeline name is used to namespace checkpoint files. If not provided,
177/// defaults to "default". State is installed into a thread-local cell that
178/// the `#[harn_builtin]`-emitted handlers below read; subsequent calls on
179/// the same thread overwrite the state for that thread (the Harn VM
180/// executes single-threaded per run).
181pub fn register_checkpoint_builtins(vm: &mut Vm, base_dir: &Path, pipeline_name: &str) {
182    let safe_name = sanitize_pipeline_name(pipeline_name);
183    CHECKPOINT_STATE.with(|cell| {
184        *cell.borrow_mut() = Some(CheckpointState::new(base_dir, &safe_name));
185    });
186    for def in MODULE_BUILTINS {
187        vm.register_builtin_def(def);
188    }
189}
190
191pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
192    &CHECKPOINT_IMPL_DEF,
193    &CHECKPOINT_GET_IMPL_DEF,
194    &CHECKPOINT_CLEAR_IMPL_DEF,
195    &CHECKPOINT_LIST_IMPL_DEF,
196    &CHECKPOINT_EXISTS_IMPL_DEF,
197    &CHECKPOINT_DELETE_IMPL_DEF,
198];
199
200#[harn_builtin(
201    sig = "checkpoint(key: string, value: any) -> nil",
202    category = "checkpoint",
203    doc = "Persist a checkpoint key/value pair to durable storage immediately."
204)]
205fn checkpoint_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
206    let key = args.first().map(|a| a.display()).unwrap_or_default();
207    let value = args.get(1).unwrap_or(&VmValue::Nil);
208    let json_val = vm_to_json(value);
209    with_state("checkpoint", |state| {
210        state.set(key, json_val).map_err(VmError::Runtime)
211    })?;
212    Ok(VmValue::Nil)
213}
214
215#[harn_builtin(
216    sig = "checkpoint_get(key: string) -> any",
217    category = "checkpoint",
218    doc = "Read a persisted checkpoint value, or nil if the key is absent."
219)]
220fn checkpoint_get_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
221    let key = args.first().map(|a| a.display()).unwrap_or_default();
222    with_state("checkpoint_get", |state| Ok(state.get(&key)))
223}
224
225#[harn_builtin(
226    sig = "checkpoint_clear() -> nil",
227    category = "checkpoint",
228    doc = "Clear every checkpoint for the active pipeline."
229)]
230fn checkpoint_clear_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
231    with_state("checkpoint_clear", |state| {
232        state.clear().map_err(VmError::Runtime)
233    })?;
234    Ok(VmValue::Nil)
235}
236
237#[harn_builtin(
238    sig = "checkpoint_list() -> list",
239    category = "checkpoint",
240    doc = "Return every checkpoint key for the active pipeline."
241)]
242fn checkpoint_list_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
243    with_state("checkpoint_list", |state| {
244        let keys = state.list();
245        Ok(VmValue::List(std::sync::Arc::new(
246            keys.into_iter()
247                .map(|k| VmValue::String(arcstr::ArcStr::from(k)))
248                .collect(),
249        )))
250    })
251}
252
253#[harn_builtin(
254    sig = "checkpoint_exists(key: string) -> bool",
255    category = "checkpoint",
256    doc = "Return true when the checkpoint key is present (even when its value is nil)."
257)]
258fn checkpoint_exists_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
259    let key = args.first().map(|a| a.display()).unwrap_or_default();
260    with_state("checkpoint_exists", |state| {
261        Ok(VmValue::Bool(state.exists(&key)))
262    })
263}
264
265#[harn_builtin(
266    sig = "checkpoint_delete(key: string) -> nil",
267    category = "checkpoint",
268    doc = "Remove a single key from the checkpoint store."
269)]
270fn checkpoint_delete_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
271    let key = args.first().map(|a| a.display()).unwrap_or_default();
272    with_state("checkpoint_delete", |state| {
273        state.delete(&key).map_err(VmError::Runtime)
274    })?;
275    Ok(VmValue::Nil)
276}