kmp_embedded/
commit_bundle.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CommitNativeBundle {
28 data_dir: PathBuf,
29 bundle_path: PathBuf,
30}
31
32impl CommitNativeBundle {
33 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 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 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(¤t_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 merge_bundles(¤t_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
137pub 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
163pub 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
172pub 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
227pub 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 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}