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::{
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#[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 pub fn for_resolved(resolved: &ResolvedDataDir) -> Option<Self> {
41 Self::for_resolved_excluding_abouts(resolved, Vec::new())
42 }
43
44 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 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 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 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
264pub 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
299pub 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
308pub 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
363pub 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 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}