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