1use 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#[derive(Debug, Clone)]
44pub struct TransactionLogStore {
45 location: TableLocation,
46}
47
48impl TransactionLogStore {
49 pub const LOG_DIR_NAME: &str = storage::layout::LOG_DIR_NAME;
51 pub const CURRENT_FILE_NAME: &str = storage::layout::CURRENT_FILE_NAME;
53 pub const COMMIT_FILENAME_DIGITS: usize = storage::layout::COMMIT_FILENAME_DIGITS;
55
56 pub fn new(location: TableLocation) -> Self {
58 Self { location }
59 }
60
61 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 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 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 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 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 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 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 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 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 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 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 ¤t_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 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 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(¤t_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(¤t_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(¤t_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(¤t_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 let current_version = store.load_current_version().await?;
605 assert_eq!(current_version, 1);
606
607 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 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 store.commit_with_expected_version(0, vec![]).await?;
639
640 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 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(¤t_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 let (tmp, store) = create_test_log_store();
745
746 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 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(¤t_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(¤t_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}