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 and an inter-process lock.
4//! Before the store can change, the live event stream must exactly match the
5//! committed `.kmp/memory.jsonl` stream. The marker then disappears only after
6//! the complete post-write stream is durably published. A stale checkout is
7//! therefore rejected before SQLite changes, while a crash or an ambiguous
8//! backend failure leaves something `doctor` can name.
9
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use kmp_adapter_embedded::{BundleHeader, EmbeddedKernelStore, merge_bundles, verify_bundle};
17use kmp_domain::PortError;
18
19use crate::{ResolvedDataDir, project_bundle_path};
20
21pub const PENDING_EXPORT_DIR: &str = "bundle-export-pending";
22const EXPORT_LOCK_FILE: &str = "commit-native-bundle.lock";
23
24static UNIQUE_FILE: AtomicU64 = AtomicU64::new(0);
25
26/// The committed head bundle paired with the machine store it protects.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CommitNativeBundle {
29    data_dir: PathBuf,
30    bundle_path: PathBuf,
31}
32
33impl CommitNativeBundle {
34    /// Project stores have a conventional git path. Explicit and user-default
35    /// stores do not: exporting either beside the caller's cwd would recreate
36    /// the wrong-directory backup bug under another name.
37    pub fn for_resolved(resolved: &ResolvedDataDir) -> Option<Self> {
38        project_bundle_path(resolved).map(|bundle_path| Self {
39            data_dir: resolved.path().to_path_buf(),
40            bundle_path,
41        })
42    }
43
44    pub fn new(data_dir: impl Into<PathBuf>, bundle_path: impl Into<PathBuf>) -> Self {
45        Self {
46            data_dir: data_dir.into(),
47            bundle_path: bundle_path.into(),
48        }
49    }
50
51    pub fn path(&self) -> &Path {
52        &self.bundle_path
53    }
54
55    /// Proves that the live store and committed bundle are the same history,
56    /// then marks a write as needing an export before the store can change.
57    /// The returned guard holds the inter-process lock through publication.
58    pub async fn begin_write(
59        &self,
60        store: &EmbeddedKernelStore,
61    ) -> Result<PendingBundleExport, PortError> {
62        let pending_dir = self.data_dir.join(PENDING_EXPORT_DIR);
63        fs::create_dir_all(&pending_dir).map_err(|error| {
64            PortError::Unavailable(format!(
65                "could not create commit-native export marker directory `{}`: {error}",
66                pending_dir.display()
67            ))
68        })?;
69        let lock_path = self.data_dir.join(EXPORT_LOCK_FILE);
70        let publish_lock = OpenOptions::new()
71            .create(true)
72            .truncate(false)
73            .read(true)
74            .write(true)
75            .open(&lock_path)
76            .map_err(|error| {
77                PortError::Unavailable(format!(
78                    "could not open commit-native export lock `{}`: {error}",
79                    lock_path.display()
80                ))
81            })?;
82        publish_lock.try_lock().map_err(|error| match error {
83            std::fs::TryLockError::WouldBlock => PortError::Conflict(format!(
84                "another commit-native memory write holds `{}`; retry after it completes",
85                lock_path.display()
86            )),
87            std::fs::TryLockError::Error(error) => PortError::Unavailable(format!(
88                "could not lock commit-native export `{}`: {error}",
89                lock_path.display()
90            )),
91        })?;
92
93        let pending = pending_bundle_exports(&self.data_dir);
94        if !pending.is_empty() {
95            return Err(PortError::Conflict(format!(
96                "{} commit-native export marker(s) are still pending in `{}`; reconcile the \
97                 canonical bundle explicitly before another memory write",
98                pending.len(),
99                pending_dir.display()
100            )));
101        }
102
103        let live_before = store.export_bundle().await?;
104        let live_header = verify_bundle(&live_before)?;
105        let canonical_before = match fs::read_to_string(&self.bundle_path) {
106            Ok(bundle) => {
107                let canonical_header = verify_bundle(&bundle).map_err(|error| {
108                    PortError::InvalidState(format!(
109                        "committed memory bundle `{}` is invalid: {error}",
110                        self.bundle_path.display()
111                    ))
112                })?;
113                // Equal-length histories can still be different branches, so
114                // compare their decoded event streams rather than trusting
115                // metadata alone. Prefixes are valid bundles but not a safe
116                // base for a new project write: Git and SQLite must agree
117                // exactly before either can advance.
118                merge_bundles(&bundle, &live_before, "commit-native-preflight")?;
119                if canonical_header.event_count != live_header.event_count {
120                    return Err(PortError::Conflict(format!(
121                        "committed memory bundle `{}` has {} events while the live store has {}; \
122                         refusing to change SQLite until the two histories are explicitly \
123                         reconciled",
124                        self.bundle_path.display(),
125                        canonical_header.event_count,
126                        live_header.event_count
127                    )));
128                }
129                Some(bundle)
130            }
131            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
132                if live_header.event_count != 0 {
133                    return Err(PortError::Conflict(format!(
134                        "live project store has {} events but committed memory bundle `{}` is \
135                         missing; export or recover it explicitly before another memory write",
136                        live_header.event_count,
137                        self.bundle_path.display()
138                    )));
139                }
140                None
141            }
142            Err(error) => {
143                return Err(PortError::Unavailable(format!(
144                    "could not read committed memory bundle `{}`: {error}",
145                    self.bundle_path.display()
146                )));
147            }
148        };
149
150        let marker = pending_dir.join(unique_name("write", "pending"));
151        let mut file = OpenOptions::new()
152            .create_new(true)
153            .write(true)
154            .open(&marker)
155            .map_err(|error| {
156                PortError::Unavailable(format!(
157                    "could not create commit-native export marker `{}`: {error}",
158                    marker.display()
159                ))
160            })?;
161        writeln!(file, "bundle={}", self.bundle_path.display()).map_err(|error| {
162            PortError::Unavailable(format!(
163                "could not write commit-native export marker `{}`: {error}",
164                marker.display()
165            ))
166        })?;
167        file.sync_all().map_err(|error| {
168            PortError::Unavailable(format!(
169                "could not make commit-native export marker `{}` durable: {error}",
170                marker.display()
171            ))
172        })?;
173        sync_parent(Some(&pending_dir))?;
174        Ok(PendingBundleExport {
175            marker,
176            publish_lock,
177            canonical_before,
178            live_before,
179        })
180    }
181
182    /// Writes the complete stream after a successful memory mutation. An
183    /// identical digest is already current, so an idempotent retry does not
184    /// churn the snapshot creation time in git.
185    pub async fn publish(
186        &self,
187        store: &EmbeddedKernelStore,
188        pending: &PendingBundleExport,
189    ) -> Result<BundleHeader, PortError> {
190        let bundle = store.export_bundle().await?;
191        let header = verify_bundle(&bundle)?;
192        merge_bundles(
193            &pending.live_before,
194            &bundle,
195            "commit-native-post-write-check",
196        )?;
197        let live_before_header = verify_bundle(&pending.live_before)?;
198        if header.event_count < live_before_header.event_count {
199            return Err(PortError::Conflict(format!(
200                "live memory history shrank from {} to {} events during a guarded write",
201                live_before_header.event_count, header.event_count
202            )));
203        }
204
205        let canonical_now = match fs::read_to_string(&self.bundle_path) {
206            Ok(bundle) => Some(bundle),
207            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
208            Err(error) => {
209                return Err(PortError::Unavailable(format!(
210                    "could not re-read committed memory bundle `{}`: {error}",
211                    self.bundle_path.display()
212                )));
213            }
214        };
215        if canonical_now != pending.canonical_before {
216            return Err(PortError::Conflict(format!(
217                "committed memory bundle `{}` changed during a guarded write; the pending marker \
218                 remains for explicit recovery",
219                self.bundle_path.display()
220            )));
221        }
222        write_bundle_atomically(&self.bundle_path, &bundle)?;
223        Ok(header)
224    }
225}
226
227/// A marker intentionally has no cleanup in `Drop`: unwinding, a killed
228/// process, or an ambiguous backend error are precisely the cases that must
229/// remain visible to `doctor`.
230pub struct PendingBundleExport {
231    marker: PathBuf,
232    publish_lock: fs::File,
233    canonical_before: Option<String>,
234    live_before: String,
235}
236
237impl PendingBundleExport {
238    pub fn complete(self) -> Result<(), PortError> {
239        remove_marker(&self.marker)?;
240        self.publish_lock.unlock().map_err(|error| {
241            PortError::Unavailable(format!(
242                "could not unlock commit-native export after clearing `{}`: {error}",
243                self.marker.display()
244            ))
245        })
246    }
247}
248
249pub fn pending_bundle_exports(data_dir: &Path) -> Vec<PathBuf> {
250    let Ok(entries) = fs::read_dir(data_dir.join(PENDING_EXPORT_DIR)) else {
251        return Vec::new();
252    };
253    let mut pending: Vec<PathBuf> = entries
254        .filter_map(Result::ok)
255        .map(|entry| entry.path())
256        .filter(|path| path.is_file())
257        .collect();
258    pending.sort();
259    pending
260}
261
262/// Clears markers after an operator has stopped other writers and explicitly
263/// acknowledged that a successful full export contains every committed write.
264pub fn clear_pending_bundle_exports(data_dir: &Path) -> Result<(), PortError> {
265    for marker in pending_bundle_exports(data_dir) {
266        remove_marker(&marker)?;
267    }
268    Ok(())
269}
270
271/// Same-directory durable replacement, so a failed export leaves either the
272/// previous complete bundle or the next complete bundle, never half a JSONL
273/// stream. Unix rename replaces atomically; Windows keeps the previous file
274/// beside it until the new one has taken the canonical name.
275pub fn write_bundle_atomically(path: &Path, bundle: &str) -> Result<(), PortError> {
276    let parent = path
277        .parent()
278        .filter(|parent| !parent.as_os_str().is_empty());
279    if let Some(parent) = parent {
280        fs::create_dir_all(parent).map_err(|error| {
281            PortError::Unavailable(format!(
282                "could not create bundle directory `{}`: {error}",
283                parent.display()
284            ))
285        })?;
286    }
287    let temp = path.with_file_name(unique_name("memory", "tmp"));
288    let write_result = (|| -> Result<(), PortError> {
289        let mut file = OpenOptions::new()
290            .create_new(true)
291            .write(true)
292            .open(&temp)
293            .map_err(|error| {
294                PortError::Unavailable(format!(
295                    "could not create temporary bundle `{}`: {error}",
296                    temp.display()
297                ))
298            })?;
299        file.write_all(bundle.as_bytes()).map_err(|error| {
300            PortError::Unavailable(format!(
301                "could not write temporary bundle `{}`: {error}",
302                temp.display()
303            ))
304        })?;
305        file.sync_all().map_err(|error| {
306            PortError::Unavailable(format!(
307                "could not make temporary bundle `{}` durable: {error}",
308                temp.display()
309            ))
310        })?;
311        replace_file(&temp, path).map_err(|error| {
312            PortError::Unavailable(format!(
313                "could not replace bundle `{}`: {error}",
314                path.display()
315            ))
316        })?;
317        sync_parent(parent)?;
318        Ok(())
319    })();
320    if write_result.is_err() {
321        let _ = fs::remove_file(&temp);
322    }
323    write_result
324}
325
326/// Publishes an immutable bundle without a check-then-replace race. The hard
327/// link is an atomic create-if-absent operation on the same filesystem: two
328/// snapshot creators can agree on existing content, but neither can replace
329/// the other's recovery point.
330pub fn write_bundle_if_absent(path: &Path, bundle: &str) -> Result<bool, PortError> {
331    let parent = path
332        .parent()
333        .filter(|parent| !parent.as_os_str().is_empty());
334    if let Some(parent) = parent {
335        fs::create_dir_all(parent).map_err(|error| {
336            PortError::Unavailable(format!(
337                "could not create bundle directory `{}`: {error}",
338                parent.display()
339            ))
340        })?;
341    }
342    let staged = path.with_file_name(unique_name("snapshot", "tmp"));
343    write_bundle_atomically(&staged, bundle)?;
344    let linked = match fs::hard_link(&staged, path) {
345        Ok(()) => true,
346        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false,
347        Err(error) => {
348            let _ = fs::remove_file(&staged);
349            return Err(PortError::Unavailable(format!(
350                "could not publish immutable bundle `{}`: {error}",
351                path.display()
352            )));
353        }
354    };
355    fs::remove_file(&staged).map_err(|error| {
356        PortError::Unavailable(format!(
357            "could not remove staged bundle `{}`: {error}",
358            staged.display()
359        ))
360    })?;
361    sync_parent(parent)?;
362    Ok(linked)
363}
364
365fn remove_marker(marker: &Path) -> Result<(), PortError> {
366    match fs::remove_file(marker) {
367        Ok(()) => {}
368        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
369        Err(error) => {
370            return Err(PortError::Unavailable(format!(
371                "could not clear commit-native export marker `{}`: {error}",
372                marker.display()
373            )));
374        }
375    }
376    if let Some(parent) = marker.parent() {
377        // Keep the empty marker directory. Syncing it makes the deletion as
378        // durable as creation; removing it immediately would require syncing
379        // its parent as a second filesystem transaction.
380        sync_parent(Some(parent))?;
381    }
382    Ok(())
383}
384
385fn unique_name(prefix: &str, suffix: &str) -> String {
386    let time = SystemTime::now()
387        .duration_since(UNIX_EPOCH)
388        .unwrap_or(Duration::ZERO)
389        .as_nanos();
390    let sequence = UNIQUE_FILE.fetch_add(1, Ordering::Relaxed);
391    format!(
392        ".{prefix}-{}-{time}-{sequence}.{suffix}",
393        std::process::id()
394    )
395}
396
397#[cfg(not(windows))]
398fn replace_file(temp: &Path, destination: &Path) -> std::io::Result<()> {
399    fs::rename(temp, destination)
400}
401
402#[cfg(windows)]
403fn replace_file(temp: &Path, destination: &Path) -> std::io::Result<()> {
404    let previous = destination.with_file_name(unique_name("memory", "previous"));
405    if destination.exists() {
406        fs::rename(destination, &previous)?;
407    }
408    match fs::rename(temp, destination) {
409        Ok(()) => {
410            let _ = fs::remove_file(previous);
411            Ok(())
412        }
413        Err(error) => {
414            let _ = fs::rename(previous, destination);
415            Err(error)
416        }
417    }
418}
419
420#[cfg(unix)]
421fn sync_parent(parent: Option<&Path>) -> Result<(), PortError> {
422    let Some(parent) = parent else {
423        return Ok(());
424    };
425    std::fs::File::open(parent)
426        .and_then(|directory| directory.sync_all())
427        .map_err(|error| {
428            PortError::Unavailable(format!(
429                "could not make bundle directory `{}` durable: {error}",
430                parent.display()
431            ))
432        })
433}
434
435#[cfg(not(unix))]
436fn sync_parent(_parent: Option<&Path>) -> Result<(), PortError> {
437    Ok(())
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[tokio::test]
445    async fn pending_marker_survives_until_the_export_completes() {
446        let dir = tempfile::tempdir().expect("dir");
447        let kernel = crate::EmbeddedKernel::open(&dir.path().join(".kernel")).expect("kernel");
448        let native = CommitNativeBundle::new(
449            dir.path().join(".kernel"),
450            dir.path().join(".kmp/memory.jsonl"),
451        );
452        let pending = native.begin_write(kernel.store()).await.expect("marker");
453        assert_eq!(pending_bundle_exports(&dir.path().join(".kernel")).len(), 1);
454
455        pending.complete().expect("complete");
456        assert!(pending_bundle_exports(&dir.path().join(".kernel")).is_empty());
457    }
458
459    #[tokio::test]
460    async fn a_concurrent_writer_is_rejected_without_blocking_the_runtime() {
461        let dir = tempfile::tempdir().expect("dir");
462        let data_dir = dir.path().join(".kernel");
463        let kernel = crate::EmbeddedKernel::open(&data_dir).expect("kernel");
464        let native = CommitNativeBundle::new(&data_dir, dir.path().join(".kmp/memory.jsonl"));
465        let lock_path = data_dir.join(EXPORT_LOCK_FILE);
466        let competing_writer = OpenOptions::new()
467            .create(true)
468            .truncate(false)
469            .read(true)
470            .write(true)
471            .open(&lock_path)
472            .expect("lock file");
473        competing_writer.lock().expect("competing lock");
474
475        let error = match native.begin_write(kernel.store()).await {
476            Ok(_) => panic!("a second writer must fail fast"),
477            Err(error) => error,
478        };
479
480        assert!(matches!(error, PortError::Conflict(_)));
481        assert!(pending_bundle_exports(&data_dir).is_empty());
482    }
483
484    #[test]
485    fn atomic_write_replaces_a_complete_bundle() {
486        let dir = tempfile::tempdir().expect("dir");
487        let path = dir.path().join(".kmp/memory.jsonl");
488        write_bundle_atomically(&path, "first\n").expect("first");
489        write_bundle_atomically(&path, "second\n").expect("second");
490        assert_eq!(fs::read_to_string(path).expect("read"), "second\n");
491    }
492
493    #[test]
494    fn immutable_write_never_replaces_an_existing_recovery_point() {
495        let dir = tempfile::tempdir().expect("dir");
496        let path = dir.path().join(".kmp/snapshots/release.jsonl");
497        assert!(write_bundle_if_absent(&path, "first\n").expect("created"));
498        assert!(!write_bundle_if_absent(&path, "second\n").expect("exists"));
499        assert_eq!(fs::read_to_string(path).expect("read"), "first\n");
500    }
501}