Skip to main content

timeseries_table_format/transaction_log/
log_store.rs

1//! Async helpers for persisting and reading the metadata log.
2//!
3//! This module owns all on-disk interactions with `_timeseries_log/`:
4//! - Tracking the `CURRENT` pointer and interpreting the "no file" case as
5//!   version `0` (fresh table).
6//! - Writing zero-padded commit files with optimistic concurrency control so
7//!   each version is created exactly once.
8//! - Mapping failures into [`CommitError`] variants so callers retain typed
9//!   storage, protocol, and state-validation causes.
10//!
11//! All operations delegate to the async storage backend and remain focused on
12//! durability, leaving higher-level planning (which actions to commit) to the
13//! caller.
14use crate::metadata::protocol::RawTableProtocolRequirements;
15use crate::storage::{self, StorageError, TableLocation};
16use crate::transaction_log::actions::{Commit, LogAction};
17use crate::transaction_log::*;
18use chrono::Utc;
19use snafu::Backtrace;
20use std::path::{Path, PathBuf};
21
22fn is_unknown_action(action: &serde_json::Value) -> bool {
23    let name = match action {
24        serde_json::Value::Object(fields) if fields.len() == 1 => fields.keys().next(),
25        serde_json::Value::String(name) => Some(name),
26        _ => None,
27    };
28
29    name.is_some_and(|name| {
30        !matches!(
31            name.as_str(),
32            "AddSegment" | "RemoveSegment" | "UpdateTableMeta" | "UpdateTableCoverage"
33        )
34    })
35}
36
37/// Helper for reading and writing the commit log under a table root.
38///
39/// Layout:
40///   `<root>/_timeseries_log/0000000001.json`
41///   `<root>/_timeseries_log/0000000002.json`
42///   `<root>/_timeseries_log/CURRENT`
43#[derive(Debug, Clone)]
44pub struct TransactionLogStore {
45    location: TableLocation,
46}
47
48impl TransactionLogStore {
49    /// Name of the subdirectory containing the commit log.
50    pub const LOG_DIR_NAME: &str = storage::layout::LOG_DIR_NAME;
51    /// Name of the file that stores the current version pointer.
52    pub const CURRENT_FILE_NAME: &str = storage::layout::CURRENT_FILE_NAME;
53    /// Number of digits used in zero-padded commit file names.
54    pub const COMMIT_FILENAME_DIGITS: usize = storage::layout::COMMIT_FILENAME_DIGITS;
55
56    /// Create a new TransactionLogStore rooted at a table directory.
57    pub fn new(location: TableLocation) -> Self {
58        Self { location }
59    }
60
61    /// Get the TableLocation of the LogStore.
62    pub fn location(&self) -> &TableLocation {
63        &self.location
64    }
65
66    fn commit_rel_path(version: u64) -> PathBuf {
67        storage::layout::commit_rel_path(version)
68    }
69
70    /// Helper: read a log-relative file and map storage errors into CommitError.
71    async fn read_to_string_rel(&self, rel: &Path) -> Result<String, CommitError> {
72        match storage::read_to_string(self.location.as_ref(), rel).await {
73            Ok(s) => Ok(s),
74            Err(source) => Err(CommitError::Storage { source }),
75        }
76    }
77
78    async fn rollback_unpublished_commit(
79        &self,
80        commit_rel: &Path,
81        publish_error: StorageError,
82    ) -> CommitError {
83        match storage::remove_file(self.location.as_ref(), commit_rel).await {
84            Ok(()) => CommitError::Storage {
85                source: publish_error,
86            },
87            Err(cleanup_error) => CommitError::AmbiguousOutcome {
88                commit_path: commit_rel.display().to_string(),
89                operation_error: Box::new(publish_error),
90                cleanup_error: Box::new(cleanup_error),
91            },
92        }
93    }
94
95    /// Load a single commit by version.
96    ///
97    /// - On storage-layer failures, returns `CommitError::Storage`.
98    /// - On JSON parse failures, returns [`CommitError::CommitDeserialization`].
99    pub async fn load_commit(&self, version: u64) -> Result<Commit, CommitError> {
100        let rel = Self::commit_rel_path(version);
101        let json = self.read_to_string_rel(&rel).await?;
102
103        let mut value: serde_json::Value =
104            serde_json::from_str(&json).map_err(|source| CommitError::CommitDeserialization {
105                version,
106                source,
107                backtrace: Backtrace::capture(),
108            })?;
109
110        for metadata in value
111            .get("actions")
112            .and_then(serde_json::Value::as_array)
113            .into_iter()
114            .flatten()
115            .filter_map(|action| {
116                let fields = action.as_object()?;
117                if fields.len() == 1 {
118                    fields.get("UpdateTableMeta")
119                } else {
120                    None
121                }
122            })
123        {
124            serde_json::from_value::<RawTableProtocolRequirements>(metadata.clone())
125                .map_err(|source| CommitError::CommitDeserialization {
126                    version,
127                    source,
128                    backtrace: Backtrace::capture(),
129                })?
130                .ensure_read_compatible()
131                .map_err(CommitError::from)?;
132        }
133
134        if let Some(actions) = value
135            .get_mut("actions")
136            .and_then(serde_json::Value::as_array_mut)
137        {
138            actions.retain(|action| !is_unknown_action(action));
139        }
140
141        let commit =
142            serde_json::from_value(value).map_err(|source| CommitError::CommitDeserialization {
143                version,
144                source,
145                backtrace: Backtrace::capture(),
146            })?;
147
148        Ok(commit)
149    }
150
151    /// Load the CURRENT version pointer.
152    ///
153    /// Behavior:
154    /// - If CURRENT does not exist, treat as a fresh table and return 0.
155    /// - If CURRENT contains invalid or empty content, return a typed pointer error.
156    pub async fn load_current_version(&self) -> Result<u64, CommitError> {
157        let rel = storage::layout::current_rel_path();
158
159        let contents = match storage::read_to_string(self.location.as_ref(), &rel).await {
160            Ok(s) => s,
161            Err(StorageError::NotFound { .. }) => return Ok(0),
162            Err(source) => return Err(CommitError::Storage { source }),
163        };
164
165        let trimmed = contents.trim();
166        if trimmed.is_empty() {
167            return Err(CommitError::EmptyCurrentPointer {
168                path: rel.display().to_string(),
169                backtrace: Backtrace::capture(),
170            });
171        }
172        let version =
173            trimmed
174                .parse::<u64>()
175                .map_err(|source| CommitError::CurrentVersionParse {
176                    contents: trimmed.to_string(),
177                    source,
178                    backtrace: Backtrace::capture(),
179                })?;
180
181        Ok(version)
182    }
183
184    /// Commit a new version with an optimistic concurrency guard.
185    ///
186    /// ## Concurrency semantics
187    ///
188    /// - The check on CURRENT is advisory/best-effort and subject to races.
189    ///   Two writers may both read the same CURRENT value and attempt to commit
190    ///   the same next version. The actual concurrency guard is the atomic
191    ///   creation of the commit file using "create only if not exists" semantics.
192    /// - If another writer wins the race and creates the commit file first,
193    ///   this operation will fail with `StorageError::AlreadyExists`.
194    /// - Callers must be prepared to handle `StorageError::AlreadyExists` and
195    ///   implement retry logic (e.g., reload CURRENT and retry the commit).
196    ///
197    /// If updating CURRENT fails, this method removes the commit file created
198    /// by this invocation. A cleanup failure returns
199    /// [`CommitError::AmbiguousOutcome`] so callers do not assume rollback.
200    ///
201    /// ## Steps
202    ///
203    /// 1. Load CURRENT (advisory check).
204    /// 2. If CURRENT != expected, return `CommitError::Conflict`.
205    /// 3. Compute version = expected + 1 (with overflow check).
206    /// 4. Build a `Commit` struct.
207    /// 5. Serialize to JSON.
208    /// 6. Create commit file `_timeseries_log/<zero-padded>.json` using
209    ///    "create only if not exists" semantics (atomic guard).
210    /// 7. Update `_timeseries_log/CURRENT` with the new version (e.g. `"1\n"`).
211    pub(crate) async fn commit_with_expected_version(
212        &self,
213        expected: u64,
214        actions: Vec<LogAction>,
215    ) -> Result<u64, CommitError> {
216        self.commit_inner(expected, actions, || {}).await
217    }
218
219    /// Commit while preserving newly created paths that may be referenced.
220    ///
221    /// `preserve_referenced_paths` runs before post-outcome tracing when the
222    /// commit succeeds or becomes ambiguous.
223    pub(crate) async fn commit_with_path_preservation<F>(
224        &self,
225        expected: u64,
226        actions: Vec<LogAction>,
227        preserve_referenced_paths: F,
228    ) -> Result<u64, CommitError>
229    where
230        F: FnOnce(),
231    {
232        self.commit_inner(expected, actions, preserve_referenced_paths)
233            .await
234    }
235
236    #[tracing::instrument(
237        name = "transaction.commit",
238        level = "debug",
239        skip_all,
240        fields(
241            expected_version = expected,
242            observed_version = tracing::field::Empty,
243            proposed_version = tracing::field::Empty,
244            committed_version = tracing::field::Empty,
245            action_count = actions.len(),
246            failure_stage = tracing::field::Empty,
247            rollback_outcome = tracing::field::Empty,
248            outcome = tracing::field::Empty
249        )
250    )]
251    async fn commit_inner<F>(
252        &self,
253        expected: u64,
254        actions: Vec<LogAction>,
255        on_commit_may_exist: F,
256    ) -> Result<u64, CommitError>
257    where
258        F: FnOnce(),
259    {
260        let span = tracing::Span::current();
261
262        // 1) Guard on CURRENT
263        let current = match self.load_current_version().await {
264            Ok(current) => current,
265            Err(error) => {
266                span.record("failure_stage", "current_read");
267                span.record("outcome", "failed");
268                return Err(error);
269            }
270        };
271        span.record("observed_version", current);
272        if current != expected {
273            span.record("failure_stage", "advisory_check");
274            span.record("outcome", "conflict");
275            return ConflictSnafu {
276                expected,
277                found: current,
278            }
279            .fail();
280        }
281
282        // 2) Compute next version with overflow guard
283        let version = match checked_next_version(expected) {
284            Ok(version) => version,
285            Err(error) => {
286                span.record("failure_stage", "version_calculation");
287                span.record("outcome", "failed");
288                return Err(error);
289            }
290        };
291        span.record("proposed_version", version);
292
293        // 3) Build commit payload
294        let commit = Commit {
295            version,
296            base_version: expected,
297            timestamp: Utc::now(),
298            actions,
299        };
300
301        let json = match serde_json::to_vec(&commit) {
302            Ok(json) => json,
303            Err(error) => {
304                span.record("failure_stage", "serialization");
305                span.record("outcome", "failed");
306                return Err(CommitError::CommitSerialization {
307                    version,
308                    source: error,
309                    backtrace: Backtrace::capture(),
310                });
311            }
312        };
313
314        // 4) Attempt to create the commit file *only if it does not already exist*.
315        //    If the file already exists (AlreadyExists error), we propagate it as-is
316        //    rather than converting to Conflict. This allows higher-level code to
317        //    implement automatic conflict resolution (e.g., retrying with rebased
318        //    changes if the operations don't actually conflict, like Delta Lake).
319        let commit_rel = Self::commit_rel_path(version);
320        let mut commit_guard =
321            storage::FileCleanupGuard::new_disarmed(self.location.as_ref(), &commit_rel)
322                .map_err(|source| CommitError::Storage { source })?;
323        match storage::write_new(self.location.as_ref(), &commit_rel, &json).await {
324            Ok(()) => commit_guard.arm(),
325            Err(StorageError::CleanupFailed {
326                operation_error,
327                cleanup_error,
328                ..
329            }) => {
330                on_commit_may_exist();
331                span.record("failure_stage", "atomic_create");
332                span.record("outcome", "ambiguous");
333                return Err(CommitError::AmbiguousOutcome {
334                    commit_path: commit_rel.display().to_string(),
335                    operation_error,
336                    cleanup_error,
337                });
338            }
339            Err(source @ StorageError::AlreadyExists { .. }) => {
340                span.record("failure_stage", "atomic_create");
341                span.record("outcome", "conflict");
342                return Err(CommitError::Storage { source });
343            }
344            Err(source) => {
345                span.record("failure_stage", "atomic_create");
346                span.record("outcome", "failed");
347                return Err(CommitError::Storage { source });
348            }
349        }
350
351        // 5) Update CURRENT via atomic write (temp + rename).
352        let current_rel = storage::layout::current_rel_path();
353        let current_contents = format!("{version}\n");
354        if let Err(publish_error) = storage::write_atomic(
355            self.location.as_ref(),
356            &current_rel,
357            current_contents.as_bytes(),
358        )
359        .await
360        {
361            let error = self
362                .rollback_unpublished_commit(&commit_rel, publish_error)
363                .await;
364            commit_guard.disarm();
365            let ambiguous = matches!(&error, CommitError::AmbiguousOutcome { .. });
366            if ambiguous {
367                on_commit_may_exist();
368            }
369            span.record("failure_stage", "current_publication");
370            if ambiguous {
371                span.record("rollback_outcome", "failed");
372                span.record("outcome", "ambiguous");
373            } else {
374                span.record("rollback_outcome", "succeeded");
375                span.record("outcome", "failed");
376            }
377            return Err(error);
378        }
379
380        commit_guard.disarm();
381        on_commit_may_exist();
382
383        span.record("committed_version", version);
384        span.record("outcome", "succeeded");
385        Ok(version)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use std::error::Error as _;
392
393    use super::*;
394    use crate::storage::layout;
395    use crate::table::test_util::TraceCapture;
396    use serde_json;
397    use tempfile::TempDir;
398
399    type TestResult = Result<(), Box<dyn std::error::Error>>;
400
401    // ==================== LogStore tests ====================
402
403    fn create_test_log_store() -> (TempDir, TransactionLogStore) {
404        let tmp = TempDir::new().expect("create temp dir");
405        let location = TableLocation::local(tmp.path());
406        let store = TransactionLogStore::new(location);
407        (tmp, store)
408    }
409
410    fn assert_commit_span(capture: &TraceCapture, expected_fields: &[(&str, Option<&str>)]) {
411        let spans: Vec<_> = capture
412            .spans()
413            .into_iter()
414            .filter(|span| span.name == "transaction.commit")
415            .collect();
416        assert_eq!(spans.len(), 1, "expected one transaction.commit span");
417        assert_eq!(spans[0].level, tracing::Level::DEBUG);
418        for (field, expected) in expected_fields {
419            assert_eq!(
420                spans[0].fields.get(*field).map(String::as_str),
421                *expected,
422                "unexpected transaction.commit.{field}"
423            );
424        }
425        assert!(
426            !capture
427                .events()
428                .iter()
429                .any(|event| event.name == "transaction.commit"),
430            "transaction commits must not emit duplicate events"
431        );
432    }
433
434    #[tokio::test]
435    async fn load_current_version_returns_zero_when_no_current_file() -> TestResult {
436        let (_tmp, store) = create_test_log_store();
437
438        let version = store.load_current_version().await?;
439
440        assert_eq!(version, 0);
441        Ok(())
442    }
443
444    #[tokio::test]
445    async fn load_current_version_returns_version_from_file() -> TestResult {
446        let (tmp, store) = create_test_log_store();
447
448        // Manually create CURRENT file with version 5.
449        let log_dir = tmp.path().join(layout::log_rel_dir());
450        tokio::fs::create_dir_all(&log_dir).await?;
451        let current_path = tmp.path().join(layout::current_rel_path());
452        tokio::fs::write(&current_path, "5\n").await?;
453
454        let version = store.load_current_version().await?;
455
456        assert_eq!(version, 5);
457        Ok(())
458    }
459
460    #[tokio::test]
461    async fn load_current_version_handles_whitespace() -> TestResult {
462        let (tmp, store) = create_test_log_store();
463
464        let log_dir = tmp.path().join(layout::log_rel_dir());
465        tokio::fs::create_dir_all(&log_dir).await?;
466        let current_path = tmp.path().join(layout::current_rel_path());
467        tokio::fs::write(&current_path, "  42  \n").await?;
468
469        let version = store.load_current_version().await?;
470
471        assert_eq!(version, 42);
472        Ok(())
473    }
474
475    #[tokio::test]
476    async fn load_current_version_returns_empty_pointer_error() -> TestResult {
477        let (tmp, store) = create_test_log_store();
478
479        let log_dir = tmp.path().join(layout::log_rel_dir());
480        tokio::fs::create_dir_all(&log_dir).await?;
481        let current_path = tmp.path().join(layout::current_rel_path());
482        tokio::fs::write(&current_path, "").await?;
483
484        let result = store.load_current_version().await;
485
486        assert!(result.is_err());
487        let err = result.expect_err("expected EmptyCurrentPointer");
488        assert!(matches!(err, CommitError::EmptyCurrentPointer { .. }));
489        Ok(())
490    }
491
492    #[tokio::test]
493    async fn load_current_version_preserves_invalid_integer_source() -> TestResult {
494        let (tmp, store) = create_test_log_store();
495
496        let log_dir = tmp.path().join(layout::log_rel_dir());
497        tokio::fs::create_dir_all(&log_dir).await?;
498        let current_path = tmp.path().join(layout::current_rel_path());
499        tokio::fs::write(&current_path, "not-a-number").await?;
500
501        let result = store.load_current_version().await;
502
503        assert!(result.is_err());
504        let err = result.expect_err("expected CurrentVersionParse");
505        assert!(matches!(err, CommitError::CurrentVersionParse { .. }));
506        assert!(
507            err.source()
508                .is_some_and(|source| source.is::<std::num::ParseIntError>())
509        );
510        assert!(snafu::ErrorCompat::backtrace(&err).is_some());
511        Ok(())
512    }
513
514    #[tokio::test]
515    async fn load_commit_preserves_json_source() -> TestResult {
516        let (tmp, store) = create_test_log_store();
517        let commit_path = tmp.path().join(layout::commit_rel_path(1));
518        tokio::fs::create_dir_all(commit_path.parent().expect("commit parent")).await?;
519        tokio::fs::write(&commit_path, "{ invalid json").await?;
520
521        let err = store
522            .load_commit(1)
523            .await
524            .expect_err("invalid JSON must fail");
525
526        assert!(matches!(err, CommitError::CommitDeserialization { .. }));
527        assert!(
528            err.source()
529                .is_some_and(|source| source.is::<serde_json::Error>())
530        );
531        assert!(snafu::ErrorCompat::backtrace(&err).is_some());
532        Ok(())
533    }
534
535    #[tokio::test]
536    async fn commit_current_read_failure_records_failure_stage() -> TestResult {
537        let (tmp, store) = create_test_log_store();
538        let current_path = tmp.path().join(layout::current_rel_path());
539        tokio::fs::create_dir_all(current_path.parent().expect("CURRENT parent")).await?;
540        tokio::fs::write(current_path, "invalid").await?;
541        let capture = TraceCapture::default();
542
543        let err = capture
544            .run(store.commit_with_expected_version(0, vec![]))
545            .await
546            .expect_err("invalid CURRENT should fail");
547
548        assert!(matches!(err, CommitError::CurrentVersionParse { .. }));
549        assert_commit_span(
550            &capture,
551            &[
552                ("expected_version", Some("0")),
553                ("observed_version", None),
554                ("proposed_version", None),
555                ("committed_version", None),
556                ("action_count", Some("0")),
557                ("failure_stage", Some("current_read")),
558                ("rollback_outcome", None),
559                ("outcome", Some("failed")),
560            ],
561        );
562        Ok(())
563    }
564
565    #[tokio::test]
566    async fn commit_version_overflow_records_failure_stage() -> TestResult {
567        let (tmp, store) = create_test_log_store();
568        let current_path = tmp.path().join(layout::current_rel_path());
569        tokio::fs::create_dir_all(current_path.parent().expect("CURRENT parent")).await?;
570        tokio::fs::write(current_path, format!("{}\n", u64::MAX)).await?;
571        let capture = TraceCapture::default();
572
573        let err = capture
574            .run(store.commit_with_expected_version(u64::MAX, vec![]))
575            .await
576            .expect_err("version overflow should fail");
577
578        assert!(matches!(err, CommitError::VersionOverflow { .. }));
579        assert_commit_span(
580            &capture,
581            &[
582                ("expected_version", Some("18446744073709551615")),
583                ("observed_version", Some("18446744073709551615")),
584                ("proposed_version", None),
585                ("committed_version", None),
586                ("action_count", Some("0")),
587                ("failure_stage", Some("version_calculation")),
588                ("rollback_outcome", None),
589                ("outcome", Some("failed")),
590            ],
591        );
592        Ok(())
593    }
594
595    #[tokio::test]
596    async fn commit_first_version_succeeds() -> TestResult {
597        let (tmp, store) = create_test_log_store();
598
599        let version = store.commit_with_expected_version(0, vec![]).await?;
600
601        assert_eq!(version, 1);
602
603        // Verify CURRENT was updated.
604        let current_version = store.load_current_version().await?;
605        assert_eq!(current_version, 1);
606
607        // Verify commit file was created.
608        let commit_path = tmp.path().join(layout::commit_rel_path(1));
609        assert!(commit_path.exists());
610
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn commit_subsequent_versions_succeeds() -> TestResult {
616        let (_tmp, store) = create_test_log_store();
617
618        // Commit versions 1, 2, 3.
619        let v1 = store.commit_with_expected_version(0, vec![]).await?;
620        let v2 = store.commit_with_expected_version(1, vec![]).await?;
621        let v3 = store.commit_with_expected_version(2, vec![]).await?;
622
623        assert_eq!(v1, 1);
624        assert_eq!(v2, 2);
625        assert_eq!(v3, 3);
626
627        let current = store.load_current_version().await?;
628        assert_eq!(current, 3);
629
630        Ok(())
631    }
632
633    #[tokio::test]
634    async fn commit_with_wrong_expected_version_returns_conflict() -> TestResult {
635        let (_tmp, store) = create_test_log_store();
636
637        // Commit version 1.
638        store.commit_with_expected_version(0, vec![]).await?;
639
640        // Try to commit with expected=0 again (stale).
641        let capture = TraceCapture::default();
642        let result = capture
643            .run(store.commit_with_expected_version(0, vec![]))
644            .await;
645
646        assert!(result.is_err());
647        let err = result.expect_err("expected Conflict");
648        match err {
649            CommitError::Conflict {
650                expected, found, ..
651            } => {
652                assert_eq!(expected, 0);
653                assert_eq!(found, 1);
654            }
655            _ => panic!("expected Conflict error, got {err:?}"),
656        }
657        assert_commit_span(
658            &capture,
659            &[
660                ("expected_version", Some("0")),
661                ("observed_version", Some("1")),
662                ("proposed_version", None),
663                ("committed_version", None),
664                ("action_count", Some("0")),
665                ("failure_stage", Some("advisory_check")),
666                ("rollback_outcome", None),
667                ("outcome", Some("conflict")),
668            ],
669        );
670
671        Ok(())
672    }
673
674    #[tokio::test]
675    async fn commit_creates_valid_json_file() -> TestResult {
676        let (tmp, store) = create_test_log_store();
677
678        let action = LogAction::RemoveSegment {
679            path: "data/test-seg.parquet".to_string(),
680        };
681
682        let capture = TraceCapture::default();
683        capture
684            .run(store.commit_with_expected_version(0, vec![action]))
685            .await?;
686
687        assert_commit_span(
688            &capture,
689            &[
690                ("expected_version", Some("0")),
691                ("observed_version", Some("0")),
692                ("proposed_version", Some("1")),
693                ("committed_version", Some("1")),
694                ("action_count", Some("1")),
695                ("failure_stage", None),
696                ("rollback_outcome", None),
697                ("outcome", Some("succeeded")),
698            ],
699        );
700        for value in capture
701            .spans()
702            .into_iter()
703            .flat_map(|span| span.fields.into_values())
704        {
705            assert!(!value.contains("data/test-seg.parquet"));
706            assert!(!value.contains(&tmp.path().display().to_string()));
707        }
708
709        // Read and parse the commit file.
710        let commit_path = tmp.path().join(layout::commit_rel_path(1));
711        let contents = tokio::fs::read_to_string(&commit_path).await?;
712        let commit: Commit = serde_json::from_str(&contents)?;
713
714        assert_eq!(commit.version, 1);
715        assert_eq!(commit.base_version, 0);
716        assert_eq!(commit.actions.len(), 1);
717        assert!(matches!(
718            &commit.actions[0],
719            LogAction::RemoveSegment { path } if path == "data/test-seg.parquet"
720        ));
721
722        Ok(())
723    }
724
725    #[tokio::test]
726    async fn commit_current_file_contains_version_with_newline() -> TestResult {
727        let (tmp, store) = create_test_log_store();
728
729        store.commit_with_expected_version(0, vec![]).await?;
730
731        let current_path = tmp.path().join(layout::current_rel_path());
732        let contents = tokio::fs::read_to_string(&current_path).await?;
733
734        assert_eq!(contents, "1\n");
735
736        Ok(())
737    }
738
739    #[tokio::test]
740    async fn commit_returns_already_exists_when_commit_file_already_exists() -> TestResult {
741        // Simulates a race condition where another writer created the commit file first.
742        // We expect AlreadyExists (not Conflict) so higher-level code can implement
743        // automatic conflict resolution (retry with rebased changes if non-conflicting).
744        let (tmp, store) = create_test_log_store();
745
746        // Manually create the commit file that version 1 would use
747        let log_dir = tmp.path().join(layout::log_rel_dir());
748        tokio::fs::create_dir_all(&log_dir).await?;
749        let commit_file = tmp.path().join(layout::commit_rel_path(1));
750        tokio::fs::write(&commit_file, b"{}").await?;
751
752        // Now try to commit at version 1 - should fail with Storage(AlreadyExists)
753        let capture = TraceCapture::default();
754        let result = capture
755            .run(store.commit_with_expected_version(0, vec![]))
756            .await;
757
758        assert!(
759            matches!(
760                result,
761                Err(CommitError::Storage {
762                    source: StorageError::AlreadyExists { .. }
763                })
764            ),
765            "expected Storage(AlreadyExists) error, got: {result:?}",
766        );
767        assert_commit_span(
768            &capture,
769            &[
770                ("observed_version", Some("0")),
771                ("proposed_version", Some("1")),
772                ("committed_version", None),
773                ("failure_stage", Some("atomic_create")),
774                ("rollback_outcome", None),
775                ("outcome", Some("conflict")),
776            ],
777        );
778
779        Ok(())
780    }
781
782    #[tokio::test]
783    async fn commit_write_failure_records_atomic_create_failure() -> TestResult {
784        let (tmp, store) = create_test_log_store();
785        let commit_path = tmp.path().join(layout::commit_rel_path(1));
786        storage::inject_write_new_failure(commit_path.clone(), false);
787        let capture = TraceCapture::default();
788
789        let err = capture
790            .run(store.commit_with_expected_version(0, vec![]))
791            .await
792            .expect_err("commit write should fail");
793
794        assert!(matches!(err, CommitError::Storage { .. }));
795        assert!(!commit_path.exists());
796        assert_commit_span(
797            &capture,
798            &[
799                ("observed_version", Some("0")),
800                ("proposed_version", Some("1")),
801                ("committed_version", None),
802                ("failure_stage", Some("atomic_create")),
803                ("rollback_outcome", None),
804                ("outcome", Some("failed")),
805            ],
806        );
807        Ok(())
808    }
809
810    #[tokio::test]
811    async fn current_update_failure_removes_owned_commit_file() -> TestResult {
812        let (tmp, store) = create_test_log_store();
813        let current_tmp = tmp
814            .path()
815            .join(layout::current_rel_path().with_extension("tmp"));
816        tokio::fs::create_dir_all(&current_tmp).await?;
817
818        let capture = TraceCapture::default();
819        let err = capture
820            .run(store.commit_with_expected_version(0, vec![]))
821            .await
822            .expect_err("CURRENT update should fail");
823
824        assert!(matches!(err, CommitError::Storage { .. }));
825        assert!(!tmp.path().join(layout::commit_rel_path(1)).exists());
826        assert_eq!(store.load_current_version().await?, 0);
827        assert_commit_span(
828            &capture,
829            &[
830                ("observed_version", Some("0")),
831                ("proposed_version", Some("1")),
832                ("committed_version", None),
833                ("failure_stage", Some("current_publication")),
834                ("rollback_outcome", Some("succeeded")),
835                ("outcome", Some("failed")),
836            ],
837        );
838        Ok(())
839    }
840
841    #[tokio::test]
842    async fn current_update_cleanup_failure_records_ambiguous_outcome() -> TestResult {
843        let (tmp, store) = create_test_log_store();
844        let current_tmp = tmp
845            .path()
846            .join(layout::current_rel_path().with_extension("tmp"));
847        tokio::fs::create_dir_all(&current_tmp).await?;
848        let commit_path = tmp.path().join(layout::commit_rel_path(1));
849        storage::inject_cleanup_failure(commit_path.clone());
850        let capture = TraceCapture::default();
851
852        let err = capture
853            .run(store.commit_with_expected_version(0, vec![]))
854            .await
855            .expect_err("CURRENT update and rollback should fail");
856
857        assert!(matches!(err, CommitError::AmbiguousOutcome { .. }));
858        assert!(commit_path.exists());
859        assert_eq!(store.load_current_version().await?, 0);
860        assert_commit_span(
861            &capture,
862            &[
863                ("observed_version", Some("0")),
864                ("proposed_version", Some("1")),
865                ("committed_version", None),
866                ("failure_stage", Some("current_publication")),
867                ("rollback_outcome", Some("failed")),
868                ("outcome", Some("ambiguous")),
869            ],
870        );
871        tokio::fs::remove_file(commit_path).await?;
872        Ok(())
873    }
874
875    #[tokio::test]
876    async fn cleanup_failure_returns_ambiguous_outcome() -> TestResult {
877        let (tmp, store) = create_test_log_store();
878        let commit_rel = layout::commit_rel_path(1);
879        tokio::fs::create_dir_all(tmp.path().join(&commit_rel)).await?;
880        let publish_error =
881            storage::read_to_string(store.location.as_ref(), Path::new("missing-current.tmp"))
882                .await
883                .expect_err("missing path should fail");
884
885        let err = store
886            .rollback_unpublished_commit(&commit_rel, publish_error)
887            .await;
888        let message = err.to_string();
889        let (operation_error, cleanup_error) = match &err {
890            CommitError::AmbiguousOutcome {
891                operation_error,
892                cleanup_error,
893                ..
894            } => (operation_error, cleanup_error),
895            other => panic!("unexpected commit error: {other:?}"),
896        };
897        let primary = err
898            .source()
899            .and_then(|source| source.downcast_ref::<Box<StorageError>>())
900            .map(Box::as_ref)
901            .expect("primary storage source");
902
903        assert!(matches!(err, CommitError::AmbiguousOutcome { .. }));
904        assert!(std::ptr::eq(primary, operation_error.as_ref()));
905        assert!(matches!(primary, StorageError::NotFound { .. }));
906        assert!(matches!(
907            cleanup_error.as_ref(),
908            StorageError::OtherIo { .. }
909        ));
910        assert!(std::ptr::eq(
911            snafu::ErrorCompat::backtrace(&err).expect("commit backtrace"),
912            snafu::ErrorCompat::backtrace(primary).expect("storage backtrace")
913        ));
914        assert!(message.contains("missing-current.tmp"));
915        assert!(message.contains(&commit_rel.display().to_string()));
916        Ok(())
917    }
918
919    #[tokio::test]
920    async fn commit_write_cleanup_failure_returns_ambiguous_outcome() -> TestResult {
921        let (tmp, store) = create_test_log_store();
922        let commit_rel = layout::commit_rel_path(1);
923        let commit_path = tmp.path().join(&commit_rel);
924        storage::inject_write_new_failure(commit_path.clone(), true);
925
926        let capture = TraceCapture::default();
927        let err = capture
928            .run(store.commit_with_expected_version(0, vec![]))
929            .await
930            .expect_err("commit write and cleanup should fail");
931
932        assert!(matches!(err, CommitError::AmbiguousOutcome { .. }));
933        assert!(commit_path.exists());
934        assert_eq!(store.load_current_version().await?, 0);
935        assert_commit_span(
936            &capture,
937            &[
938                ("observed_version", Some("0")),
939                ("proposed_version", Some("1")),
940                ("committed_version", None),
941                ("failure_stage", Some("atomic_create")),
942                ("rollback_outcome", None),
943                ("outcome", Some("ambiguous")),
944            ],
945        );
946        tokio::fs::remove_file(commit_path).await?;
947        Ok(())
948    }
949}