Skip to main content

hyphae_storage/log/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3mod 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/// Failure while opening, validating, or appending to a durable log.
31#[derive(Debug, Error)]
32pub enum LogError {
33    /// A filesystem operation failed.
34    #[error(transparent)]
35    Io(#[from] io::Error),
36
37    /// A frame does not start with the Hyphae magic bytes.
38    #[error("invalid frame magic at byte offset {offset}")]
39    BadMagic {
40        /// Frame offset.
41        offset: u64,
42    },
43
44    /// A frame uses a disk version this binary cannot decode.
45    #[error(
46        "unsupported log version {found} at byte offset {offset}; supported version is {supported}"
47    )]
48    UnsupportedVersion {
49        /// Frame offset.
50        offset: u64,
51        /// Version found on disk.
52        found: u16,
53        /// Version understood by this binary.
54        supported: u16,
55    },
56
57    /// A frame kind is not part of this format version.
58    #[error("unknown frame kind {kind} at byte offset {offset}")]
59    UnknownFrameKind {
60        /// Frame offset.
61        offset: u64,
62        /// Raw kind byte.
63        kind: u8,
64    },
65
66    /// Reserved frame flags are nonzero.
67    #[error("unsupported frame flags {flags:#04x} at byte offset {offset}")]
68    UnsupportedFlags {
69        /// Frame offset.
70        offset: u64,
71        /// Raw flag byte.
72        flags: u8,
73    },
74
75    /// A payload exceeds the per-frame allocation limit.
76    #[error("frame payload is {length} bytes; maximum is {maximum}")]
77    PayloadTooLarge {
78        /// Requested or decoded length.
79        length: usize,
80        /// Configured maximum.
81        maximum: usize,
82    },
83
84    /// A frame sequence is not exactly the previous sequence plus one.
85    #[error("invalid sequence at byte offset {offset}: expected {expected}, found {found}")]
86    InvalidSequence {
87        /// Frame offset.
88        offset: u64,
89        /// Expected sequence.
90        expected: u64,
91        /// Sequence found.
92        found: u64,
93    },
94
95    /// The digest chain does not connect to the prior frame.
96    #[error("previous-frame digest mismatch at sequence {sequence}")]
97    PreviousDigestMismatch {
98        /// Invalid frame sequence.
99        sequence: u64,
100    },
101
102    /// The CRC32C integrity check failed.
103    #[error("CRC32C mismatch at sequence {sequence}")]
104    ChecksumMismatch {
105        /// Invalid frame sequence.
106        sequence: u64,
107    },
108
109    /// The BLAKE3 frame digest failed.
110    #[error("BLAKE3 digest mismatch at sequence {sequence}")]
111    DigestMismatch {
112        /// Invalid frame sequence.
113        sequence: u64,
114    },
115
116    /// A transaction descriptor has the wrong length or contents.
117    #[error("malformed transaction descriptor at sequence {sequence}")]
118    MalformedTransaction {
119        /// Invalid frame sequence.
120        sequence: u64,
121    },
122
123    /// An operation or commit appeared without its matching begin frame.
124    #[error("{kind} frame at sequence {sequence} has no matching transaction begin")]
125    TransactionBoundary {
126        /// Frame kind being validated.
127        kind: &'static str,
128        /// Invalid frame sequence.
129        sequence: u64,
130    },
131
132    /// The committed operation count or digest differs from its descriptor.
133    #[error("transaction content mismatch at commit sequence {sequence}")]
134    TransactionContentMismatch {
135        /// Invalid commit sequence.
136        sequence: u64,
137    },
138
139    /// A transaction identifier was reused for different contents.
140    #[error(
141        "transaction identifier {transaction_id} was already committed with different contents"
142    )]
143    IdempotencyConflict {
144        /// Reused identifier.
145        transaction_id: Uuid,
146    },
147
148    /// A transaction cannot be committed without at least one operation.
149    #[error("a transaction must contain at least one operation")]
150    EmptyTransaction,
151
152    /// The operation count cannot be represented by the disk format.
153    #[error("transaction has too many operations")]
154    TooManyOperations,
155
156    /// The sequence space has been exhausted.
157    #[error("log sequence space is exhausted")]
158    SequenceExhausted,
159
160    /// A segment base sequence and digest do not form a canonical anchor.
161    #[error("invalid log segment anchor")]
162    InvalidAnchor,
163
164    /// The writer observed an uncertain I/O result and must be reopened.
165    #[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/// Durable identity of a committed transaction.
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177pub struct CommitReceipt {
178    /// Caller-supplied idempotency key.
179    pub transaction_id: Uuid,
180    /// Sequence of the durable commit frame.
181    pub commit_sequence: u64,
182    /// Digest of the commit frame and its chain prefix.
183    pub commit_digest: [u8; 32],
184    /// Digest of the canonical operation list.
185    pub transaction_digest: [u8; 32],
186}
187
188/// Result of an idempotent append request.
189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub enum AppendOutcome {
191    /// New frames were written and synchronized.
192    Committed(CommitReceipt),
193    /// The exact transaction was already durable; no frames were appended.
194    Existing(CommitReceipt),
195}
196
197/// A committed transaction reconstructed from the verified log.
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct RecoveredTransaction {
200    /// Durable commit identity.
201    pub receipt: CommitReceipt,
202    /// Opaque operation payloads in original order.
203    pub operations: Vec<Vec<u8>>,
204}
205
206/// Evidence produced while opening and validating a segment.
207#[derive(Clone, Debug, Default, Eq, PartialEq)]
208pub struct RecoveryReport {
209    /// Sequence immediately preceding this segment, or zero for the first segment.
210    pub base_sequence: u64,
211    /// Digest immediately preceding this segment, or all zero for the first segment.
212    pub base_digest: [u8; 32],
213    /// Unique committed transactions in commit order.
214    pub transactions: Vec<RecoveredTransaction>,
215    /// Complete but uncommitted transaction attempts ignored during recovery.
216    pub ignored_uncommitted_transactions: u64,
217    /// Repeated commits with the same id and content, deduplicated during replay.
218    pub duplicate_commits: u64,
219    /// Incomplete bytes removed from the physical tail.
220    pub truncated_tail_bytes: u64,
221    /// Length after any incomplete tail was removed.
222    pub valid_bytes: u64,
223    /// Last complete frame sequence, including uncommitted attempts.
224    pub last_sequence: u64,
225    /// Digest of the last complete frame.
226    pub last_digest: [u8; 32],
227}
228
229/// A newly opened writer together with its recovery evidence.
230#[derive(Debug)]
231pub struct OpenedLog<'directory> {
232    /// Exclusive writer handle.
233    pub log: DurableLog,
234    /// Verified replay and tail-repair report.
235    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/// Append-only transaction log with synchronous commit durability.
250#[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    /// Opens, verifies, and repairs only an incomplete physical tail.
273    ///
274    /// Full frames with invalid checksums, digests, versions, sequences, or
275    /// transaction boundaries are rejected as corruption and never truncated.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error for I/O failures or any complete invalid frame.
280    #[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    /// Appends and synchronizes one atomic transaction.
410    ///
411    /// Retrying the same identifier and operation bytes returns the original
412    /// receipt without appending. Reusing it with different bytes fails.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error for invalid bounds, conflicting idempotency keys, or
417    /// filesystem failures. Any append/sync failure poisons the writer so the
418    /// caller must reopen and recover before another write.
419    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                &current.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}