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};
21use crate::{
22 RecoveryLimits, StorageLimitError,
23 limits::{OperationDeadline, limit_io_error},
24};
25
26const DESCRIPTOR_LENGTH: usize = 36;
27const TRANSACTION_DOMAIN: &[u8] = b"hyphae-transaction-v1";
28pub(crate) const MAX_OPERATION_BYTES: usize = MAX_PAYLOAD_LENGTH;
29
30#[derive(Debug, Error)]
32pub enum LogError {
33 #[error(transparent)]
35 Io(#[from] io::Error),
36
37 #[error("invalid frame magic at byte offset {offset}")]
39 BadMagic {
40 offset: u64,
42 },
43
44 #[error(
46 "unsupported log version {found} at byte offset {offset}; supported version is {supported}"
47 )]
48 UnsupportedVersion {
49 offset: u64,
51 found: u16,
53 supported: u16,
55 },
56
57 #[error("unknown frame kind {kind} at byte offset {offset}")]
59 UnknownFrameKind {
60 offset: u64,
62 kind: u8,
64 },
65
66 #[error("unsupported frame flags {flags:#04x} at byte offset {offset}")]
68 UnsupportedFlags {
69 offset: u64,
71 flags: u8,
73 },
74
75 #[error("frame payload is {length} bytes; maximum is {maximum}")]
77 PayloadTooLarge {
78 length: usize,
80 maximum: usize,
82 },
83
84 #[error("invalid sequence at byte offset {offset}: expected {expected}, found {found}")]
86 InvalidSequence {
87 offset: u64,
89 expected: u64,
91 found: u64,
93 },
94
95 #[error("previous-frame digest mismatch at sequence {sequence}")]
97 PreviousDigestMismatch {
98 sequence: u64,
100 },
101
102 #[error("CRC32C mismatch at sequence {sequence}")]
104 ChecksumMismatch {
105 sequence: u64,
107 },
108
109 #[error("BLAKE3 digest mismatch at sequence {sequence}")]
111 DigestMismatch {
112 sequence: u64,
114 },
115
116 #[error("malformed transaction descriptor at sequence {sequence}")]
118 MalformedTransaction {
119 sequence: u64,
121 },
122
123 #[error("{kind} frame at sequence {sequence} has no matching transaction begin")]
125 TransactionBoundary {
126 kind: &'static str,
128 sequence: u64,
130 },
131
132 #[error("transaction content mismatch at commit sequence {sequence}")]
134 TransactionContentMismatch {
135 sequence: u64,
137 },
138
139 #[error(
141 "transaction identifier {transaction_id} was already committed with different contents"
142 )]
143 IdempotencyConflict {
144 transaction_id: Uuid,
146 },
147
148 #[error("a transaction must contain at least one operation")]
150 EmptyTransaction,
151
152 #[error("transaction has too many operations")]
154 TooManyOperations,
155
156 #[error("log sequence space is exhausted")]
158 SequenceExhausted,
159
160 #[error("invalid log segment anchor")]
162 InvalidAnchor,
163
164 #[error("durable log writer is poisoned; reopen it before writing again")]
166 Poisoned,
167}
168
169impl From<StorageLimitError> for LogError {
170 fn from(source: StorageLimitError) -> Self {
171 Self::Io(limit_io_error(source))
172 }
173}
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177pub struct CommitReceipt {
178 pub transaction_id: Uuid,
180 pub commit_sequence: u64,
182 pub commit_digest: [u8; 32],
184 pub transaction_digest: [u8; 32],
186}
187
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub enum AppendOutcome {
191 Committed(CommitReceipt),
193 Existing(CommitReceipt),
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct RecoveredTransaction {
200 pub receipt: CommitReceipt,
202 pub operations: Vec<Vec<u8>>,
204}
205
206#[derive(Clone, Debug, Default, Eq, PartialEq)]
208pub struct RecoveryReport {
209 pub base_sequence: u64,
211 pub base_digest: [u8; 32],
213 pub transactions: Vec<RecoveredTransaction>,
215 pub ignored_uncommitted_transactions: u64,
217 pub duplicate_commits: u64,
219 pub truncated_tail_bytes: u64,
221 pub valid_bytes: u64,
223 pub last_sequence: u64,
225 pub last_digest: [u8; 32],
227}
228
229#[derive(Debug)]
231pub struct OpenedLog<'directory> {
232 pub log: DurableLog,
234 pub recovery: RecoveryReport,
236 directory_lock: PhantomData<&'directory crate::DataDirectory>,
237}
238
239impl OpenedLog<'_> {
240 pub(crate) fn new(log: DurableLog, recovery: RecoveryReport) -> Self {
241 Self {
242 log,
243 recovery,
244 directory_lock: PhantomData,
245 }
246 }
247}
248
249#[derive(Debug)]
251pub struct DurableLog {
252 file: File,
253 disk_format_version: u16,
254 max_file_bytes: u64,
255 max_frames: u64,
256 max_transactions: u64,
257 max_operations: u64,
258 max_decoded_operation_bytes: u64,
259 frame_count: u64,
260 transaction_count: u64,
261 operation_count: u64,
262 decoded_operation_bytes: u64,
263 next_sequence: u64,
264 previous_digest: [u8; 32],
265 committed: HashMap<Uuid, CommitReceipt>,
266 poisoned: bool,
267 #[cfg(test)]
268 fail_next_sync: bool,
269}
270
271impl DurableLog {
272 #[cfg(test)]
281 pub(crate) fn open_file(
282 path: impl AsRef<Path>,
283 ) -> Result<(DurableLog, RecoveryReport), LogError> {
284 Self::open_file_at(path, 0, [0; 32])
285 }
286
287 #[cfg(test)]
288 pub(crate) fn open_file_at(
289 path: impl AsRef<Path>,
290 base_sequence: u64,
291 base_digest: [u8; 32],
292 ) -> Result<(DurableLog, RecoveryReport), LogError> {
293 Self::open_file_at_version(path, base_sequence, base_digest, DISK_FORMAT_VERSION)
294 }
295
296 pub(crate) fn open_file_at_version(
297 path: impl AsRef<Path>,
298 base_sequence: u64,
299 base_digest: [u8; 32],
300 disk_format_version: u16,
301 ) -> Result<(DurableLog, RecoveryReport), LogError> {
302 let limits = RecoveryLimits::default();
303 let deadline = OperationDeadline::new(limits.timeout);
304 Self::open_file_at_version_with_limits(
305 path,
306 base_sequence,
307 base_digest,
308 disk_format_version,
309 &limits,
310 &deadline,
311 )
312 }
313
314 pub(crate) fn open_file_at_version_with_limits(
315 path: impl AsRef<Path>,
316 base_sequence: u64,
317 base_digest: [u8; 32],
318 disk_format_version: u16,
319 limits: &RecoveryLimits,
320 deadline: &OperationDeadline,
321 ) -> Result<(DurableLog, RecoveryReport), LogError> {
322 deadline.check()?;
323 if (base_sequence == 0) != (base_digest == [0; 32]) {
324 return Err(LogError::InvalidAnchor);
325 }
326 if !(MIN_DISK_FORMAT_VERSION..=DISK_FORMAT_VERSION).contains(&disk_format_version) {
327 return Err(LogError::UnsupportedVersion {
328 offset: 0,
329 found: disk_format_version,
330 supported: DISK_FORMAT_VERSION,
331 });
332 }
333 let path = path.as_ref();
334 if let Some(parent) = path.parent() {
335 std::fs::create_dir_all(parent)?;
336 }
337 let existed = path.exists();
338 let mut file = OpenOptions::new()
339 .create(true)
340 .read(true)
341 .write(true)
342 .truncate(false)
343 .open(path)?;
344 if !existed {
345 file.sync_all()?;
346 #[cfg(unix)]
347 if let Some(parent) = path.parent() {
348 File::open(parent)?.sync_all()?;
349 }
350 }
351 let physical_length = file.metadata()?.len();
352 if physical_length > limits.max_log_file_bytes {
353 return Err(StorageLimitError::LogFileBytesExceeded {
354 actual: physical_length,
355 maximum: limits.max_log_file_bytes,
356 }
357 .into());
358 }
359 let scanned = scan(
360 &mut file,
361 physical_length,
362 base_sequence,
363 base_digest,
364 disk_format_version,
365 limits,
366 deadline,
367 )?;
368 deadline.check()?;
369 ensure_file_length_unchanged(&file, physical_length)?;
370 if physical_length != scanned.report.valid_bytes {
371 file.set_len(scanned.report.valid_bytes)?;
372 file.sync_data()?;
373 }
374 file.seek(SeekFrom::End(0))?;
375
376 let committed = scanned
377 .report
378 .transactions
379 .iter()
380 .map(|transaction| (transaction.receipt.transaction_id, transaction.receipt))
381 .collect();
382 let next_sequence = scanned
383 .report
384 .last_sequence
385 .checked_add(1)
386 .ok_or(LogError::SequenceExhausted)?;
387 let log = Self {
388 file,
389 disk_format_version,
390 max_file_bytes: limits.max_log_file_bytes,
391 max_frames: limits.max_log_frames,
392 max_transactions: limits.max_transactions,
393 max_operations: limits.max_operations,
394 max_decoded_operation_bytes: limits.max_decoded_operation_bytes,
395 frame_count: scanned.frame_count,
396 transaction_count: scanned.transaction_count,
397 operation_count: scanned.operation_count,
398 decoded_operation_bytes: scanned.decoded_operation_bytes,
399 next_sequence,
400 previous_digest: scanned.report.last_digest,
401 committed,
402 poisoned: false,
403 #[cfg(test)]
404 fail_next_sync: false,
405 };
406 Ok((log, scanned.report))
407 }
408
409 pub fn append_transaction(
420 &mut self,
421 transaction_id: Uuid,
422 operations: &[Vec<u8>],
423 ) -> Result<AppendOutcome, LogError> {
424 if self.poisoned {
425 return Err(LogError::Poisoned);
426 }
427 if operations.is_empty() {
428 return Err(LogError::EmptyTransaction);
429 }
430 let operation_count =
431 u32::try_from(operations.len()).map_err(|_| LogError::TooManyOperations)?;
432 for operation in operations {
433 if operation.len() > MAX_PAYLOAD_LENGTH {
434 return Err(LogError::PayloadTooLarge {
435 length: operation.len(),
436 maximum: MAX_PAYLOAD_LENGTH,
437 });
438 }
439 }
440
441 let transaction_digest = transaction_digest(operations, operation_count)?;
442 if let Some(receipt) = self.committed.get(&transaction_id).copied() {
443 return if receipt.transaction_digest == transaction_digest {
444 Ok(AppendOutcome::Existing(receipt))
445 } else {
446 Err(LogError::IdempotencyConflict { transaction_id })
447 };
448 }
449 let operation_delta =
450 u64::try_from(operations.len()).map_err(|_| LogError::TooManyOperations)?;
451 let projected_frames = self.preflight_frames(operation_delta)?;
452 let projected_transactions = self.transaction_count.checked_add(1).ok_or(
453 StorageLimitError::TransactionsExceeded {
454 maximum: self.max_transactions,
455 },
456 )?;
457 if projected_transactions > self.max_transactions {
458 return Err(StorageLimitError::TransactionsExceeded {
459 maximum: self.max_transactions,
460 }
461 .into());
462 }
463 let projected_operations = self.operation_count.checked_add(operation_delta).ok_or(
464 StorageLimitError::OperationsExceeded {
465 maximum: self.max_operations,
466 },
467 )?;
468 if projected_operations > self.max_operations {
469 return Err(StorageLimitError::OperationsExceeded {
470 maximum: self.max_operations,
471 }
472 .into());
473 }
474 let operation_byte_delta = operations.iter().try_fold(0_u64, |total, operation| {
475 let bytes = u64::try_from(operation.len()).ok()?;
476 total.checked_add(bytes)
477 });
478 let projected_operation_bytes = operation_byte_delta
479 .and_then(|delta| self.decoded_operation_bytes.checked_add(delta))
480 .ok_or(StorageLimitError::DecodedOperationBytesExceeded {
481 maximum: self.max_decoded_operation_bytes,
482 })?;
483 if projected_operation_bytes > self.max_decoded_operation_bytes {
484 return Err(StorageLimitError::DecodedOperationBytesExceeded {
485 maximum: self.max_decoded_operation_bytes,
486 }
487 .into());
488 }
489 let current_bytes = self.file.metadata()?.len();
490 let appended_bytes = transaction_encoded_length(operations).ok_or(
491 StorageLimitError::LogFileBytesExceeded {
492 actual: u64::MAX,
493 maximum: self.max_file_bytes,
494 },
495 )?;
496 let projected_bytes = current_bytes.checked_add(appended_bytes).ok_or(
497 StorageLimitError::LogFileBytesExceeded {
498 actual: u64::MAX,
499 maximum: self.max_file_bytes,
500 },
501 )?;
502 if projected_bytes > self.max_file_bytes {
503 return Err(StorageLimitError::LogFileBytesExceeded {
504 actual: projected_bytes,
505 maximum: self.max_file_bytes,
506 }
507 .into());
508 }
509
510 let descriptor = encode_descriptor(operation_count, transaction_digest);
511 let append_result = self.append_new_transaction(
512 transaction_id,
513 operations,
514 &descriptor,
515 transaction_digest,
516 );
517 if append_result.is_err() {
518 self.poisoned = true;
519 } else {
520 self.frame_count = projected_frames;
521 self.transaction_count = projected_transactions;
522 self.operation_count = projected_operations;
523 self.decoded_operation_bytes = projected_operation_bytes;
524 }
525 append_result
526 }
527
528 fn preflight_frames(&self, operation_count: u64) -> Result<u64, LogError> {
529 let frame_delta =
530 operation_count
531 .checked_add(2)
532 .ok_or(StorageLimitError::LogFramesExceeded {
533 maximum: self.max_frames,
534 })?;
535 self.next_sequence
536 .checked_add(frame_delta)
537 .ok_or(LogError::SequenceExhausted)?;
538 let projected_frames = self.frame_count.checked_add(frame_delta).ok_or(
539 StorageLimitError::LogFramesExceeded {
540 maximum: self.max_frames,
541 },
542 )?;
543 if projected_frames > self.max_frames {
544 return Err(StorageLimitError::LogFramesExceeded {
545 maximum: self.max_frames,
546 }
547 .into());
548 }
549 Ok(projected_frames)
550 }
551
552 pub(crate) fn is_poisoned(&self) -> bool {
553 self.poisoned
554 }
555
556 pub(crate) fn set_disk_format_version(
557 &mut self,
558 disk_format_version: u16,
559 ) -> Result<(), LogError> {
560 if !(MIN_DISK_FORMAT_VERSION..=DISK_FORMAT_VERSION).contains(&disk_format_version) {
561 return Err(LogError::UnsupportedVersion {
562 offset: 0,
563 found: disk_format_version,
564 supported: DISK_FORMAT_VERSION,
565 });
566 }
567 self.disk_format_version = disk_format_version;
568 Ok(())
569 }
570
571 #[cfg(test)]
572 pub(crate) fn inject_sync_failure(&mut self) {
573 self.fail_next_sync = true;
574 }
575
576 fn append_new_transaction(
577 &mut self,
578 transaction_id: Uuid,
579 operations: &[Vec<u8>],
580 descriptor: &[u8; DESCRIPTOR_LENGTH],
581 transaction_digest: [u8; 32],
582 ) -> Result<AppendOutcome, LogError> {
583 self.append_frame(FrameKind::Begin, transaction_id, descriptor)?;
584 for operation in operations {
585 self.append_frame(FrameKind::Operation, transaction_id, operation)?;
586 }
587 let receipt = self.append_frame(FrameKind::Commit, transaction_id, descriptor)?;
588 #[cfg(test)]
589 if std::mem::take(&mut self.fail_next_sync) {
590 return Err(io::Error::other("injected log sync failure").into());
591 }
592 self.file.sync_data()?;
593
594 let receipt = CommitReceipt {
595 transaction_id,
596 commit_sequence: receipt.sequence,
597 commit_digest: receipt.digest,
598 transaction_digest,
599 };
600 self.committed.insert(transaction_id, receipt);
601 Ok(AppendOutcome::Committed(receipt))
602 }
603
604 fn append_frame(
605 &mut self,
606 kind: FrameKind,
607 transaction_id: Uuid,
608 payload: &[u8],
609 ) -> Result<WrittenFrame, LogError> {
610 let frame = Frame {
611 kind,
612 sequence: self.next_sequence,
613 transaction_id,
614 previous_digest: self.previous_digest,
615 digest: [0; 32],
616 payload: payload.to_vec(),
617 };
618 let encoded = frame.encode(self.disk_format_version)?;
619 let digest = copy_array(&encoded[80..112]);
620 self.file.write_all(&encoded)?;
621 let written = WrittenFrame {
622 sequence: self.next_sequence,
623 digest,
624 };
625 self.next_sequence = self
626 .next_sequence
627 .checked_add(1)
628 .ok_or(LogError::SequenceExhausted)?;
629 self.previous_digest = digest;
630 Ok(written)
631 }
632}
633
634fn transaction_encoded_length(operations: &[Vec<u8>]) -> Option<u64> {
635 let header_bytes = u64::try_from(HEADER_LENGTH).ok()?;
636 let descriptor_bytes = u64::try_from(DESCRIPTOR_LENGTH).ok()?;
637 let mut total = header_bytes.checked_add(descriptor_bytes)?.checked_mul(2)?;
638 for operation in operations {
639 let operation_bytes = u64::try_from(operation.len()).ok()?;
640 total = total
641 .checked_add(header_bytes)?
642 .checked_add(operation_bytes)?;
643 }
644 Some(total)
645}
646
647#[derive(Clone, Copy, Debug)]
648struct WrittenFrame {
649 sequence: u64,
650 digest: [u8; 32],
651}
652
653#[derive(Debug)]
654struct PendingTransaction {
655 transaction_id: Uuid,
656 operation_count: u32,
657 expected_digest: [u8; 32],
658 operations: Vec<Vec<u8>>,
659}
660
661struct ScanOutcome {
662 report: RecoveryReport,
663 frame_count: u64,
664 transaction_count: u64,
665 operation_count: u64,
666 decoded_operation_bytes: u64,
667}
668
669#[allow(clippy::too_many_lines)]
670fn scan(
671 file: &mut File,
672 physical_length: u64,
673 base_sequence: u64,
674 base_digest: [u8; 32],
675 disk_format_version: u16,
676 limits: &RecoveryLimits,
677 deadline: &OperationDeadline,
678) -> Result<ScanOutcome, LogError> {
679 deadline.check()?;
680 file.seek(SeekFrom::Start(0))?;
681 let mut report = RecoveryReport {
682 base_sequence,
683 base_digest,
684 last_sequence: base_sequence,
685 last_digest: base_digest,
686 ..RecoveryReport::default()
687 };
688 let mut offset = 0_u64;
689 let mut expected_sequence = base_sequence
690 .checked_add(1)
691 .ok_or(LogError::SequenceExhausted)?;
692 let mut expected_previous_digest = base_digest;
693 let mut pending: Option<PendingTransaction> = None;
694 let mut committed: HashMap<Uuid, CommitReceipt> = HashMap::new();
695 let mut frame_count = 0_u64;
696 let mut operation_count = 0_u64;
697 let mut decoded_operation_bytes = 0_u64;
698
699 loop {
700 deadline.check()?;
701 let remaining = physical_length
702 .checked_sub(offset)
703 .ok_or_else(|| io::Error::other("log scan exceeded its captured file length"))?;
704 if remaining == 0 {
705 break;
706 }
707 let header_length = u64::try_from(HEADER_LENGTH)
708 .map_err(|_| io::Error::other("log header length overflow"))?;
709 if remaining < header_length {
710 report.truncated_tail_bytes = remaining;
711 break;
712 }
713 let mut header = [0_u8; HEADER_LENGTH];
714 match read_exact_or_tail(file, &mut header)? {
715 ReadStatus::End | ReadStatus::Partial => {
716 report.truncated_tail_bytes = remaining;
717 break;
718 }
719 ReadStatus::Complete => {}
720 }
721 let length = payload_length(&header, offset, disk_format_version)?;
722 let frame_length = header_length
723 .checked_add(
724 u64::try_from(length)
725 .map_err(|_| io::Error::other("log payload length overflow"))?,
726 )
727 .ok_or_else(|| io::Error::other("log frame length overflow"))?;
728 if frame_length > remaining {
729 report.truncated_tail_bytes = remaining;
730 break;
731 }
732 let mut payload = vec![0_u8; length];
733 if read_payload_or_tail(file, &mut payload, deadline)? != ReadStatus::Complete {
734 report.truncated_tail_bytes = remaining;
735 break;
736 }
737 frame_count = frame_count
738 .checked_add(1)
739 .ok_or(StorageLimitError::LogFramesExceeded {
740 maximum: limits.max_log_frames,
741 })?;
742 if frame_count > limits.max_log_frames {
743 return Err(StorageLimitError::LogFramesExceeded {
744 maximum: limits.max_log_frames,
745 }
746 .into());
747 }
748
749 let frame = Frame::decode(&header, payload, offset, disk_format_version)?;
750 if frame.sequence != expected_sequence {
751 return Err(LogError::InvalidSequence {
752 offset,
753 expected: expected_sequence,
754 found: frame.sequence,
755 });
756 }
757 if frame.previous_digest != expected_previous_digest {
758 return Err(LogError::PreviousDigestMismatch {
759 sequence: frame.sequence,
760 });
761 }
762
763 if frame.kind == FrameKind::Operation {
764 operation_count =
765 operation_count
766 .checked_add(1)
767 .ok_or(StorageLimitError::OperationsExceeded {
768 maximum: limits.max_operations,
769 })?;
770 if operation_count > limits.max_operations {
771 return Err(StorageLimitError::OperationsExceeded {
772 maximum: limits.max_operations,
773 }
774 .into());
775 }
776 let payload_bytes = u64::try_from(frame.payload.len()).map_err(|_| {
777 StorageLimitError::DecodedOperationBytesExceeded {
778 maximum: limits.max_decoded_operation_bytes,
779 }
780 })?;
781 decoded_operation_bytes = decoded_operation_bytes.checked_add(payload_bytes).ok_or(
782 StorageLimitError::DecodedOperationBytesExceeded {
783 maximum: limits.max_decoded_operation_bytes,
784 },
785 )?;
786 if decoded_operation_bytes > limits.max_decoded_operation_bytes {
787 return Err(StorageLimitError::DecodedOperationBytesExceeded {
788 maximum: limits.max_decoded_operation_bytes,
789 }
790 .into());
791 }
792 }
793 apply_frame(
794 &frame,
795 &mut pending,
796 &mut committed,
797 &mut report,
798 limits,
799 deadline,
800 )?;
801 offset = offset
802 .checked_add(frame_length)
803 .ok_or(LogError::SequenceExhausted)?;
804 report.valid_bytes = offset;
805 report.last_sequence = frame.sequence;
806 report.last_digest = frame.digest;
807 expected_sequence = expected_sequence
808 .checked_add(1)
809 .ok_or(LogError::SequenceExhausted)?;
810 expected_previous_digest = frame.digest;
811 }
812
813 if pending.is_some() {
814 report.ignored_uncommitted_transactions =
815 report.ignored_uncommitted_transactions.saturating_add(1);
816 }
817 let transaction_count = u64::try_from(report.transactions.len()).map_err(|_| {
818 StorageLimitError::TransactionsExceeded {
819 maximum: limits.max_transactions,
820 }
821 })?;
822 Ok(ScanOutcome {
823 report,
824 frame_count,
825 transaction_count,
826 operation_count,
827 decoded_operation_bytes,
828 })
829}
830
831fn ensure_file_length_unchanged(file: &File, expected: u64) -> io::Result<()> {
832 let actual = file.metadata()?.len();
833 if actual == expected {
834 Ok(())
835 } else {
836 Err(io::Error::other(format!(
837 "log changed while being scanned: expected {expected} bytes, found {actual}"
838 )))
839 }
840}
841
842fn read_payload_or_tail(
843 file: &mut File,
844 payload: &mut [u8],
845 deadline: &OperationDeadline,
846) -> Result<ReadStatus, LogError> {
847 for chunk in payload.chunks_mut(64 * 1024) {
848 deadline.check()?;
849 if read_exact_or_tail(file, chunk)? != ReadStatus::Complete {
850 return Ok(ReadStatus::Partial);
851 }
852 }
853 Ok(ReadStatus::Complete)
854}
855
856fn apply_frame(
857 frame: &Frame,
858 pending: &mut Option<PendingTransaction>,
859 committed: &mut HashMap<Uuid, CommitReceipt>,
860 report: &mut RecoveryReport,
861 limits: &RecoveryLimits,
862 deadline: &OperationDeadline,
863) -> Result<(), LogError> {
864 match frame.kind {
865 FrameKind::Begin => {
866 if pending.is_some() {
867 report.ignored_uncommitted_transactions =
868 report.ignored_uncommitted_transactions.saturating_add(1);
869 }
870 let (operation_count, expected_digest) =
871 decode_descriptor(&frame.payload, frame.sequence)?;
872 *pending = Some(PendingTransaction {
873 transaction_id: frame.transaction_id,
874 operation_count,
875 expected_digest,
876 operations: Vec::new(),
877 });
878 Ok(())
879 }
880 FrameKind::Operation => {
881 let Some(current) = pending
882 .as_mut()
883 .filter(|current| current.transaction_id == frame.transaction_id)
884 else {
885 return Err(LogError::TransactionBoundary {
886 kind: "operation",
887 sequence: frame.sequence,
888 });
889 };
890 current.operations.push(frame.payload.clone());
891 Ok(())
892 }
893 FrameKind::Commit => {
894 let Some(current) = pending
895 .take()
896 .filter(|current| current.transaction_id == frame.transaction_id)
897 else {
898 return Err(LogError::TransactionBoundary {
899 kind: "commit",
900 sequence: frame.sequence,
901 });
902 };
903 let (operation_count, commit_digest) =
904 decode_descriptor(&frame.payload, frame.sequence)?;
905 let actual_count =
906 u32::try_from(current.operations.len()).map_err(|_| LogError::TooManyOperations)?;
907 let actual_digest = transaction_digest_with_deadline(
908 ¤t.operations,
909 actual_count,
910 Some(deadline),
911 )?;
912 if operation_count != current.operation_count
913 || commit_digest != current.expected_digest
914 || actual_count != operation_count
915 || actual_digest != commit_digest
916 {
917 return Err(LogError::TransactionContentMismatch {
918 sequence: frame.sequence,
919 });
920 }
921
922 let receipt = CommitReceipt {
923 transaction_id: frame.transaction_id,
924 commit_sequence: frame.sequence,
925 commit_digest: frame.digest,
926 transaction_digest: actual_digest,
927 };
928 if let Some(existing) = committed.get(&frame.transaction_id) {
929 if existing.transaction_digest != actual_digest {
930 return Err(LogError::IdempotencyConflict {
931 transaction_id: frame.transaction_id,
932 });
933 }
934 report.duplicate_commits = report.duplicate_commits.saturating_add(1);
935 } else {
936 let transaction_count = u64::try_from(report.transactions.len())
937 .ok()
938 .and_then(|count| count.checked_add(1))
939 .ok_or(StorageLimitError::TransactionsExceeded {
940 maximum: limits.max_transactions,
941 })?;
942 if transaction_count > limits.max_transactions {
943 return Err(StorageLimitError::TransactionsExceeded {
944 maximum: limits.max_transactions,
945 }
946 .into());
947 }
948 committed.insert(frame.transaction_id, receipt);
949 report.transactions.push(RecoveredTransaction {
950 receipt,
951 operations: current.operations,
952 });
953 }
954 Ok(())
955 }
956 }
957}
958
959fn encode_descriptor(operation_count: u32, digest: [u8; 32]) -> [u8; DESCRIPTOR_LENGTH] {
960 let mut descriptor = [0_u8; DESCRIPTOR_LENGTH];
961 descriptor[..4].copy_from_slice(&operation_count.to_le_bytes());
962 descriptor[4..].copy_from_slice(&digest);
963 descriptor
964}
965
966fn decode_descriptor(payload: &[u8], sequence: u64) -> Result<(u32, [u8; 32]), LogError> {
967 if payload.len() != DESCRIPTOR_LENGTH {
968 return Err(LogError::MalformedTransaction { sequence });
969 }
970 let operation_count = u32::from_le_bytes(copy_array(&payload[..4]));
971 if operation_count == 0 {
972 return Err(LogError::MalformedTransaction { sequence });
973 }
974 let digest = copy_array(&payload[4..]);
975 Ok((operation_count, digest))
976}
977
978pub(crate) fn transaction_digest(
979 operations: &[Vec<u8>],
980 operation_count: u32,
981) -> Result<[u8; 32], LogError> {
982 transaction_digest_with_deadline(operations, operation_count, None)
983}
984
985fn transaction_digest_with_deadline(
986 operations: &[Vec<u8>],
987 operation_count: u32,
988 deadline: Option<&OperationDeadline>,
989) -> Result<[u8; 32], LogError> {
990 if let Some(deadline) = deadline {
991 deadline.check()?;
992 }
993 let mut hasher = blake3::Hasher::new();
994 hasher.update(TRANSACTION_DOMAIN);
995 hasher.update(&u64::from(operation_count).to_le_bytes());
996 for operation in operations {
997 if let Some(deadline) = deadline {
998 deadline.check()?;
999 }
1000 let length = u64::try_from(operation.len()).map_err(|_| LogError::PayloadTooLarge {
1001 length: operation.len(),
1002 maximum: MAX_PAYLOAD_LENGTH,
1003 })?;
1004 hasher.update(&length.to_le_bytes());
1005 for chunk in operation.chunks(64 * 1024) {
1006 if let Some(deadline) = deadline {
1007 deadline.check()?;
1008 }
1009 hasher.update(chunk);
1010 }
1011 }
1012 Ok(*hasher.finalize().as_bytes())
1013}
1014
1015fn copy_array<const N: usize>(source: &[u8]) -> [u8; N] {
1016 let mut output = [0_u8; N];
1017 output.copy_from_slice(source);
1018 output
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use std::{
1024 error::Error,
1025 fs::OpenOptions,
1026 io::{Read, Seek, SeekFrom, Write},
1027 time::Duration,
1028 };
1029
1030 use hyphae_core::DISK_FORMAT_VERSION;
1031 use uuid::Uuid;
1032
1033 use super::{
1034 AppendOutcome, DurableLog, LogError, OpenedLog, ensure_file_length_unchanged,
1035 frame::HEADER_LENGTH, scan, transaction_encoded_length,
1036 };
1037 use crate::{
1038 RecoveryLimits, StorageLimitError, limits::OperationDeadline, storage_limit_from_io,
1039 test_support::TestDirectory,
1040 };
1041
1042 fn log_storage_limit(error: &LogError) -> Option<&StorageLimitError> {
1043 match error {
1044 LogError::Io(source) => storage_limit_from_io(source),
1045 _ => None,
1046 }
1047 }
1048
1049 fn open_for_test(path: &std::path::Path) -> Result<OpenedLog<'static>, LogError> {
1050 let (log, recovery) = DurableLog::open_file(path)?;
1051 Ok(OpenedLog::new(log, recovery))
1052 }
1053
1054 #[test]
1055 fn committed_transaction_recovers_in_order() -> Result<(), Box<dyn Error>> {
1056 let temporary = TestDirectory::new("log-recovery")?;
1057 let path = temporary.path().join("segment.hylog");
1058 let transaction_id = Uuid::now_v7();
1059 let mut opened = open_for_test(&path)?;
1060 let outcome = opened
1061 .log
1062 .append_transaction(transaction_id, &[b"put:a=1".to_vec(), b"put:b=2".to_vec()])?;
1063 assert!(matches!(outcome, AppendOutcome::Committed(_)));
1064 drop(opened);
1065
1066 let reopened = open_for_test(&path)?;
1067 assert_eq!(reopened.recovery.transactions.len(), 1);
1068 assert_eq!(
1069 reopened.recovery.transactions[0].operations,
1070 [b"put:a=1".to_vec(), b"put:b=2".to_vec()]
1071 );
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn recovery_limits_are_exact_and_fail_before_tail_repair() -> Result<(), Box<dyn Error>> {
1077 let temporary = TestDirectory::new("log-recovery-limits")?;
1078 let path = temporary.path().join("segment.hylog");
1079 let mut opened = open_for_test(&path)?;
1080 opened
1081 .log
1082 .append_transaction(Uuid::now_v7(), &[b"one".to_vec()])?;
1083 opened
1084 .log
1085 .append_transaction(Uuid::now_v7(), &[b"four".to_vec()])?;
1086 drop(opened);
1087 let file_bytes = std::fs::metadata(&path)?.len();
1088 let exact = RecoveryLimits {
1089 max_log_file_bytes: file_bytes,
1090 max_log_frames: 6,
1091 max_transactions: 2,
1092 max_operations: 2,
1093 max_decoded_operation_bytes: 7,
1094 ..RecoveryLimits::default()
1095 };
1096 let open = |limits: &RecoveryLimits, timeout| {
1097 let deadline = OperationDeadline::new(timeout);
1098 DurableLog::open_file_at_version_with_limits(
1099 &path,
1100 0,
1101 [0; 32],
1102 DISK_FORMAT_VERSION,
1103 limits,
1104 &deadline,
1105 )
1106 };
1107 assert_eq!(
1108 open(&exact, Duration::from_secs(5))?.1.transactions.len(),
1109 2
1110 );
1111
1112 for (limits, expected) in [
1113 (
1114 RecoveryLimits {
1115 max_log_file_bytes: file_bytes - 1,
1116 ..exact.clone()
1117 },
1118 StorageLimitError::LogFileBytesExceeded {
1119 actual: file_bytes,
1120 maximum: file_bytes - 1,
1121 },
1122 ),
1123 (
1124 RecoveryLimits {
1125 max_log_frames: 5,
1126 ..exact.clone()
1127 },
1128 StorageLimitError::LogFramesExceeded { maximum: 5 },
1129 ),
1130 (
1131 RecoveryLimits {
1132 max_transactions: 1,
1133 ..exact.clone()
1134 },
1135 StorageLimitError::TransactionsExceeded { maximum: 1 },
1136 ),
1137 (
1138 RecoveryLimits {
1139 max_operations: 1,
1140 ..exact.clone()
1141 },
1142 StorageLimitError::OperationsExceeded { maximum: 1 },
1143 ),
1144 (
1145 RecoveryLimits {
1146 max_decoded_operation_bytes: 6,
1147 ..exact.clone()
1148 },
1149 StorageLimitError::DecodedOperationBytesExceeded { maximum: 6 },
1150 ),
1151 ] {
1152 assert!(matches!(
1153 open(&limits, Duration::from_secs(5)),
1154 Err(source) if log_storage_limit(&source) == Some(&expected)
1155 ));
1156 assert_eq!(std::fs::metadata(&path)?.len(), file_bytes);
1157 }
1158 assert!(matches!(
1159 open(&exact, Duration::ZERO),
1160 Err(source)
1161 if log_storage_limit(&source) == Some(&StorageLimitError::TimedOut)
1162 ));
1163 assert_eq!(std::fs::metadata(&path)?.len(), file_bytes);
1164 Ok(())
1165 }
1166
1167 #[test]
1168 fn scan_stops_at_captured_length_and_detects_later_growth() -> Result<(), Box<dyn Error>> {
1169 let temporary = TestDirectory::new("log-captured-length")?;
1170 let path = temporary.path().join("segment.hylog");
1171 let mut opened = open_for_test(&path)?;
1172 opened
1173 .log
1174 .append_transaction(Uuid::now_v7(), &[b"first".to_vec()])?;
1175 let captured_length = opened.log.file.metadata()?.len();
1176 opened
1177 .log
1178 .append_transaction(Uuid::now_v7(), &[b"second".to_vec()])?;
1179 drop(opened);
1180
1181 let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
1182 let scanned = scan(
1183 &mut file,
1184 captured_length,
1185 0,
1186 [0; 32],
1187 DISK_FORMAT_VERSION,
1188 &RecoveryLimits::default(),
1189 &OperationDeadline::new(Duration::from_secs(5)),
1190 )?;
1191
1192 assert_eq!(scanned.report.transactions.len(), 1);
1193 assert_eq!(scanned.report.valid_bytes, captured_length);
1194 assert_eq!(file.stream_position()?, captured_length);
1195 assert!(file.metadata()?.len() > captured_length);
1196 assert!(ensure_file_length_unchanged(&file, captured_length).is_err());
1197 Ok(())
1198 }
1199
1200 #[test]
1201 fn incomplete_payload_tail_does_not_consume_a_frame_limit() -> Result<(), Box<dyn Error>> {
1202 let temporary = TestDirectory::new("log-partial-payload-frame-limit")?;
1203 let path = temporary.path().join("segment.hylog");
1204 let operations = [b"one".to_vec()];
1205 let transaction_bytes =
1206 transaction_encoded_length(&operations).ok_or("transaction length overflow")?;
1207 let limits = RecoveryLimits {
1208 max_log_frames: 6,
1209 ..RecoveryLimits::default()
1210 };
1211 let open = || {
1212 DurableLog::open_file_at_version_with_limits(
1213 &path,
1214 0,
1215 [0; 32],
1216 DISK_FORMAT_VERSION,
1217 &limits,
1218 &OperationDeadline::new(Duration::from_secs(5)),
1219 )
1220 };
1221
1222 let (mut log, _) = open()?;
1223 log.append_transaction(Uuid::now_v7(), &operations)?;
1224 log.append_transaction(Uuid::now_v7(), &operations)?;
1225 drop(log);
1226
1227 let partial_tail_bytes = u64::try_from(HEADER_LENGTH)? + 1;
1228 let partial_length = transaction_bytes
1229 .checked_add(partial_tail_bytes)
1230 .ok_or("partial length overflow")?;
1231 let file = OpenOptions::new().read(true).write(true).open(&path)?;
1232 file.set_len(partial_length)?;
1233 file.sync_all()?;
1234 drop(file);
1235
1236 let (mut recovered, report) = open()?;
1237 assert_eq!(report.truncated_tail_bytes, partial_tail_bytes);
1238 assert_eq!(std::fs::metadata(&path)?.len(), transaction_bytes);
1239 recovered.append_transaction(Uuid::now_v7(), &operations)?;
1240 drop(recovered);
1241
1242 let (_, final_report) = open()?;
1243 assert_eq!(final_report.transactions.len(), 2);
1244 assert_eq!(std::fs::metadata(&path)?.len(), transaction_bytes * 2);
1245 Ok(())
1246 }
1247
1248 #[test]
1249 fn append_sequence_preflight_is_exact_and_never_writes_on_exhaustion()
1250 -> Result<(), Box<dyn Error>> {
1251 let temporary = TestDirectory::new("log-sequence-preflight")?;
1252 let operations = [b"one".to_vec()];
1253 let limits = RecoveryLimits::default();
1254 let base_digest = [7; 32];
1255
1256 let exact_path = temporary.path().join("exact.hylog");
1257 let exact_base = u64::MAX - 4;
1258 let (mut exact, _) = DurableLog::open_file_at_version_with_limits(
1259 &exact_path,
1260 exact_base,
1261 base_digest,
1262 DISK_FORMAT_VERSION,
1263 &limits,
1264 &OperationDeadline::new(Duration::from_secs(5)),
1265 )?;
1266 let AppendOutcome::Committed(receipt) =
1267 exact.append_transaction(Uuid::now_v7(), &operations)?
1268 else {
1269 return Err("new transaction was not committed".into());
1270 };
1271 assert_eq!(receipt.commit_sequence, u64::MAX - 1);
1272 assert_eq!(exact.next_sequence, u64::MAX);
1273 drop(exact);
1274 let (_, exact_report) = DurableLog::open_file_at_version_with_limits(
1275 &exact_path,
1276 exact_base,
1277 base_digest,
1278 DISK_FORMAT_VERSION,
1279 &limits,
1280 &OperationDeadline::new(Duration::from_secs(5)),
1281 )?;
1282 assert_eq!(exact_report.transactions.len(), 1);
1283
1284 let exhausted_path = temporary.path().join("exhausted.hylog");
1285 let exhausted_base = u64::MAX - 3;
1286 let (mut exhausted, _) = DurableLog::open_file_at_version_with_limits(
1287 &exhausted_path,
1288 exhausted_base,
1289 base_digest,
1290 DISK_FORMAT_VERSION,
1291 &limits,
1292 &OperationDeadline::new(Duration::from_secs(5)),
1293 )?;
1294 assert!(matches!(
1295 exhausted.append_transaction(Uuid::now_v7(), &operations),
1296 Err(LogError::SequenceExhausted)
1297 ));
1298 assert!(!exhausted.is_poisoned());
1299 assert_eq!(std::fs::metadata(&exhausted_path)?.len(), 0);
1300 drop(exhausted);
1301
1302 let (_, exhausted_report) = DurableLog::open_file_at_version_with_limits(
1303 &exhausted_path,
1304 exhausted_base,
1305 base_digest,
1306 DISK_FORMAT_VERSION,
1307 &limits,
1308 &OperationDeadline::new(Duration::from_secs(5)),
1309 )?;
1310 assert!(exhausted_report.transactions.is_empty());
1311 assert_eq!(std::fs::metadata(&exhausted_path)?.len(), 0);
1312 Ok(())
1313 }
1314
1315 #[test]
1316 fn append_never_creates_a_log_that_its_policy_cannot_reopen() -> Result<(), Box<dyn Error>> {
1317 let temporary = TestDirectory::new("log-append-limit")?;
1318 let path = temporary.path().join("segment.hylog");
1319 let operations = [b"one".to_vec()];
1320 let transaction_bytes =
1321 transaction_encoded_length(&operations).ok_or("transaction length overflow")?;
1322 let limits = RecoveryLimits {
1323 max_log_file_bytes: transaction_bytes,
1324 ..RecoveryLimits::default()
1325 };
1326 let deadline = OperationDeadline::new(Duration::from_secs(5));
1327 let (mut log, _) = DurableLog::open_file_at_version_with_limits(
1328 &path,
1329 0,
1330 [0; 32],
1331 DISK_FORMAT_VERSION,
1332 &limits,
1333 &deadline,
1334 )?;
1335 log.append_transaction(Uuid::now_v7(), &operations)?;
1336 assert_eq!(std::fs::metadata(&path)?.len(), transaction_bytes);
1337
1338 assert!(matches!(
1339 log.append_transaction(Uuid::now_v7(), &operations),
1340 Err(source)
1341 if matches!(
1342 log_storage_limit(&source),
1343 Some(StorageLimitError::LogFileBytesExceeded { actual, maximum })
1344 if *actual == transaction_bytes * 2 && *maximum == transaction_bytes
1345 )
1346 ));
1347 assert_eq!(std::fs::metadata(&path)?.len(), transaction_bytes);
1348 drop(log);
1349
1350 DurableLog::open_file_at_version_with_limits(
1351 &path,
1352 0,
1353 [0; 32],
1354 DISK_FORMAT_VERSION,
1355 &limits,
1356 &OperationDeadline::new(Duration::from_secs(5)),
1357 )?;
1358 Ok(())
1359 }
1360
1361 #[test]
1362 fn append_preserves_every_aggregate_recovery_ceiling() -> Result<(), Box<dyn Error>> {
1363 let temporary = TestDirectory::new("log-append-aggregate-limits")?;
1364 let operations = [b"one".to_vec()];
1365 for (name, limits, expected) in [
1366 (
1367 "frames",
1368 RecoveryLimits {
1369 max_log_frames: 3,
1370 ..RecoveryLimits::default()
1371 },
1372 StorageLimitError::LogFramesExceeded { maximum: 3 },
1373 ),
1374 (
1375 "transactions",
1376 RecoveryLimits {
1377 max_transactions: 1,
1378 ..RecoveryLimits::default()
1379 },
1380 StorageLimitError::TransactionsExceeded { maximum: 1 },
1381 ),
1382 (
1383 "operations",
1384 RecoveryLimits {
1385 max_operations: 1,
1386 ..RecoveryLimits::default()
1387 },
1388 StorageLimitError::OperationsExceeded { maximum: 1 },
1389 ),
1390 (
1391 "decoded-bytes",
1392 RecoveryLimits {
1393 max_decoded_operation_bytes: 3,
1394 ..RecoveryLimits::default()
1395 },
1396 StorageLimitError::DecodedOperationBytesExceeded { maximum: 3 },
1397 ),
1398 ] {
1399 let path = temporary.path().join(format!("{name}.hylog"));
1400 let deadline = OperationDeadline::new(Duration::from_secs(5));
1401 let (mut log, _) = DurableLog::open_file_at_version_with_limits(
1402 &path,
1403 0,
1404 [0; 32],
1405 DISK_FORMAT_VERSION,
1406 &limits,
1407 &deadline,
1408 )?;
1409 log.append_transaction(Uuid::now_v7(), &operations)?;
1410 let accepted_bytes = std::fs::metadata(&path)?.len();
1411 assert!(matches!(
1412 log.append_transaction(Uuid::now_v7(), &operations),
1413 Err(source) if log_storage_limit(&source) == Some(&expected)
1414 ));
1415 assert_eq!(std::fs::metadata(&path)?.len(), accepted_bytes);
1416 drop(log);
1417 DurableLog::open_file_at_version_with_limits(
1418 &path,
1419 0,
1420 [0; 32],
1421 DISK_FORMAT_VERSION,
1422 &limits,
1423 &OperationDeadline::new(Duration::from_secs(5)),
1424 )?;
1425 }
1426 Ok(())
1427 }
1428
1429 #[test]
1430 fn idempotency_survives_reopen() -> Result<(), Box<dyn Error>> {
1431 let temporary = TestDirectory::new("log-idempotency")?;
1432 let path = temporary.path().join("segment.hylog");
1433 let transaction_id = Uuid::now_v7();
1434 let operations = [b"same".to_vec()];
1435 let mut opened = open_for_test(&path)?;
1436 let first = opened.log.append_transaction(transaction_id, &operations)?;
1437 drop(opened);
1438
1439 let mut reopened = open_for_test(&path)?;
1440 let second = reopened
1441 .log
1442 .append_transaction(transaction_id, &operations)?;
1443 assert!(matches!(first, AppendOutcome::Committed(_)));
1444 assert!(matches!(second, AppendOutcome::Existing(_)));
1445
1446 let conflict = reopened
1447 .log
1448 .append_transaction(transaction_id, &[b"different".to_vec()]);
1449 assert!(matches!(
1450 conflict,
1451 Err(LogError::IdempotencyConflict { .. })
1452 ));
1453 Ok(())
1454 }
1455
1456 #[test]
1457 fn truncates_only_an_incomplete_tail() -> Result<(), Box<dyn Error>> {
1458 let temporary = TestDirectory::new("log-tail")?;
1459 let path = temporary.path().join("segment.hylog");
1460 let mut opened = open_for_test(&path)?;
1461 opened
1462 .log
1463 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
1464 drop(opened);
1465 let valid_length = std::fs::metadata(&path)?.len();
1466
1467 OpenOptions::new()
1468 .append(true)
1469 .open(&path)?
1470 .write_all(b"partial")?;
1471 let reopened = open_for_test(&path)?;
1472 assert_eq!(reopened.recovery.truncated_tail_bytes, 7);
1473 assert_eq!(std::fs::metadata(&path)?.len(), valid_length);
1474 assert_eq!(reopened.recovery.transactions.len(), 1);
1475 Ok(())
1476 }
1477
1478 #[test]
1479 fn rejects_complete_corruption_without_truncating() -> Result<(), Box<dyn Error>> {
1480 let temporary = TestDirectory::new("log-corruption")?;
1481 let path = temporary.path().join("segment.hylog");
1482 let mut opened = open_for_test(&path)?;
1483 opened
1484 .log
1485 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
1486 drop(opened);
1487 let original_length = std::fs::metadata(&path)?.len();
1488
1489 let payload_offset = u64::try_from(HEADER_LENGTH * 2)? + 36;
1490 let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
1491 file.seek(SeekFrom::Start(payload_offset))?;
1492 let mut byte = [0_u8; 1];
1493 file.read_exact(&mut byte)?;
1494 byte[0] ^= 0x01;
1495 file.seek(SeekFrom::Start(payload_offset))?;
1496 file.write_all(&byte)?;
1497 file.sync_all()?;
1498 drop(file);
1499
1500 let result = open_for_test(&path);
1501 assert!(matches!(result, Err(LogError::ChecksumMismatch { .. })));
1502 assert_eq!(std::fs::metadata(&path)?.len(), original_length);
1503 Ok(())
1504 }
1505
1506 #[test]
1507 fn retry_supersedes_an_uncommitted_attempt() -> Result<(), Box<dyn Error>> {
1508 let temporary = TestDirectory::new("log-retry")?;
1509 let path = temporary.path().join("segment.hylog");
1510 let transaction_id = Uuid::now_v7();
1511 let operations = [b"complete".to_vec()];
1512
1513 let mut opened = open_for_test(&path)?;
1514 let digest = super::transaction_digest(&operations, 1)?;
1515 let descriptor = super::encode_descriptor(1, digest);
1516 opened
1517 .log
1518 .append_frame(super::FrameKind::Begin, transaction_id, &descriptor)?;
1519 opened
1520 .log
1521 .append_frame(super::FrameKind::Operation, transaction_id, b"incomplete")?;
1522 opened.log.file.sync_data()?;
1523 drop(opened);
1524
1525 let mut recovered = open_for_test(&path)?;
1526 assert_eq!(recovered.recovery.ignored_uncommitted_transactions, 1);
1527 recovered
1528 .log
1529 .append_transaction(transaction_id, &operations)?;
1530 drop(recovered);
1531
1532 let final_open = open_for_test(&path)?;
1533 assert_eq!(final_open.recovery.transactions.len(), 1);
1534 assert_eq!(final_open.recovery.transactions[0].operations, operations);
1535 Ok(())
1536 }
1537
1538 #[test]
1539 fn every_incomplete_transaction_prefix_is_atomic() -> Result<(), Box<dyn Error>> {
1540 let temporary = TestDirectory::new("log-byte-cuts")?;
1541 let seed_path = temporary.path().join("seed.hylog");
1542 let target_path = temporary.path().join("cut.hylog");
1543 let mut seed = open_for_test(&seed_path)?;
1544 seed.log
1545 .append_transaction(Uuid::now_v7(), &[b"first".to_vec(), b"second".to_vec()])?;
1546 drop(seed);
1547 let complete = std::fs::read(&seed_path)?;
1548
1549 for cut in 0..complete.len() {
1550 std::fs::write(&target_path, &complete[..cut])?;
1551 let recovered = open_for_test(&target_path)?;
1552 assert!(
1553 recovered.recovery.transactions.is_empty(),
1554 "cut at byte {cut} exposed an uncommitted transaction"
1555 );
1556 drop(recovered);
1557 }
1558
1559 std::fs::write(&target_path, &complete)?;
1560 let recovered = open_for_test(&target_path)?;
1561 assert_eq!(recovered.recovery.transactions.len(), 1);
1562 Ok(())
1563 }
1564
1565 #[test]
1566 fn future_frame_version_fails_before_payload_allocation() -> Result<(), Box<dyn Error>> {
1567 let temporary = TestDirectory::new("log-future-version")?;
1568 let path = temporary.path().join("segment.hylog");
1569 let mut opened = open_for_test(&path)?;
1570 opened
1571 .log
1572 .append_transaction(Uuid::now_v7(), &[b"durable".to_vec()])?;
1573 drop(opened);
1574
1575 let mut bytes = std::fs::read(&path)?;
1576 bytes[8..10].copy_from_slice(&3_u16.to_le_bytes());
1577 bytes[36..44].copy_from_slice(&u64::MAX.to_le_bytes());
1578 std::fs::write(&path, &bytes)?;
1579
1580 let result = open_for_test(&path);
1581 assert!(matches!(
1582 result,
1583 Err(LogError::UnsupportedVersion {
1584 found: 3,
1585 supported: 2,
1586 ..
1587 })
1588 ));
1589 Ok(())
1590 }
1591
1592 #[test]
1593 fn anchored_segment_continues_the_global_digest_chain() -> Result<(), Box<dyn Error>> {
1594 let temporary = TestDirectory::new("log-anchored-segment")?;
1595 let first_path = temporary.path().join("first.hylog");
1596 let second_path = temporary.path().join("second.hylog");
1597 let mut first = open_for_test(&first_path)?;
1598 first
1599 .log
1600 .append_transaction(Uuid::now_v7(), &[b"before-compaction".to_vec()])?;
1601 drop(first);
1602 let (_, first_recovery) = DurableLog::open_file(&first_path)?;
1603
1604 let (mut second, empty_recovery) = DurableLog::open_file_at(
1605 &second_path,
1606 first_recovery.last_sequence,
1607 first_recovery.last_digest,
1608 )?;
1609 assert_eq!(empty_recovery.base_sequence, first_recovery.last_sequence);
1610 assert_eq!(empty_recovery.last_digest, first_recovery.last_digest);
1611 let outcome = second.append_transaction(Uuid::now_v7(), &[b"after-compaction".to_vec()])?;
1612 let AppendOutcome::Committed(receipt) = outcome else {
1613 return Err("new anchored transaction was not committed".into());
1614 };
1615 assert_eq!(receipt.commit_sequence, first_recovery.last_sequence + 3);
1616 drop(second);
1617
1618 let (_, reopened) = DurableLog::open_file_at(
1619 &second_path,
1620 first_recovery.last_sequence,
1621 first_recovery.last_digest,
1622 )?;
1623 assert_eq!(reopened.transactions.len(), 1);
1624
1625 let wrong_anchor =
1626 DurableLog::open_file_at(&second_path, first_recovery.last_sequence, [9; 32]);
1627 assert!(matches!(
1628 wrong_anchor,
1629 Err(LogError::PreviousDigestMismatch { .. })
1630 ));
1631 Ok(())
1632 }
1633}