1mod frame;
4
5use std::{
6 collections::HashMap,
7 fs::{File, OpenOptions},
8 io::{self, Seek, SeekFrom, Write},
9 marker::PhantomData,
10 path::Path,
11};
12
13use hyphae_core::{DISK_FORMAT_VERSION, MIN_DISK_FORMAT_VERSION};
14use thiserror::Error;
15use uuid::Uuid;
16
17use self::frame::{
18 Frame, FrameKind, HEADER_LENGTH, MAX_PAYLOAD_LENGTH, ReadStatus, payload_length,
19 read_exact_or_tail,
20};
21
22const DESCRIPTOR_LENGTH: usize = 36;
23const TRANSACTION_DOMAIN: &[u8] = b"hyphae-transaction-v1";
24pub(crate) const MAX_OPERATION_BYTES: usize = MAX_PAYLOAD_LENGTH;
25
26#[derive(Debug, Error)]
28pub enum LogError {
29 #[error(transparent)]
31 Io(#[from] io::Error),
32
33 #[error("invalid frame magic at byte offset {offset}")]
35 BadMagic {
36 offset: u64,
38 },
39
40 #[error(
42 "unsupported log version {found} at byte offset {offset}; supported version is {supported}"
43 )]
44 UnsupportedVersion {
45 offset: u64,
47 found: u16,
49 supported: u16,
51 },
52
53 #[error("unknown frame kind {kind} at byte offset {offset}")]
55 UnknownFrameKind {
56 offset: u64,
58 kind: u8,
60 },
61
62 #[error("unsupported frame flags {flags:#04x} at byte offset {offset}")]
64 UnsupportedFlags {
65 offset: u64,
67 flags: u8,
69 },
70
71 #[error("frame payload is {length} bytes; maximum is {maximum}")]
73 PayloadTooLarge {
74 length: usize,
76 maximum: usize,
78 },
79
80 #[error("invalid sequence at byte offset {offset}: expected {expected}, found {found}")]
82 InvalidSequence {
83 offset: u64,
85 expected: u64,
87 found: u64,
89 },
90
91 #[error("previous-frame digest mismatch at sequence {sequence}")]
93 PreviousDigestMismatch {
94 sequence: u64,
96 },
97
98 #[error("CRC32C mismatch at sequence {sequence}")]
100 ChecksumMismatch {
101 sequence: u64,
103 },
104
105 #[error("BLAKE3 digest mismatch at sequence {sequence}")]
107 DigestMismatch {
108 sequence: u64,
110 },
111
112 #[error("malformed transaction descriptor at sequence {sequence}")]
114 MalformedTransaction {
115 sequence: u64,
117 },
118
119 #[error("{kind} frame at sequence {sequence} has no matching transaction begin")]
121 TransactionBoundary {
122 kind: &'static str,
124 sequence: u64,
126 },
127
128 #[error("transaction content mismatch at commit sequence {sequence}")]
130 TransactionContentMismatch {
131 sequence: u64,
133 },
134
135 #[error(
137 "transaction identifier {transaction_id} was already committed with different contents"
138 )]
139 IdempotencyConflict {
140 transaction_id: Uuid,
142 },
143
144 #[error("a transaction must contain at least one operation")]
146 EmptyTransaction,
147
148 #[error("transaction has too many operations")]
150 TooManyOperations,
151
152 #[error("log sequence space is exhausted")]
154 SequenceExhausted,
155
156 #[error("invalid log segment anchor")]
158 InvalidAnchor,
159
160 #[error("durable log writer is poisoned; reopen it before writing again")]
162 Poisoned,
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub struct CommitReceipt {
168 pub transaction_id: Uuid,
170 pub commit_sequence: u64,
172 pub commit_digest: [u8; 32],
174 pub transaction_digest: [u8; 32],
176}
177
178#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum AppendOutcome {
181 Committed(CommitReceipt),
183 Existing(CommitReceipt),
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct RecoveredTransaction {
190 pub receipt: CommitReceipt,
192 pub operations: Vec<Vec<u8>>,
194}
195
196#[derive(Clone, Debug, Default, Eq, PartialEq)]
198pub struct RecoveryReport {
199 pub base_sequence: u64,
201 pub base_digest: [u8; 32],
203 pub transactions: Vec<RecoveredTransaction>,
205 pub ignored_uncommitted_transactions: u64,
207 pub duplicate_commits: u64,
209 pub truncated_tail_bytes: u64,
211 pub valid_bytes: u64,
213 pub last_sequence: u64,
215 pub last_digest: [u8; 32],
217}
218
219#[derive(Debug)]
221pub struct OpenedLog<'directory> {
222 pub log: DurableLog,
224 pub recovery: RecoveryReport,
226 directory_lock: PhantomData<&'directory crate::DataDirectory>,
227}
228
229impl OpenedLog<'_> {
230 pub(crate) fn new(log: DurableLog, recovery: RecoveryReport) -> Self {
231 Self {
232 log,
233 recovery,
234 directory_lock: PhantomData,
235 }
236 }
237}
238
239#[derive(Debug)]
241pub struct DurableLog {
242 file: File,
243 disk_format_version: u16,
244 next_sequence: u64,
245 previous_digest: [u8; 32],
246 committed: HashMap<Uuid, CommitReceipt>,
247 poisoned: bool,
248 #[cfg(test)]
249 fail_next_sync: bool,
250}
251
252impl DurableLog {
253 #[cfg(test)]
262 pub(crate) fn open_file(
263 path: impl AsRef<Path>,
264 ) -> Result<(DurableLog, RecoveryReport), LogError> {
265 Self::open_file_at(path, 0, [0; 32])
266 }
267
268 #[cfg(test)]
269 pub(crate) fn open_file_at(
270 path: impl AsRef<Path>,
271 base_sequence: u64,
272 base_digest: [u8; 32],
273 ) -> Result<(DurableLog, RecoveryReport), LogError> {
274 Self::open_file_at_version(path, base_sequence, base_digest, DISK_FORMAT_VERSION)
275 }
276
277 pub(crate) fn open_file_at_version(
278 path: impl AsRef<Path>,
279 base_sequence: u64,
280 base_digest: [u8; 32],
281 disk_format_version: u16,
282 ) -> Result<(DurableLog, RecoveryReport), LogError> {
283 if (base_sequence == 0) != (base_digest == [0; 32]) {
284 return Err(LogError::InvalidAnchor);
285 }
286 if !(MIN_DISK_FORMAT_VERSION..=DISK_FORMAT_VERSION).contains(&disk_format_version) {
287 return Err(LogError::UnsupportedVersion {
288 offset: 0,
289 found: disk_format_version,
290 supported: DISK_FORMAT_VERSION,
291 });
292 }
293 let path = path.as_ref();
294 if let Some(parent) = path.parent() {
295 std::fs::create_dir_all(parent)?;
296 }
297 let existed = path.exists();
298 let mut file = OpenOptions::new()
299 .create(true)
300 .read(true)
301 .write(true)
302 .truncate(false)
303 .open(path)?;
304 if !existed {
305 file.sync_all()?;
306 #[cfg(unix)]
307 if let Some(parent) = path.parent() {
308 File::open(parent)?.sync_all()?;
309 }
310 }
311 let recovery = scan(&mut file, base_sequence, base_digest, disk_format_version)?;
312 let physical_length = file.metadata()?.len();
313 if physical_length != recovery.valid_bytes {
314 file.set_len(recovery.valid_bytes)?;
315 file.sync_data()?;
316 }
317 file.seek(SeekFrom::End(0))?;
318
319 let committed = recovery
320 .transactions
321 .iter()
322 .map(|transaction| (transaction.receipt.transaction_id, transaction.receipt))
323 .collect();
324 let next_sequence = recovery
325 .last_sequence
326 .checked_add(1)
327 .ok_or(LogError::SequenceExhausted)?;
328 let log = Self {
329 file,
330 disk_format_version,
331 next_sequence,
332 previous_digest: recovery.last_digest,
333 committed,
334 poisoned: false,
335 #[cfg(test)]
336 fail_next_sync: false,
337 };
338 Ok((log, recovery))
339 }
340
341 pub fn append_transaction(
352 &mut self,
353 transaction_id: Uuid,
354 operations: &[Vec<u8>],
355 ) -> Result<AppendOutcome, LogError> {
356 if self.poisoned {
357 return Err(LogError::Poisoned);
358 }
359 if operations.is_empty() {
360 return Err(LogError::EmptyTransaction);
361 }
362 let operation_count =
363 u32::try_from(operations.len()).map_err(|_| LogError::TooManyOperations)?;
364 for operation in operations {
365 if operation.len() > MAX_PAYLOAD_LENGTH {
366 return Err(LogError::PayloadTooLarge {
367 length: operation.len(),
368 maximum: MAX_PAYLOAD_LENGTH,
369 });
370 }
371 }
372
373 let transaction_digest = transaction_digest(operations, operation_count)?;
374 if let Some(receipt) = self.committed.get(&transaction_id).copied() {
375 return if receipt.transaction_digest == transaction_digest {
376 Ok(AppendOutcome::Existing(receipt))
377 } else {
378 Err(LogError::IdempotencyConflict { transaction_id })
379 };
380 }
381
382 let descriptor = encode_descriptor(operation_count, transaction_digest);
383 let append_result = self.append_new_transaction(
384 transaction_id,
385 operations,
386 &descriptor,
387 transaction_digest,
388 );
389 if append_result.is_err() {
390 self.poisoned = true;
391 }
392 append_result
393 }
394
395 pub(crate) fn is_poisoned(&self) -> bool {
396 self.poisoned
397 }
398
399 pub(crate) fn set_disk_format_version(
400 &mut self,
401 disk_format_version: u16,
402 ) -> Result<(), LogError> {
403 if !(MIN_DISK_FORMAT_VERSION..=DISK_FORMAT_VERSION).contains(&disk_format_version) {
404 return Err(LogError::UnsupportedVersion {
405 offset: 0,
406 found: disk_format_version,
407 supported: DISK_FORMAT_VERSION,
408 });
409 }
410 self.disk_format_version = disk_format_version;
411 Ok(())
412 }
413
414 #[cfg(test)]
415 pub(crate) fn inject_sync_failure(&mut self) {
416 self.fail_next_sync = true;
417 }
418
419 fn append_new_transaction(
420 &mut self,
421 transaction_id: Uuid,
422 operations: &[Vec<u8>],
423 descriptor: &[u8; DESCRIPTOR_LENGTH],
424 transaction_digest: [u8; 32],
425 ) -> Result<AppendOutcome, LogError> {
426 self.append_frame(FrameKind::Begin, transaction_id, descriptor)?;
427 for operation in operations {
428 self.append_frame(FrameKind::Operation, transaction_id, operation)?;
429 }
430 let receipt = self.append_frame(FrameKind::Commit, transaction_id, descriptor)?;
431 #[cfg(test)]
432 if std::mem::take(&mut self.fail_next_sync) {
433 return Err(io::Error::other("injected log sync failure").into());
434 }
435 self.file.sync_data()?;
436
437 let receipt = CommitReceipt {
438 transaction_id,
439 commit_sequence: receipt.sequence,
440 commit_digest: receipt.digest,
441 transaction_digest,
442 };
443 self.committed.insert(transaction_id, receipt);
444 Ok(AppendOutcome::Committed(receipt))
445 }
446
447 fn append_frame(
448 &mut self,
449 kind: FrameKind,
450 transaction_id: Uuid,
451 payload: &[u8],
452 ) -> Result<WrittenFrame, LogError> {
453 let frame = Frame {
454 kind,
455 sequence: self.next_sequence,
456 transaction_id,
457 previous_digest: self.previous_digest,
458 digest: [0; 32],
459 payload: payload.to_vec(),
460 };
461 let encoded = frame.encode(self.disk_format_version)?;
462 let digest = copy_array(&encoded[80..112]);
463 self.file.write_all(&encoded)?;
464 let written = WrittenFrame {
465 sequence: self.next_sequence,
466 digest,
467 };
468 self.next_sequence = self
469 .next_sequence
470 .checked_add(1)
471 .ok_or(LogError::SequenceExhausted)?;
472 self.previous_digest = digest;
473 Ok(written)
474 }
475}
476
477#[derive(Clone, Copy, Debug)]
478struct WrittenFrame {
479 sequence: u64,
480 digest: [u8; 32],
481}
482
483#[derive(Debug)]
484struct PendingTransaction {
485 transaction_id: Uuid,
486 operation_count: u32,
487 expected_digest: [u8; 32],
488 operations: Vec<Vec<u8>>,
489}
490
491fn scan(
492 file: &mut File,
493 base_sequence: u64,
494 base_digest: [u8; 32],
495 disk_format_version: u16,
496) -> Result<RecoveryReport, LogError> {
497 file.seek(SeekFrom::Start(0))?;
498 let physical_length = file.metadata()?.len();
499 let mut report = RecoveryReport {
500 base_sequence,
501 base_digest,
502 last_sequence: base_sequence,
503 last_digest: base_digest,
504 ..RecoveryReport::default()
505 };
506 let mut offset = 0_u64;
507 let mut expected_sequence = base_sequence
508 .checked_add(1)
509 .ok_or(LogError::SequenceExhausted)?;
510 let mut expected_previous_digest = base_digest;
511 let mut pending: Option<PendingTransaction> = None;
512 let mut committed: HashMap<Uuid, CommitReceipt> = HashMap::new();
513
514 loop {
515 let mut header = [0_u8; HEADER_LENGTH];
516 match read_exact_or_tail(file, &mut header)? {
517 ReadStatus::End => break,
518 ReadStatus::Partial => {
519 report.truncated_tail_bytes = physical_length.saturating_sub(offset);
520 break;
521 }
522 ReadStatus::Complete => {}
523 }
524 let length = payload_length(&header, offset, disk_format_version)?;
525 let mut payload = vec![0_u8; length];
526 if read_exact_or_tail(file, &mut payload)? != ReadStatus::Complete {
527 report.truncated_tail_bytes = physical_length.saturating_sub(offset);
528 break;
529 }
530
531 let frame = Frame::decode(&header, payload, offset, disk_format_version)?;
532 if frame.sequence != expected_sequence {
533 return Err(LogError::InvalidSequence {
534 offset,
535 expected: expected_sequence,
536 found: frame.sequence,
537 });
538 }
539 if frame.previous_digest != expected_previous_digest {
540 return Err(LogError::PreviousDigestMismatch {
541 sequence: frame.sequence,
542 });
543 }
544
545 apply_frame(&frame, &mut pending, &mut committed, &mut report)?;
546 let frame_length = u64::try_from(HEADER_LENGTH + frame.payload.len()).map_err(|_| {
547 LogError::PayloadTooLarge {
548 length: frame.payload.len(),
549 maximum: MAX_PAYLOAD_LENGTH,
550 }
551 })?;
552 offset = offset
553 .checked_add(frame_length)
554 .ok_or(LogError::SequenceExhausted)?;
555 report.valid_bytes = offset;
556 report.last_sequence = frame.sequence;
557 report.last_digest = frame.digest;
558 expected_sequence = expected_sequence
559 .checked_add(1)
560 .ok_or(LogError::SequenceExhausted)?;
561 expected_previous_digest = frame.digest;
562 }
563
564 if pending.is_some() {
565 report.ignored_uncommitted_transactions =
566 report.ignored_uncommitted_transactions.saturating_add(1);
567 }
568 Ok(report)
569}
570
571fn apply_frame(
572 frame: &Frame,
573 pending: &mut Option<PendingTransaction>,
574 committed: &mut HashMap<Uuid, CommitReceipt>,
575 report: &mut RecoveryReport,
576) -> Result<(), LogError> {
577 match frame.kind {
578 FrameKind::Begin => {
579 if pending.is_some() {
580 report.ignored_uncommitted_transactions =
581 report.ignored_uncommitted_transactions.saturating_add(1);
582 }
583 let (operation_count, expected_digest) =
584 decode_descriptor(&frame.payload, frame.sequence)?;
585 *pending = Some(PendingTransaction {
586 transaction_id: frame.transaction_id,
587 operation_count,
588 expected_digest,
589 operations: Vec::new(),
590 });
591 Ok(())
592 }
593 FrameKind::Operation => {
594 let Some(current) = pending
595 .as_mut()
596 .filter(|current| current.transaction_id == frame.transaction_id)
597 else {
598 return Err(LogError::TransactionBoundary {
599 kind: "operation",
600 sequence: frame.sequence,
601 });
602 };
603 current.operations.push(frame.payload.clone());
604 Ok(())
605 }
606 FrameKind::Commit => {
607 let Some(current) = pending
608 .take()
609 .filter(|current| current.transaction_id == frame.transaction_id)
610 else {
611 return Err(LogError::TransactionBoundary {
612 kind: "commit",
613 sequence: frame.sequence,
614 });
615 };
616 let (operation_count, commit_digest) =
617 decode_descriptor(&frame.payload, frame.sequence)?;
618 let actual_count =
619 u32::try_from(current.operations.len()).map_err(|_| LogError::TooManyOperations)?;
620 let actual_digest = transaction_digest(¤t.operations, actual_count)?;
621 if operation_count != current.operation_count
622 || commit_digest != current.expected_digest
623 || actual_count != operation_count
624 || actual_digest != commit_digest
625 {
626 return Err(LogError::TransactionContentMismatch {
627 sequence: frame.sequence,
628 });
629 }
630
631 let receipt = CommitReceipt {
632 transaction_id: frame.transaction_id,
633 commit_sequence: frame.sequence,
634 commit_digest: frame.digest,
635 transaction_digest: actual_digest,
636 };
637 if let Some(existing) = committed.get(&frame.transaction_id) {
638 if existing.transaction_digest != actual_digest {
639 return Err(LogError::IdempotencyConflict {
640 transaction_id: frame.transaction_id,
641 });
642 }
643 report.duplicate_commits = report.duplicate_commits.saturating_add(1);
644 } else {
645 committed.insert(frame.transaction_id, receipt);
646 report.transactions.push(RecoveredTransaction {
647 receipt,
648 operations: current.operations,
649 });
650 }
651 Ok(())
652 }
653 }
654}
655
656fn encode_descriptor(operation_count: u32, digest: [u8; 32]) -> [u8; DESCRIPTOR_LENGTH] {
657 let mut descriptor = [0_u8; DESCRIPTOR_LENGTH];
658 descriptor[..4].copy_from_slice(&operation_count.to_le_bytes());
659 descriptor[4..].copy_from_slice(&digest);
660 descriptor
661}
662
663fn decode_descriptor(payload: &[u8], sequence: u64) -> Result<(u32, [u8; 32]), LogError> {
664 if payload.len() != DESCRIPTOR_LENGTH {
665 return Err(LogError::MalformedTransaction { sequence });
666 }
667 let operation_count = u32::from_le_bytes(copy_array(&payload[..4]));
668 if operation_count == 0 {
669 return Err(LogError::MalformedTransaction { sequence });
670 }
671 let digest = copy_array(&payload[4..]);
672 Ok((operation_count, digest))
673}
674
675pub(crate) fn transaction_digest(
676 operations: &[Vec<u8>],
677 operation_count: u32,
678) -> Result<[u8; 32], LogError> {
679 let mut hasher = blake3::Hasher::new();
680 hasher.update(TRANSACTION_DOMAIN);
681 hasher.update(&u64::from(operation_count).to_le_bytes());
682 for operation in operations {
683 let length = u64::try_from(operation.len()).map_err(|_| LogError::PayloadTooLarge {
684 length: operation.len(),
685 maximum: MAX_PAYLOAD_LENGTH,
686 })?;
687 hasher.update(&length.to_le_bytes());
688 hasher.update(operation);
689 }
690 Ok(*hasher.finalize().as_bytes())
691}
692
693fn copy_array<const N: usize>(source: &[u8]) -> [u8; N] {
694 let mut output = [0_u8; N];
695 output.copy_from_slice(source);
696 output
697}
698
699#[cfg(test)]
700mod tests {
701 use std::{
702 error::Error,
703 fs::OpenOptions,
704 io::{Read, Seek, SeekFrom, Write},
705 };
706
707 use uuid::Uuid;
708
709 use super::{AppendOutcome, DurableLog, LogError, OpenedLog, frame::HEADER_LENGTH};
710 use crate::test_support::TestDirectory;
711
712 fn open_for_test(path: &std::path::Path) -> Result<OpenedLog<'static>, LogError> {
713 let (log, recovery) = DurableLog::open_file(path)?;
714 Ok(OpenedLog::new(log, recovery))
715 }
716
717 #[test]
718 fn committed_transaction_recovers_in_order() -> Result<(), Box<dyn Error>> {
719 let temporary = TestDirectory::new("log-recovery")?;
720 let path = temporary.path().join("segment.hylog");
721 let transaction_id = Uuid::now_v7();
722 let mut opened = open_for_test(&path)?;
723 let outcome = opened
724 .log
725 .append_transaction(transaction_id, &[b"put:a=1".to_vec(), b"put:b=2".to_vec()])?;
726 assert!(matches!(outcome, AppendOutcome::Committed(_)));
727 drop(opened);
728
729 let reopened = open_for_test(&path)?;
730 assert_eq!(reopened.recovery.transactions.len(), 1);
731 assert_eq!(
732 reopened.recovery.transactions[0].operations,
733 [b"put:a=1".to_vec(), b"put:b=2".to_vec()]
734 );
735 Ok(())
736 }
737
738 #[test]
739 fn idempotency_survives_reopen() -> Result<(), Box<dyn Error>> {
740 let temporary = TestDirectory::new("log-idempotency")?;
741 let path = temporary.path().join("segment.hylog");
742 let transaction_id = Uuid::now_v7();
743 let operations = [b"same".to_vec()];
744 let mut opened = open_for_test(&path)?;
745 let first = opened.log.append_transaction(transaction_id, &operations)?;
746 drop(opened);
747
748 let mut reopened = open_for_test(&path)?;
749 let second = reopened
750 .log
751 .append_transaction(transaction_id, &operations)?;
752 assert!(matches!(first, AppendOutcome::Committed(_)));
753 assert!(matches!(second, AppendOutcome::Existing(_)));
754
755 let conflict = reopened
756 .log
757 .append_transaction(transaction_id, &[b"different".to_vec()]);
758 assert!(matches!(
759 conflict,
760 Err(LogError::IdempotencyConflict { .. })
761 ));
762 Ok(())
763 }
764
765 #[test]
766 fn truncates_only_an_incomplete_tail() -> Result<(), Box<dyn Error>> {
767 let temporary = TestDirectory::new("log-tail")?;
768 let path = temporary.path().join("segment.hylog");
769 let mut opened = open_for_test(&path)?;
770 opened
771 .log
772 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
773 drop(opened);
774 let valid_length = std::fs::metadata(&path)?.len();
775
776 OpenOptions::new()
777 .append(true)
778 .open(&path)?
779 .write_all(b"partial")?;
780 let reopened = open_for_test(&path)?;
781 assert_eq!(reopened.recovery.truncated_tail_bytes, 7);
782 assert_eq!(std::fs::metadata(&path)?.len(), valid_length);
783 assert_eq!(reopened.recovery.transactions.len(), 1);
784 Ok(())
785 }
786
787 #[test]
788 fn rejects_complete_corruption_without_truncating() -> Result<(), Box<dyn Error>> {
789 let temporary = TestDirectory::new("log-corruption")?;
790 let path = temporary.path().join("segment.hylog");
791 let mut opened = open_for_test(&path)?;
792 opened
793 .log
794 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
795 drop(opened);
796 let original_length = std::fs::metadata(&path)?.len();
797
798 let payload_offset = u64::try_from(HEADER_LENGTH * 2)? + 36;
799 let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
800 file.seek(SeekFrom::Start(payload_offset))?;
801 let mut byte = [0_u8; 1];
802 file.read_exact(&mut byte)?;
803 byte[0] ^= 0x01;
804 file.seek(SeekFrom::Start(payload_offset))?;
805 file.write_all(&byte)?;
806 file.sync_all()?;
807 drop(file);
808
809 let result = open_for_test(&path);
810 assert!(matches!(result, Err(LogError::ChecksumMismatch { .. })));
811 assert_eq!(std::fs::metadata(&path)?.len(), original_length);
812 Ok(())
813 }
814
815 #[test]
816 fn retry_supersedes_an_uncommitted_attempt() -> Result<(), Box<dyn Error>> {
817 let temporary = TestDirectory::new("log-retry")?;
818 let path = temporary.path().join("segment.hylog");
819 let transaction_id = Uuid::now_v7();
820 let operations = [b"complete".to_vec()];
821
822 let mut opened = open_for_test(&path)?;
823 let digest = super::transaction_digest(&operations, 1)?;
824 let descriptor = super::encode_descriptor(1, digest);
825 opened
826 .log
827 .append_frame(super::FrameKind::Begin, transaction_id, &descriptor)?;
828 opened
829 .log
830 .append_frame(super::FrameKind::Operation, transaction_id, b"incomplete")?;
831 opened.log.file.sync_data()?;
832 drop(opened);
833
834 let mut recovered = open_for_test(&path)?;
835 assert_eq!(recovered.recovery.ignored_uncommitted_transactions, 1);
836 recovered
837 .log
838 .append_transaction(transaction_id, &operations)?;
839 drop(recovered);
840
841 let final_open = open_for_test(&path)?;
842 assert_eq!(final_open.recovery.transactions.len(), 1);
843 assert_eq!(final_open.recovery.transactions[0].operations, operations);
844 Ok(())
845 }
846
847 #[test]
848 fn every_incomplete_transaction_prefix_is_atomic() -> Result<(), Box<dyn Error>> {
849 let temporary = TestDirectory::new("log-byte-cuts")?;
850 let seed_path = temporary.path().join("seed.hylog");
851 let target_path = temporary.path().join("cut.hylog");
852 let mut seed = open_for_test(&seed_path)?;
853 seed.log
854 .append_transaction(Uuid::now_v7(), &[b"first".to_vec(), b"second".to_vec()])?;
855 drop(seed);
856 let complete = std::fs::read(&seed_path)?;
857
858 for cut in 0..complete.len() {
859 std::fs::write(&target_path, &complete[..cut])?;
860 let recovered = open_for_test(&target_path)?;
861 assert!(
862 recovered.recovery.transactions.is_empty(),
863 "cut at byte {cut} exposed an uncommitted transaction"
864 );
865 drop(recovered);
866 }
867
868 std::fs::write(&target_path, &complete)?;
869 let recovered = open_for_test(&target_path)?;
870 assert_eq!(recovered.recovery.transactions.len(), 1);
871 Ok(())
872 }
873
874 #[test]
875 fn future_frame_version_fails_before_payload_allocation() -> Result<(), Box<dyn Error>> {
876 let temporary = TestDirectory::new("log-future-version")?;
877 let path = temporary.path().join("segment.hylog");
878 let mut opened = open_for_test(&path)?;
879 opened
880 .log
881 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
882 drop(opened);
883
884 let mut bytes = std::fs::read(&path)?;
885 bytes[8..10].copy_from_slice(&3_u16.to_le_bytes());
886 bytes[36..44].copy_from_slice(&u64::MAX.to_le_bytes());
887 std::fs::write(&path, &bytes)?;
888
889 let result = open_for_test(&path);
890 assert!(matches!(
891 result,
892 Err(LogError::UnsupportedVersion {
893 found: 3,
894 supported: 2,
895 ..
896 })
897 ));
898 Ok(())
899 }
900
901 #[test]
902 fn anchored_segment_continues_the_global_digest_chain() -> Result<(), Box<dyn Error>> {
903 let temporary = TestDirectory::new("log-anchored-segment")?;
904 let first_path = temporary.path().join("first.hylog");
905 let second_path = temporary.path().join("second.hylog");
906 let mut first = open_for_test(&first_path)?;
907 first
908 .log
909 .append_transaction(Uuid::now_v7(), &[b"before-compaction".to_vec()])?;
910 drop(first);
911 let (_, first_recovery) = DurableLog::open_file(&first_path)?;
912
913 let (mut second, empty_recovery) = DurableLog::open_file_at(
914 &second_path,
915 first_recovery.last_sequence,
916 first_recovery.last_digest,
917 )?;
918 assert_eq!(empty_recovery.base_sequence, first_recovery.last_sequence);
919 assert_eq!(empty_recovery.last_digest, first_recovery.last_digest);
920 let outcome = second.append_transaction(Uuid::now_v7(), &[b"after-compaction".to_vec()])?;
921 let AppendOutcome::Committed(receipt) = outcome else {
922 return Err("new anchored transaction was not committed".into());
923 };
924 assert_eq!(receipt.commit_sequence, first_recovery.last_sequence + 3);
925 drop(second);
926
927 let (_, reopened) = DurableLog::open_file_at(
928 &second_path,
929 first_recovery.last_sequence,
930 first_recovery.last_digest,
931 )?;
932 assert_eq!(reopened.transactions.len(), 1);
933
934 let wrong_anchor =
935 DurableLog::open_file_at(&second_path, first_recovery.last_sequence, [9; 32]);
936 assert!(matches!(
937 wrong_anchor,
938 Err(LogError::PreviousDigestMismatch { .. })
939 ));
940 Ok(())
941 }
942}