timeseries_table_format/transaction_log/
log_store.rs1use 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#[derive(Debug, Clone)]
29pub struct TransactionLogStore {
30 location: TableLocation,
31}
32
33impl TransactionLogStore {
34 pub const LOG_DIR_NAME: &str = storage::layout::LOG_DIR_NAME;
36 pub const CURRENT_FILE_NAME: &str = storage::layout::CURRENT_FILE_NAME;
38 pub const COMMIT_FILENAME_DIGITS: usize = storage::layout::COMMIT_FILENAME_DIGITS;
40
41 pub fn new(location: TableLocation) -> Self {
43 Self { location }
44 }
45
46 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 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 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 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 pub(crate) async fn commit_with_expected_version(
180 &self,
181 expected: u64,
182 actions: Vec<LogAction>,
183 ) -> Result<u64, CommitError> {
184 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 let version = expected.checked_add(1).context(CorruptStateSnafu {
196 msg: "version counter overflow".to_string(),
197 })?;
198
199 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 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 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 ¤t_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 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 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(¤t_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(¤t_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(¤t_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(¤t_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 let current_version = store.load_current_version().await?;
357 assert_eq!(current_version, 1);
358
359 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 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 store.commit_with_expected_version(0, vec![]).await?;
391
392 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 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(¤t_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 let (tmp, store) = create_test_log_store();
456
457 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 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(¤t_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}