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 storage-layer failures into [`CommitError`] variants so callers
9//!   can differentiate between conflicts, storage errors, and corrupt state.
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::table_metadata::TABLE_FORMAT_VERSION;
15use crate::storage::{self, StorageError, TableLocation};
16use crate::transaction_log::actions::{Commit, LogAction};
17use crate::transaction_log::*;
18use chrono::Utc;
19use snafu::{Backtrace, prelude::*};
20use std::path::{Path, PathBuf};
21
22/// Helper for reading and writing the commit log under a table root.
23///
24/// Layout:
25///   `<root>/_timeseries_log/0000000001.json`
26///   `<root>/_timeseries_log/0000000002.json`
27///   `<root>/_timeseries_log/CURRENT`
28#[derive(Debug, Clone)]
29pub struct TransactionLogStore {
30    location: TableLocation,
31}
32
33impl TransactionLogStore {
34    /// Name of the subdirectory containing the commit log.
35    pub const LOG_DIR_NAME: &str = storage::layout::LOG_DIR_NAME;
36    /// Name of the file that stores the current version pointer.
37    pub const CURRENT_FILE_NAME: &str = storage::layout::CURRENT_FILE_NAME;
38    /// Number of digits used in zero-padded commit file names.
39    pub const COMMIT_FILENAME_DIGITS: usize = storage::layout::COMMIT_FILENAME_DIGITS;
40
41    /// Create a new TransactionLogStore rooted at a table directory.
42    pub fn new(location: TableLocation) -> Self {
43        Self { location }
44    }
45
46    /// Get the TableLocation of the LogStore.
47    pub fn location(&self) -> &TableLocation {
48        &self.location
49    }
50
51    fn commit_rel_path(version: u64) -> PathBuf {
52        storage::layout::commit_rel_path(version)
53    }
54
55    /// Helper: read a log-relative file and map storage errors into CommitError.
56    async fn read_to_string_rel(&self, rel: &Path) -> Result<String, CommitError> {
57        match storage::read_to_string(self.location.as_ref(), rel).await {
58            Ok(s) => Ok(s),
59            Err(source) => Err(CommitError::Storage { source }),
60        }
61    }
62
63    async fn rollback_unpublished_commit(
64        &self,
65        commit_rel: &Path,
66        publish_error: StorageError,
67    ) -> CommitError {
68        match storage::remove_file(self.location.as_ref(), commit_rel).await {
69            Ok(()) => CommitError::Storage {
70                source: publish_error,
71            },
72            Err(cleanup_error) => CommitError::AmbiguousOutcome {
73                commit_path: commit_rel.display().to_string(),
74                operation_error: Box::new(publish_error),
75                cleanup_error: Box::new(cleanup_error),
76                backtrace: Backtrace::capture(),
77            },
78        }
79    }
80
81    /// Load a single commit by version.
82    ///
83    /// - On storage-layer failures, returns `CommitError::Storage`.
84    /// - On JSON parse failures, returns `CommitError::CorruptState`.
85    pub async fn load_commit(&self, version: u64) -> Result<Commit, CommitError> {
86        let rel = Self::commit_rel_path(version);
87        let json = self.read_to_string_rel(&rel).await?;
88
89        let value: serde_json::Value =
90            serde_json::from_str(&json).map_err(|e| CommitError::CorruptState {
91                msg: format!("failed to parse commit {version}: {e}"),
92                backtrace: Backtrace::capture(),
93            })?;
94
95        if let Some(found) = value
96            .get("actions")
97            .and_then(serde_json::Value::as_array)
98            .into_iter()
99            .flatten()
100            .filter_map(|action| {
101                action
102                    .pointer("/UpdateTableMeta/format_version")
103                    .and_then(serde_json::Value::as_u64)
104            })
105            .find(|&found| found != u64::from(TABLE_FORMAT_VERSION))
106        {
107            return Err(CommitError::UnsupportedFormatVersion {
108                expected: TABLE_FORMAT_VERSION,
109                found,
110            });
111        }
112
113        let commit = serde_json::from_value(value).map_err(|e| CommitError::CorruptState {
114            msg: format!("failed to parse commit {version}: {e}"),
115            backtrace: Backtrace::capture(),
116        })?;
117
118        Ok(commit)
119    }
120
121    /// Load the CURRENT version pointer.
122    ///
123    /// Behavior:
124    /// - If CURRENT does not exist, treat as a fresh table and return 0.
125    /// - If CURRENT contains invalid or empty content, return CorruptState.
126    pub async fn load_current_version(&self) -> Result<u64, CommitError> {
127        let rel = storage::layout::current_rel_path();
128
129        let contents = match storage::read_to_string(self.location.as_ref(), &rel).await {
130            Ok(s) => s,
131            Err(StorageError::NotFound { .. }) => return Ok(0),
132            Err(source) => return Err(CommitError::Storage { source }),
133        };
134
135        let trimmed = contents.trim();
136        if trimmed.is_empty() {
137            return CorruptStateSnafu {
138                msg: format!("CURRENT has empty content at {rel:?}",),
139            }
140            .fail();
141        }
142        let version = trimmed
143            .parse::<u64>()
144            .map_err(|e| CommitError::CorruptState {
145                msg: format!("CURRENT has invalid content {trimmed:?}: {e}"),
146                backtrace: Backtrace::capture(),
147            })?;
148
149        Ok(version)
150    }
151
152    /// Commit a new version with an optimistic concurrency guard.
153    ///
154    /// ## Concurrency semantics
155    ///
156    /// - The check on CURRENT is advisory/best-effort and subject to races.
157    ///   Two writers may both read the same CURRENT value and attempt to commit
158    ///   the same next version. The actual concurrency guard is the atomic
159    ///   creation of the commit file using "create only if not exists" semantics.
160    /// - If another writer wins the race and creates the commit file first,
161    ///   this operation will fail with `StorageError::AlreadyExists`.
162    /// - Callers must be prepared to handle `StorageError::AlreadyExists` and
163    ///   implement retry logic (e.g., reload CURRENT and retry the commit).
164    ///
165    /// If updating CURRENT fails, this method removes the commit file created
166    /// by this invocation. A cleanup failure returns
167    /// [`CommitError::AmbiguousOutcome`] so callers do not assume rollback.
168    ///
169    /// ## Steps
170    ///
171    /// 1. Load CURRENT (advisory check).
172    /// 2. If CURRENT != expected, return `CommitError::Conflict`.
173    /// 3. Compute version = expected + 1 (with overflow check).
174    /// 4. Build a `Commit` struct.
175    /// 5. Serialize to JSON.
176    /// 6. Create commit file `_timeseries_log/<zero-padded>.json` using
177    ///    "create only if not exists" semantics (atomic guard).
178    /// 7. Update `_timeseries_log/CURRENT` with the new version (e.g. `"1\n"`).
179    pub(crate) async fn commit_with_expected_version(
180        &self,
181        expected: u64,
182        actions: Vec<LogAction>,
183    ) -> Result<u64, CommitError> {
184        // 1) Guard on CURRENT
185        let current = self.load_current_version().await?;
186        if current != expected {
187            return ConflictSnafu {
188                expected,
189                found: current,
190            }
191            .fail();
192        }
193
194        // 2) Compute next version with overflow guard
195        let version = expected.checked_add(1).context(CorruptStateSnafu {
196            msg: "version counter overflow".to_string(),
197        })?;
198
199        // 3) Build commit payload
200        let commit = Commit {
201            version,
202            base_version: expected,
203            timestamp: Utc::now(),
204            actions,
205        };
206
207        let json = serde_json::to_vec(&commit).map_err(|e| CommitError::CorruptState {
208            msg: format!("failed to serialize commit {version}: {e}"),
209            backtrace: Backtrace::capture(),
210        })?;
211
212        // 4) Attempt to create the commit file *only if it does not already exist*.
213        //    If the file already exists (AlreadyExists error), we propagate it as-is
214        //    rather than converting to Conflict. This allows higher-level code to
215        //    implement automatic conflict resolution (e.g., retrying with rebased
216        //    changes if the operations don't actually conflict, like Delta Lake).
217        let commit_rel = Self::commit_rel_path(version);
218        match storage::write_new(self.location.as_ref(), &commit_rel, &json).await {
219            Ok(()) => {}
220            Err(StorageError::CleanupFailed {
221                operation_error,
222                cleanup_error,
223                ..
224            }) => {
225                return Err(CommitError::AmbiguousOutcome {
226                    commit_path: commit_rel.display().to_string(),
227                    operation_error,
228                    cleanup_error,
229                    backtrace: Backtrace::capture(),
230                });
231            }
232            Err(source) => return Err(CommitError::Storage { source }),
233        }
234
235        // 5) Update CURRENT via atomic write (temp + rename).
236        let current_rel = storage::layout::current_rel_path();
237        let current_contents = format!("{version}\n");
238        if let Err(publish_error) = storage::write_atomic(
239            self.location.as_ref(),
240            &current_rel,
241            current_contents.as_bytes(),
242        )
243        .await
244        {
245            return Err(self
246                .rollback_unpublished_commit(&commit_rel, publish_error)
247                .await);
248        }
249
250        Ok(version)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::storage::layout;
258    use serde_json;
259    use tempfile::TempDir;
260
261    type TestResult = Result<(), Box<dyn std::error::Error>>;
262
263    // ==================== LogStore tests ====================
264
265    fn create_test_log_store() -> (TempDir, TransactionLogStore) {
266        let tmp = TempDir::new().expect("create temp dir");
267        let location = TableLocation::local(tmp.path());
268        let store = TransactionLogStore::new(location);
269        (tmp, store)
270    }
271
272    #[tokio::test]
273    async fn load_current_version_returns_zero_when_no_current_file() -> TestResult {
274        let (_tmp, store) = create_test_log_store();
275
276        let version = store.load_current_version().await?;
277
278        assert_eq!(version, 0);
279        Ok(())
280    }
281
282    #[tokio::test]
283    async fn load_current_version_returns_version_from_file() -> TestResult {
284        let (tmp, store) = create_test_log_store();
285
286        // Manually create CURRENT file with version 5.
287        let log_dir = tmp.path().join(layout::log_rel_dir());
288        tokio::fs::create_dir_all(&log_dir).await?;
289        let current_path = tmp.path().join(layout::current_rel_path());
290        tokio::fs::write(&current_path, "5\n").await?;
291
292        let version = store.load_current_version().await?;
293
294        assert_eq!(version, 5);
295        Ok(())
296    }
297
298    #[tokio::test]
299    async fn load_current_version_handles_whitespace() -> TestResult {
300        let (tmp, store) = create_test_log_store();
301
302        let log_dir = tmp.path().join(layout::log_rel_dir());
303        tokio::fs::create_dir_all(&log_dir).await?;
304        let current_path = tmp.path().join(layout::current_rel_path());
305        tokio::fs::write(&current_path, "  42  \n").await?;
306
307        let version = store.load_current_version().await?;
308
309        assert_eq!(version, 42);
310        Ok(())
311    }
312
313    #[tokio::test]
314    async fn load_current_version_returns_corrupt_state_for_empty_file() -> TestResult {
315        let (tmp, store) = create_test_log_store();
316
317        let log_dir = tmp.path().join(layout::log_rel_dir());
318        tokio::fs::create_dir_all(&log_dir).await?;
319        let current_path = tmp.path().join(layout::current_rel_path());
320        tokio::fs::write(&current_path, "").await?;
321
322        let result = store.load_current_version().await;
323
324        assert!(result.is_err());
325        let err = result.expect_err("expected CorruptState");
326        assert!(matches!(err, CommitError::CorruptState { .. }));
327        Ok(())
328    }
329
330    #[tokio::test]
331    async fn load_current_version_returns_corrupt_state_for_invalid_content() -> TestResult {
332        let (tmp, store) = create_test_log_store();
333
334        let log_dir = tmp.path().join(layout::log_rel_dir());
335        tokio::fs::create_dir_all(&log_dir).await?;
336        let current_path = tmp.path().join(layout::current_rel_path());
337        tokio::fs::write(&current_path, "not-a-number").await?;
338
339        let result = store.load_current_version().await;
340
341        assert!(result.is_err());
342        let err = result.expect_err("expected CorruptState");
343        assert!(matches!(err, CommitError::CorruptState { .. }));
344        Ok(())
345    }
346
347    #[tokio::test]
348    async fn commit_first_version_succeeds() -> TestResult {
349        let (tmp, store) = create_test_log_store();
350
351        let version = store.commit_with_expected_version(0, vec![]).await?;
352
353        assert_eq!(version, 1);
354
355        // Verify CURRENT was updated.
356        let current_version = store.load_current_version().await?;
357        assert_eq!(current_version, 1);
358
359        // Verify commit file was created.
360        let commit_path = tmp.path().join(layout::commit_rel_path(1));
361        assert!(commit_path.exists());
362
363        Ok(())
364    }
365
366    #[tokio::test]
367    async fn commit_subsequent_versions_succeeds() -> TestResult {
368        let (_tmp, store) = create_test_log_store();
369
370        // Commit versions 1, 2, 3.
371        let v1 = store.commit_with_expected_version(0, vec![]).await?;
372        let v2 = store.commit_with_expected_version(1, vec![]).await?;
373        let v3 = store.commit_with_expected_version(2, vec![]).await?;
374
375        assert_eq!(v1, 1);
376        assert_eq!(v2, 2);
377        assert_eq!(v3, 3);
378
379        let current = store.load_current_version().await?;
380        assert_eq!(current, 3);
381
382        Ok(())
383    }
384
385    #[tokio::test]
386    async fn commit_with_wrong_expected_version_returns_conflict() -> TestResult {
387        let (_tmp, store) = create_test_log_store();
388
389        // Commit version 1.
390        store.commit_with_expected_version(0, vec![]).await?;
391
392        // Try to commit with expected=0 again (stale).
393        let result = store.commit_with_expected_version(0, vec![]).await;
394
395        assert!(result.is_err());
396        let err = result.expect_err("expected Conflict");
397        match err {
398            CommitError::Conflict {
399                expected, found, ..
400            } => {
401                assert_eq!(expected, 0);
402                assert_eq!(found, 1);
403            }
404            _ => panic!("expected Conflict error, got {err:?}"),
405        }
406
407        Ok(())
408    }
409
410    #[tokio::test]
411    async fn commit_creates_valid_json_file() -> TestResult {
412        let (tmp, store) = create_test_log_store();
413
414        let action = LogAction::RemoveSegment {
415            path: "data/test-seg.parquet".to_string(),
416        };
417
418        store.commit_with_expected_version(0, vec![action]).await?;
419
420        // Read and parse the commit file.
421        let commit_path = tmp.path().join(layout::commit_rel_path(1));
422        let contents = tokio::fs::read_to_string(&commit_path).await?;
423        let commit: Commit = serde_json::from_str(&contents)?;
424
425        assert_eq!(commit.version, 1);
426        assert_eq!(commit.base_version, 0);
427        assert_eq!(commit.actions.len(), 1);
428        assert!(matches!(
429            &commit.actions[0],
430            LogAction::RemoveSegment { path } if path == "data/test-seg.parquet"
431        ));
432
433        Ok(())
434    }
435
436    #[tokio::test]
437    async fn commit_current_file_contains_version_with_newline() -> TestResult {
438        let (tmp, store) = create_test_log_store();
439
440        store.commit_with_expected_version(0, vec![]).await?;
441
442        let current_path = tmp.path().join(layout::current_rel_path());
443        let contents = tokio::fs::read_to_string(&current_path).await?;
444
445        assert_eq!(contents, "1\n");
446
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn commit_returns_already_exists_when_commit_file_already_exists() -> TestResult {
452        // Simulates a race condition where another writer created the commit file first.
453        // We expect AlreadyExists (not Conflict) so higher-level code can implement
454        // automatic conflict resolution (retry with rebased changes if non-conflicting).
455        let (tmp, store) = create_test_log_store();
456
457        // Manually create the commit file that version 1 would use
458        let log_dir = tmp.path().join(layout::log_rel_dir());
459        tokio::fs::create_dir_all(&log_dir).await?;
460        let commit_file = tmp.path().join(layout::commit_rel_path(1));
461        tokio::fs::write(&commit_file, b"{}").await?;
462
463        // Now try to commit at version 1 - should fail with Storage(AlreadyExists)
464        let result = store.commit_with_expected_version(0, vec![]).await;
465
466        assert!(
467            matches!(
468                result,
469                Err(CommitError::Storage {
470                    source: StorageError::AlreadyExists { .. }
471                })
472            ),
473            "expected Storage(AlreadyExists) error, got: {result:?}",
474        );
475
476        Ok(())
477    }
478
479    #[tokio::test]
480    async fn current_update_failure_removes_owned_commit_file() -> TestResult {
481        let (tmp, store) = create_test_log_store();
482        let current_tmp = tmp
483            .path()
484            .join(layout::current_rel_path().with_extension("tmp"));
485        tokio::fs::create_dir_all(&current_tmp).await?;
486
487        let err = store
488            .commit_with_expected_version(0, vec![])
489            .await
490            .expect_err("CURRENT update should fail");
491
492        assert!(matches!(err, CommitError::Storage { .. }));
493        assert!(!tmp.path().join(layout::commit_rel_path(1)).exists());
494        assert_eq!(store.load_current_version().await?, 0);
495        Ok(())
496    }
497
498    #[tokio::test]
499    async fn cleanup_failure_returns_ambiguous_outcome() -> TestResult {
500        let (tmp, store) = create_test_log_store();
501        let commit_rel = layout::commit_rel_path(1);
502        tokio::fs::create_dir_all(tmp.path().join(&commit_rel)).await?;
503        let publish_error =
504            storage::read_to_string(store.location.as_ref(), Path::new("missing-current.tmp"))
505                .await
506                .expect_err("missing path should fail");
507
508        let err = store
509            .rollback_unpublished_commit(&commit_rel, publish_error)
510            .await;
511        let message = err.to_string();
512
513        assert!(matches!(err, CommitError::AmbiguousOutcome { .. }));
514        assert!(message.contains("missing-current.tmp"));
515        assert!(message.contains(&commit_rel.display().to_string()));
516        Ok(())
517    }
518
519    #[tokio::test]
520    async fn commit_write_cleanup_failure_returns_ambiguous_outcome() -> TestResult {
521        let (tmp, store) = create_test_log_store();
522        let commit_rel = layout::commit_rel_path(1);
523        let commit_path = tmp.path().join(&commit_rel);
524        storage::inject_write_new_failure(commit_path.clone(), true);
525
526        let err = store
527            .commit_with_expected_version(0, vec![])
528            .await
529            .expect_err("commit write and cleanup should fail");
530
531        assert!(matches!(err, CommitError::AmbiguousOutcome { .. }));
532        assert!(commit_path.exists());
533        assert_eq!(store.load_current_version().await?, 0);
534        tokio::fs::remove_file(commit_path).await?;
535        Ok(())
536    }
537}