1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CommitNativeBundle {
29 data_dir: PathBuf,
30 bundle_path: PathBuf,
31}
32
33impl CommitNativeBundle {
34 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 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 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 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
227pub 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
262pub 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
271pub 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
326pub 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 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}