Skip to main content

kmp_embedded/
commit_bundle.rs

1//! Commit-native memory safety for project-scoped embedded stores.
2//!
3//! A write is bracketed by a local pending marker. The marker exists before
4//! the store can change and disappears only after the complete event stream is
5//! durably replaced at `.kmp/memory.jsonl`. A crash or an ambiguous backend
6//! failure therefore leaves something `doctor` can name instead of silently
7//! leaving the only current copy in `.kernel/`.
8
9use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use kmp_adapter_embedded::{BundleHeader, EmbeddedKernelStore, merge_bundles, verify_bundle};
16use kmp_domain::PortError;
17
18use crate::{ResolvedDataDir, project_bundle_path};
19
20pub const PENDING_EXPORT_DIR: &str = "bundle-export-pending";
21const EXPORT_LOCK_FILE: &str = "commit-native-bundle.lock";
22
23static UNIQUE_FILE: AtomicU64 = AtomicU64::new(0);
24
25/// The committed head bundle paired with the machine store it protects.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CommitNativeBundle {
28    data_dir: PathBuf,
29    bundle_path: PathBuf,
30}
31
32impl CommitNativeBundle {
33    /// Project stores have a conventional git path. Explicit and user-default
34    /// stores do not: exporting either beside the caller's cwd would recreate
35    /// the wrong-directory backup bug under another name.
36    pub fn for_resolved(resolved: &ResolvedDataDir) -> Option<Self> {
37        project_bundle_path(resolved).map(|bundle_path| Self {
38            data_dir: resolved.path().to_path_buf(),
39            bundle_path,
40        })
41    }
42
43    pub fn new(data_dir: impl Into<PathBuf>, bundle_path: impl Into<PathBuf>) -> Self {
44        Self {
45            data_dir: data_dir.into(),
46            bundle_path: bundle_path.into(),
47        }
48    }
49
50    pub fn path(&self) -> &Path {
51        &self.bundle_path
52    }
53
54    /// Marks a write as needing an export before the store can change.
55    pub fn begin_write(&self) -> Result<PendingBundleExport, PortError> {
56        let pending_dir = self.data_dir.join(PENDING_EXPORT_DIR);
57        fs::create_dir_all(&pending_dir).map_err(|error| {
58            PortError::Unavailable(format!(
59                "could not create commit-native export marker directory `{}`: {error}",
60                pending_dir.display()
61            ))
62        })?;
63        let marker = pending_dir.join(unique_name("write", "pending"));
64        let mut file = OpenOptions::new()
65            .create_new(true)
66            .write(true)
67            .open(&marker)
68            .map_err(|error| {
69                PortError::Unavailable(format!(
70                    "could not create commit-native export marker `{}`: {error}",
71                    marker.display()
72                ))
73            })?;
74        writeln!(file, "bundle={}", self.bundle_path.display()).map_err(|error| {
75            PortError::Unavailable(format!(
76                "could not write commit-native export marker `{}`: {error}",
77                marker.display()
78            ))
79        })?;
80        file.sync_all().map_err(|error| {
81            PortError::Unavailable(format!(
82                "could not make commit-native export marker `{}` durable: {error}",
83                marker.display()
84            ))
85        })?;
86        sync_parent(Some(&pending_dir))?;
87        Ok(PendingBundleExport { marker })
88    }
89
90    /// Writes the complete stream after a successful memory mutation. An
91    /// identical digest is already current, so an idempotent retry does not
92    /// churn the snapshot creation time in git.
93    pub async fn publish(&self, store: &EmbeddedKernelStore) -> Result<BundleHeader, PortError> {
94        let bundle = store.export_bundle().await?;
95        let header = verify_bundle(&bundle)?;
96        let lock_path = self.data_dir.join(EXPORT_LOCK_FILE);
97        let publish_lock = OpenOptions::new()
98            .create(true)
99            .truncate(false)
100            .read(true)
101            .write(true)
102            .open(&lock_path)
103            .map_err(|error| {
104                PortError::Unavailable(format!(
105                    "could not open commit-native export lock `{}`: {error}",
106                    lock_path.display()
107                ))
108            })?;
109        publish_lock.lock().map_err(|error| {
110            PortError::Unavailable(format!(
111                "could not lock commit-native export `{}`: {error}",
112                lock_path.display()
113            ))
114        })?;
115        if let Ok(current_bundle) = fs::read_to_string(&self.bundle_path)
116            && let Ok(current_header) = verify_bundle(&current_bundle)
117        {
118            if current_header.content_digest == header.content_digest
119                && current_header.event_count == header.event_count
120            {
121                return Ok(header);
122            }
123            // This is both a compatibility proof and a stale-writer guard.
124            // A later export may already protect more events; never replace
125            // it with this writer's shorter view. Divergence is loud and
126            // leaves the pending marker for recovery.
127            merge_bundles(&current_bundle, &bundle, "commit-native-prefix-check")?;
128            if current_header.event_count > header.event_count {
129                return Ok(current_header);
130            }
131        }
132        write_bundle_atomically(&self.bundle_path, &bundle)?;
133        Ok(header)
134    }
135}
136
137/// A marker intentionally has no cleanup in `Drop`: unwinding, a killed
138/// process, or an ambiguous backend error are precisely the cases that must
139/// remain visible to `doctor`.
140pub struct PendingBundleExport {
141    marker: PathBuf,
142}
143
144impl PendingBundleExport {
145    pub fn complete(self) -> Result<(), PortError> {
146        remove_marker(&self.marker)
147    }
148}
149
150pub fn pending_bundle_exports(data_dir: &Path) -> Vec<PathBuf> {
151    let Ok(entries) = fs::read_dir(data_dir.join(PENDING_EXPORT_DIR)) else {
152        return Vec::new();
153    };
154    let mut pending: Vec<PathBuf> = entries
155        .filter_map(Result::ok)
156        .map(|entry| entry.path())
157        .filter(|path| path.is_file())
158        .collect();
159    pending.sort();
160    pending
161}
162
163/// Clears markers after an operator has stopped other writers and explicitly
164/// acknowledged that a successful full export contains every committed write.
165pub fn clear_pending_bundle_exports(data_dir: &Path) -> Result<(), PortError> {
166    for marker in pending_bundle_exports(data_dir) {
167        remove_marker(&marker)?;
168    }
169    Ok(())
170}
171
172/// Same-directory durable replacement, so a failed export leaves either the
173/// previous complete bundle or the next complete bundle, never half a JSONL
174/// stream. Unix rename replaces atomically; Windows keeps the previous file
175/// beside it until the new one has taken the canonical name.
176pub fn write_bundle_atomically(path: &Path, bundle: &str) -> Result<(), PortError> {
177    let parent = path
178        .parent()
179        .filter(|parent| !parent.as_os_str().is_empty());
180    if let Some(parent) = parent {
181        fs::create_dir_all(parent).map_err(|error| {
182            PortError::Unavailable(format!(
183                "could not create bundle directory `{}`: {error}",
184                parent.display()
185            ))
186        })?;
187    }
188    let temp = path.with_file_name(unique_name("memory", "tmp"));
189    let write_result = (|| -> Result<(), PortError> {
190        let mut file = OpenOptions::new()
191            .create_new(true)
192            .write(true)
193            .open(&temp)
194            .map_err(|error| {
195                PortError::Unavailable(format!(
196                    "could not create temporary bundle `{}`: {error}",
197                    temp.display()
198                ))
199            })?;
200        file.write_all(bundle.as_bytes()).map_err(|error| {
201            PortError::Unavailable(format!(
202                "could not write temporary bundle `{}`: {error}",
203                temp.display()
204            ))
205        })?;
206        file.sync_all().map_err(|error| {
207            PortError::Unavailable(format!(
208                "could not make temporary bundle `{}` durable: {error}",
209                temp.display()
210            ))
211        })?;
212        replace_file(&temp, path).map_err(|error| {
213            PortError::Unavailable(format!(
214                "could not replace bundle `{}`: {error}",
215                path.display()
216            ))
217        })?;
218        sync_parent(parent)?;
219        Ok(())
220    })();
221    if write_result.is_err() {
222        let _ = fs::remove_file(&temp);
223    }
224    write_result
225}
226
227/// Publishes an immutable bundle without a check-then-replace race. The hard
228/// link is an atomic create-if-absent operation on the same filesystem: two
229/// snapshot creators can agree on existing content, but neither can replace
230/// the other's recovery point.
231pub fn write_bundle_if_absent(path: &Path, bundle: &str) -> Result<bool, PortError> {
232    let parent = path
233        .parent()
234        .filter(|parent| !parent.as_os_str().is_empty());
235    if let Some(parent) = parent {
236        fs::create_dir_all(parent).map_err(|error| {
237            PortError::Unavailable(format!(
238                "could not create bundle directory `{}`: {error}",
239                parent.display()
240            ))
241        })?;
242    }
243    let staged = path.with_file_name(unique_name("snapshot", "tmp"));
244    write_bundle_atomically(&staged, bundle)?;
245    let linked = match fs::hard_link(&staged, path) {
246        Ok(()) => true,
247        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false,
248        Err(error) => {
249            let _ = fs::remove_file(&staged);
250            return Err(PortError::Unavailable(format!(
251                "could not publish immutable bundle `{}`: {error}",
252                path.display()
253            )));
254        }
255    };
256    fs::remove_file(&staged).map_err(|error| {
257        PortError::Unavailable(format!(
258            "could not remove staged bundle `{}`: {error}",
259            staged.display()
260        ))
261    })?;
262    sync_parent(parent)?;
263    Ok(linked)
264}
265
266fn remove_marker(marker: &Path) -> Result<(), PortError> {
267    match fs::remove_file(marker) {
268        Ok(()) => {}
269        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
270        Err(error) => {
271            return Err(PortError::Unavailable(format!(
272                "could not clear commit-native export marker `{}`: {error}",
273                marker.display()
274            )));
275        }
276    }
277    if let Some(parent) = marker.parent() {
278        // Keep the empty marker directory. Syncing it makes the deletion as
279        // durable as creation; removing it immediately would require syncing
280        // its parent as a second filesystem transaction.
281        sync_parent(Some(parent))?;
282    }
283    Ok(())
284}
285
286fn unique_name(prefix: &str, suffix: &str) -> String {
287    let time = SystemTime::now()
288        .duration_since(UNIX_EPOCH)
289        .unwrap_or(Duration::ZERO)
290        .as_nanos();
291    let sequence = UNIQUE_FILE.fetch_add(1, Ordering::Relaxed);
292    format!(
293        ".{prefix}-{}-{time}-{sequence}.{suffix}",
294        std::process::id()
295    )
296}
297
298#[cfg(not(windows))]
299fn replace_file(temp: &Path, destination: &Path) -> std::io::Result<()> {
300    fs::rename(temp, destination)
301}
302
303#[cfg(windows)]
304fn replace_file(temp: &Path, destination: &Path) -> std::io::Result<()> {
305    let previous = destination.with_file_name(unique_name("memory", "previous"));
306    if destination.exists() {
307        fs::rename(destination, &previous)?;
308    }
309    match fs::rename(temp, destination) {
310        Ok(()) => {
311            let _ = fs::remove_file(previous);
312            Ok(())
313        }
314        Err(error) => {
315            let _ = fs::rename(previous, destination);
316            Err(error)
317        }
318    }
319}
320
321#[cfg(unix)]
322fn sync_parent(parent: Option<&Path>) -> Result<(), PortError> {
323    let Some(parent) = parent else {
324        return Ok(());
325    };
326    std::fs::File::open(parent)
327        .and_then(|directory| directory.sync_all())
328        .map_err(|error| {
329            PortError::Unavailable(format!(
330                "could not make bundle directory `{}` durable: {error}",
331                parent.display()
332            ))
333        })
334}
335
336#[cfg(not(unix))]
337fn sync_parent(_parent: Option<&Path>) -> Result<(), PortError> {
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn pending_marker_survives_until_the_export_completes() {
347        let dir = tempfile::tempdir().expect("dir");
348        let native = CommitNativeBundle::new(
349            dir.path().join(".kernel"),
350            dir.path().join(".kmp/memory.jsonl"),
351        );
352        let pending = native.begin_write().expect("marker");
353        assert_eq!(pending_bundle_exports(&dir.path().join(".kernel")).len(), 1);
354
355        pending.complete().expect("complete");
356        assert!(pending_bundle_exports(&dir.path().join(".kernel")).is_empty());
357    }
358
359    #[test]
360    fn atomic_write_replaces_a_complete_bundle() {
361        let dir = tempfile::tempdir().expect("dir");
362        let path = dir.path().join(".kmp/memory.jsonl");
363        write_bundle_atomically(&path, "first\n").expect("first");
364        write_bundle_atomically(&path, "second\n").expect("second");
365        assert_eq!(fs::read_to_string(path).expect("read"), "second\n");
366    }
367
368    #[test]
369    fn immutable_write_never_replaces_an_existing_recovery_point() {
370        let dir = tempfile::tempdir().expect("dir");
371        let path = dir.path().join(".kmp/snapshots/release.jsonl");
372        assert!(write_bundle_if_absent(&path, "first\n").expect("created"));
373        assert!(!write_bundle_if_absent(&path, "second\n").expect("exists"));
374        assert_eq!(fs::read_to_string(path).expect("read"), "first\n");
375    }
376}