Skip to main content

zipatch_rs/apply/
checkpoint.rs

1//! Apply-time progress checkpoints — the data the library emits while a patch
2//! is being written so a consumer can persist enough state to resume a
3//! crashed/interrupted apply later.
4//!
5//! # Shape
6//!
7//! Every checkpoint is a small, owned, serialisable value. The library hands
8//! one to the consumer-installed [`CheckpointSink`] at each natural recovery
9//! boundary the apply driver walks past:
10//!
11//! - **Sequential apply** ([`crate::ZiPatchReader::apply_to`]) — one
12//!   [`Checkpoint::Sequential`] per top-level chunk, plus one per DEFLATE
13//!   block inside the [`crate::chunk::sqpk::SqpkFile`] `AddFile` loop
14//!   (the only chunk type that can carry hundreds of MB of payload). The
15//!   in-flight `AddFile` state rides inside the same record so a resume can
16//!   pick up the file mid-stream rather than restarting the chunk.
17//! - **Indexed apply** ([`crate::index::IndexApplier`]) — one
18//!   [`Checkpoint::Indexed`] per target boundary, plus an interior poll every
19//!   64 regions inside a long target. Same cadence as the existing
20//!   cancellation poll, so the cost of opting in is one extra struct
21//!   construction per 64 regions and one [`CheckpointSink::record`] call.
22//!
23//! Nothing here consumes a checkpoint — the read side is part of the resume
24//! work that follows. This module is the emit side only: the library writes
25//! checkpoints, the consumer persists them, and a later resume entry point
26//! will load the most recent one and pick up from there.
27//!
28//! # Sink ownership
29//!
30//! [`ApplyContext::with_checkpoint_sink`](crate::ApplyContext::with_checkpoint_sink)
31//! installs a [`CheckpointSink`] for the sequential driver; the indexed
32//! driver picks it up off the same [`crate::ApplyContext`]. The default is
33//! [`NoopCheckpointSink`] — consumers that never opt in pay nothing.
34//!
35//! # Serde
36//!
37//! Every type here derives `serde::Serialize` / `serde::Deserialize` under
38//! the existing `serde` feature so consumers can persist checkpoints
39//! alongside the rest of the indexed-apply data model. The format on disk is
40//! the consumer's choice; the crate does not pin one.
41//!
42//! [`ApplyContext`]: crate::ApplyContext
43
44use std::io;
45use std::path::PathBuf;
46
47/// Persistence policy a [`CheckpointSink`] requests from the apply driver.
48///
49/// After a checkpoint is recorded, the apply driver inspects this value to
50/// decide how aggressively to push pending writes through the operating
51/// system. Cheap sinks (in-memory test capture) ask for [`Self::Flush`];
52/// durability-sensitive sinks (persist-to-disk so a crash recovers cleanly)
53/// ask for [`Self::Fsync`] or [`Self::FsyncEveryN`].
54///
55/// The driver calls [`crate::ApplyContext::sync_all`] when the policy
56/// demands it, which both flushes every cached `BufWriter` and calls
57/// `File::sync_all` on the underlying handle. Honouring this on every
58/// record would gut throughput on patches with millions of regions — hence
59/// [`Self::FsyncEveryN`] for the typical "fsync every N records" cadence
60/// downstream consumers want.
61///
62/// **Mid-block checkpoints** — the per-DEFLATE-block emissions inside
63/// [`crate::chunk::sqpk::SqpkFile`] `AddFile` — never flush and never
64/// fsync regardless of policy. Those emissions fire often enough on a
65/// multi-GB file that interleaving a sync syscall would gut throughput.
66/// The driver guarantees the next chunk-boundary checkpoint flushes the
67/// bytes the mid-block run accumulated in its `BufWriter`, so a resume
68/// from an in-flight checkpoint can never miss data that a later
69/// chunk-boundary checkpoint already covered.
70#[non_exhaustive]
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub enum CheckpointPolicy {
74    /// Flush `BufWriter` buffers to the OS only; no `fsync`. Survives a
75    /// process crash but not an OS crash or power loss between checkpoint
76    /// and recovery.
77    Flush,
78    /// Flush and `fsync` every cached file handle on every recorded
79    /// checkpoint. Strongest durability; pay the syscall cost on every
80    /// record.
81    Fsync,
82    /// Flush every record; `fsync` once every `N` records. `N == 0` is
83    /// rejected at sink-installation time
84    /// ([`crate::ApplyContext::with_checkpoint_sink`] and
85    /// [`crate::IndexApplier::with_checkpoint_sink`] both panic) — use
86    /// [`Self::Fsync`] for "fsync every record" instead.
87    ///
88    /// In-flight mid-block checkpoints (the per-DEFLATE-block emissions
89    /// inside [`crate::chunk::sqpk::SqpkFile`] `AddFile`) **never** fsync
90    /// regardless of policy: those emissions are too frequent to interleave
91    /// with a sync syscall, and the apply driver guarantees that a resume
92    /// from an in-flight checkpoint can never miss data that a later
93    /// chunk-boundary checkpoint already covered.
94    FsyncEveryN(u32),
95}
96
97/// Sink for apply-time checkpoints — installed via
98/// [`crate::ApplyContext::with_checkpoint_sink`] on the sequential driver and
99/// inherited by the indexed driver.
100///
101/// Implement on a struct that owns whatever persistence handle the consumer
102/// uses (a file, a channel, a key-value store, an in-memory `Vec` for tests).
103/// A blanket impl is provided for any
104/// `FnMut(&Checkpoint) -> io::Result<()>` closure for the ad-hoc case.
105///
106/// # Recording semantics
107///
108/// [`Self::record`] runs synchronously inline with the apply loop. The
109/// returned `io::Result` is propagated as a [`crate::ZiPatchError::Io`]; a
110/// failing sink aborts the apply at the boundary just past the chunk or
111/// region that produced the checkpoint. The library does not retry. Sinks
112/// should be cheap — buffering through to an `O_APPEND` write on a small
113/// log file is typical; the [`Self::policy`] return then decides whether the
114/// driver also issues a flush/fsync.
115pub trait CheckpointSink {
116    /// Persist or otherwise record `checkpoint`. Called inline with the
117    /// apply loop at each emission site.
118    fn record(&mut self, checkpoint: &Checkpoint) -> io::Result<()>;
119
120    /// Policy the driver should honour after each successful [`Self::record`].
121    /// Default is [`CheckpointPolicy::Flush`] — strongest available without
122    /// the per-record `fsync` cost.
123    fn policy(&self) -> CheckpointPolicy {
124        CheckpointPolicy::Flush
125    }
126}
127
128/// No-op sink used when no consumer is installed.
129///
130/// Public because [`ApplyContext::with_checkpoint_sink`](crate::ApplyContext::with_checkpoint_sink)
131/// is generic and callers occasionally need to name the default.
132#[derive(Debug, Default, Clone, Copy)]
133pub struct NoopCheckpointSink;
134
135impl CheckpointSink for NoopCheckpointSink {
136    fn record(&mut self, _checkpoint: &Checkpoint) -> io::Result<()> {
137        Ok(())
138    }
139}
140
141impl<F> CheckpointSink for F
142where
143    F: FnMut(&Checkpoint) -> io::Result<()>,
144{
145    fn record(&mut self, checkpoint: &Checkpoint) -> io::Result<()> {
146        self(checkpoint)
147    }
148}
149
150/// One apply-time checkpoint, emitted at a natural recovery boundary.
151///
152/// Two variants, one per apply driver. The `schema_version` field on each
153/// inner struct lets a future resume reader refuse to consume a checkpoint
154/// from an incompatible build rather than silently misinterpret its fields.
155#[non_exhaustive]
156#[derive(Debug, Clone, PartialEq, Eq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158pub enum Checkpoint {
159    /// Sequential apply driver checkpoint. Emitted per top-level chunk and
160    /// per DEFLATE block inside a long `SqpkFile::AddFile`.
161    Sequential(SequentialCheckpoint),
162    /// Indexed apply driver checkpoint. Emitted per target and every 64
163    /// regions inside a long target.
164    Indexed(IndexedCheckpoint),
165}
166
167/// Sequential-apply checkpoint payload.
168///
169/// Captures "how far into the patch stream the driver has gotten" using two
170/// independent measures so a resume can pick the right one:
171///
172/// - `next_chunk_index` — the zero-based index of the **next** chunk the
173///   driver is about to apply. Equal to the count of chunks that have been
174///   fully applied.
175/// - `bytes_read` — the cumulative byte offset within the patch stream the
176///   driver has read up to. Equivalent to the [`crate::ChunkEvent::bytes_read`]
177///   field at the same emission point.
178///
179/// `in_flight` is `Some` only between DEFLATE block boundaries inside an
180/// `SqpkFile::AddFile`; per-chunk emissions carry `None`. Resuming a sequential
181/// apply that crashed mid-AddFile picks up at `in_flight.block_idx`, seeking
182/// to `in_flight.bytes_into_target` within the target file before resuming the
183/// chunk's block loop.
184#[non_exhaustive]
185#[derive(Debug, Clone, PartialEq, Eq)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
187pub struct SequentialCheckpoint {
188    /// Layout version of this checkpoint struct. See [`Self::CURRENT_SCHEMA_VERSION`].
189    pub schema_version: u32,
190    /// Index of the next chunk to apply. Equal to the number of chunks that
191    /// have been fully applied as of this checkpoint.
192    pub next_chunk_index: u64,
193    /// Cumulative byte offset within the patch stream the driver has read.
194    ///
195    /// Informational metadata. Resume is positional on `next_chunk_index`,
196    /// not on this counter: the fast-forward re-parses `next_chunk_index`
197    /// chunks and surfaces a `warn!` (but does not error) if the resulting
198    /// `bytes_read` differs from the value recorded here.
199    pub bytes_read: u64,
200    /// Identifier the driver was told to associate with the patch source —
201    /// typically the patch filename. `None` when no identifier was supplied
202    /// via [`crate::ZiPatchReader::with_patch_name`]. Used at resume time
203    /// to detect a checkpoint that was persisted for a different patch.
204    pub patch_name: Option<String>,
205    /// Total byte length of the patch stream, when the driver could measure
206    /// it (i.e. the underlying reader is [`std::io::Seek`]). `None` for the
207    /// sequential `apply_to` path which only requires
208    /// [`std::io::Read`]; populated by `resume_apply_to`. Used together with
209    /// `patch_name` to detect a checkpoint persisted for a patch that has
210    /// since been replaced.
211    ///
212    /// `None` means the recording driver did not know the size (e.g.
213    /// checkpoints captured via [`crate::ZiPatchReader::apply_to`] with a
214    /// `Read`-only source); the resume path will not use it for stale
215    /// detection in that case — the `patch_name` check alone governs.
216    /// Stale-detection mismatch only fires when both the checkpoint and the
217    /// resume side carry a `Some` and the two values disagree.
218    pub patch_size: Option<u64>,
219    /// Mid-chunk state for an in-flight [`crate::chunk::sqpk::SqpkFile`]
220    /// `AddFile`. Present only at per-block emissions; `None` at per-chunk
221    /// emissions.
222    pub in_flight: Option<InFlightAddFile>,
223}
224
225impl SequentialCheckpoint {
226    /// Current schema-version constant for [`SequentialCheckpoint`].
227    pub const CURRENT_SCHEMA_VERSION: u32 = 1;
228
229    /// Construct a [`SequentialCheckpoint`] with the given fields.
230    ///
231    /// Exists because the struct is `#[non_exhaustive]`, which forbids
232    /// external code from using the struct-literal syntax. Consumers
233    /// hand-rolling a synthetic checkpoint (typically in tests, or when
234    /// migrating a persisted checkpoint from an older schema) construct
235    /// one via this method instead.
236    ///
237    /// # Notes
238    ///
239    /// The constructor is intentionally permissive: it accepts any
240    /// combination of fields, including pairings the apply driver itself
241    /// would never emit. Resume re-validates against the patch stream and
242    /// the on-disk state before honouring a checkpoint, so contradictory
243    /// inputs are detected at resume time and either trigger a
244    /// warn-and-restart (stale-detection paths) or surface as a typed
245    /// error — never silent corruption.
246    #[must_use]
247    pub fn new(
248        next_chunk_index: u64,
249        bytes_read: u64,
250        patch_name: Option<String>,
251        patch_size: Option<u64>,
252        in_flight: Option<InFlightAddFile>,
253    ) -> Self {
254        Self {
255            schema_version: Self::CURRENT_SCHEMA_VERSION,
256            next_chunk_index,
257            bytes_read,
258            patch_name,
259            patch_size,
260            in_flight,
261        }
262    }
263
264    /// Return a clone of `self` with `in_flight` overwritten.
265    ///
266    /// Convenience for resume tests / persistence-layer code that needs
267    /// to splice an in-flight payload into an otherwise-untouched
268    /// chunk-boundary checkpoint without re-naming every field.
269    ///
270    /// # Notes
271    ///
272    /// Like [`Self::new`], this is permissive — any
273    /// [`InFlightAddFile`] payload is accepted regardless of whether it
274    /// pairs sensibly with the chunk index or byte offset on `self`. Resume
275    /// re-validates before acting on the in-flight state, so a mismatch
276    /// surfaces as a warn-and-restart rather than silent corruption.
277    #[must_use]
278    pub fn with_in_flight(mut self, in_flight: Option<InFlightAddFile>) -> Self {
279        self.in_flight = in_flight;
280        self
281    }
282}
283
284/// Mid-AddFile state — the DEFLATE block boundary the driver is between.
285///
286/// Resume reads this back, opens `target_path`, seeks to
287/// `file_offset + bytes_into_target`, and re-feeds the chunk's remaining
288/// blocks starting from `block_idx`. The chunk's `path` and `file_offset`
289/// are echoed in full to make the resume self-contained — callers shouldn't
290/// have to cross-reference the original patch stream to interpret the
291/// checkpoint.
292#[non_exhaustive]
293#[derive(Debug, Clone, PartialEq, Eq)]
294#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
295pub struct InFlightAddFile {
296    /// Absolute filesystem path of the target file the `AddFile` writes into.
297    pub target_path: PathBuf,
298    /// The chunk's wire-format `file_offset` — the byte offset within the
299    /// target file at which block 0 starts.
300    pub file_offset: u64,
301    /// Zero-based index of the **next** block to write. Equal to the count
302    /// of blocks already written for this `AddFile`.
303    pub block_idx: u32,
304    /// Total decompressed bytes written into the target file so far for
305    /// this `AddFile`. The current writer position is
306    /// `file_offset + bytes_into_target`.
307    pub bytes_into_target: u64,
308}
309
310impl InFlightAddFile {
311    /// Construct an [`InFlightAddFile`] with the given fields.
312    ///
313    /// Exists because the struct is `#[non_exhaustive]`, which forbids
314    /// external code from using the struct-literal syntax.
315    ///
316    /// # Notes
317    ///
318    /// Permissive by design: any combination of fields is accepted,
319    /// including ones the apply driver would never produce (e.g.
320    /// `block_idx = u32::MAX`, or a `bytes_into_target` that does not
321    /// correspond to any real block boundary). Resume re-validates the
322    /// in-flight state against the patch stream and the on-disk file
323    /// before acting on it; contradictory inputs trigger a warn-and-restart
324    /// path rather than silent corruption.
325    #[must_use]
326    pub fn new(
327        target_path: PathBuf,
328        file_offset: u64,
329        block_idx: u32,
330        bytes_into_target: u64,
331    ) -> Self {
332        Self {
333            target_path,
334            file_offset,
335            block_idx,
336            bytes_into_target,
337        }
338    }
339}
340
341/// Indexed-apply checkpoint payload.
342///
343/// Emitted by the [`crate::index::IndexApplier::execute`] driver at the same
344/// per-target / per-64-regions cadence as the existing cancellation poll.
345///
346/// - `plan_crc32` — identity of the [`crate::index::Plan`] the checkpoint was
347///   produced against, computed via [`crate::index::Plan::crc32`].
348///   [`crate::index::IndexApplier::resume_execute`] re-computes the CRC at
349///   resume time and warns-and-restarts on mismatch (same precedent as the
350///   sequential resume's `patch_name` / `patch_size` check).
351/// - `next_target_idx` — zero-based index of the **next** target the driver
352///   will write into. Equal to the count of fully-written targets.
353/// - `next_region_idx` — zero-based index of the **next** region within
354///   `next_target_idx`'s timeline. Zero at the target boundary; advances by
355///   64 for each mid-target poll.
356/// - `bytes_written` — cumulative bytes written across all targets and
357///   regions completed so far. Mirrors the same counter the indexed driver
358///   carries internally.
359/// - `fs_ops_done` — `true` once every [`crate::index::FilesystemOp`] in
360///   [`crate::index::Plan::fs_ops`] has been applied. A resume that sees
361///   this `true` skips the `fs_ops` pass; otherwise it re-runs every op
362///   (each op is idempotent w.r.t. the install state the prior partial run
363///   would have left behind).
364#[non_exhaustive]
365#[derive(Debug, Clone, PartialEq, Eq)]
366#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
367pub struct IndexedCheckpoint {
368    /// Layout version of this checkpoint struct. See [`Self::CURRENT_SCHEMA_VERSION`].
369    pub schema_version: u32,
370    /// CRC32 of the [`crate::index::Plan`] this checkpoint was produced
371    /// against. See [`crate::index::Plan::crc32`].
372    ///
373    /// `0` is a legitimate CRC32 output — the hash space is uniform over
374    /// `u32` and a real plan can hash to zero. Consumers must represent
375    /// "no checkpoint yet" via `Option<IndexedCheckpoint>` (i.e. pass
376    /// `None` to [`crate::index::IndexApplier::resume_execute`]); a
377    /// sentinel `plan_crc32: 0` would collide with that legitimate output
378    /// and either trigger a spurious warn-and-restart against a plan that
379    /// happens to hash to zero, or silently accept a stale checkpoint
380    /// from a plan that does.
381    pub plan_crc32: u32,
382    /// `true` once every [`crate::index::FilesystemOp`] in
383    /// [`crate::index::Plan::fs_ops`] has been applied.
384    pub fs_ops_done: bool,
385    /// Index of the next target to apply. Equal to the number of targets
386    /// fully written at this checkpoint.
387    pub next_target_idx: u64,
388    /// Index of the next region within `next_target_idx`'s timeline.
389    pub next_region_idx: u64,
390    /// Cumulative bytes written across all targets and regions completed.
391    pub bytes_written: u64,
392}
393
394impl IndexedCheckpoint {
395    /// Current schema-version constant for [`IndexedCheckpoint`].
396    ///
397    /// Bumped to `2` alongside the addition of `plan_crc32` and
398    /// `fs_ops_done`; a `1`-vintage persisted checkpoint will fail
399    /// `check_schema_version`-style validation at resume time.
400    pub const CURRENT_SCHEMA_VERSION: u32 = 2;
401
402    /// Construct an [`IndexedCheckpoint`] with the given fields.
403    ///
404    /// Exists because the struct is `#[non_exhaustive]`, which forbids
405    /// external code from using the struct-literal syntax. Consumers
406    /// hand-rolling a synthetic checkpoint (typically in tests, or when
407    /// migrating a persisted checkpoint from an older schema) construct
408    /// one via this method instead.
409    ///
410    /// # Notes
411    ///
412    /// Permissive by design: any combination of fields is accepted,
413    /// including pairings the indexed driver would never emit (e.g.
414    /// `fs_ops_done: false` with `next_target_idx > 0`).
415    /// [`crate::index::IndexApplier::resume_execute`] re-validates the
416    /// checkpoint's `plan_crc32` against the plan it is handed, and
417    /// re-runs every step that has not been confirmed durable; a
418    /// contradictory checkpoint either matches a re-derivable resume
419    /// state or triggers the warn-and-restart path on CRC mismatch.
420    #[must_use]
421    pub fn new(
422        plan_crc32: u32,
423        fs_ops_done: bool,
424        next_target_idx: u64,
425        next_region_idx: u64,
426        bytes_written: u64,
427    ) -> Self {
428        Self {
429            schema_version: Self::CURRENT_SCHEMA_VERSION,
430            plan_crc32,
431            fs_ops_done,
432            next_target_idx,
433            next_region_idx,
434            bytes_written,
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::sync::Arc;
443    use std::sync::Mutex;
444
445    // --- NoopCheckpointSink ---
446
447    #[test]
448    fn noop_sink_records_succeed_for_every_variant() {
449        let mut sink = NoopCheckpointSink;
450        let seq = Checkpoint::Sequential(SequentialCheckpoint {
451            schema_version: SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
452            next_chunk_index: 3,
453            bytes_read: 1024,
454            patch_name: None,
455            patch_size: None,
456            in_flight: None,
457        });
458        let indexed = Checkpoint::Indexed(IndexedCheckpoint {
459            schema_version: IndexedCheckpoint::CURRENT_SCHEMA_VERSION,
460            plan_crc32: 0xDEAD_BEEF,
461            fs_ops_done: true,
462            next_target_idx: 7,
463            next_region_idx: 128,
464            bytes_written: 65536,
465        });
466
467        sink.record(&seq).expect("Noop must succeed");
468        sink.record(&indexed).expect("Noop must succeed");
469    }
470
471    #[test]
472    fn noop_sink_default_policy_is_flush() {
473        let sink = NoopCheckpointSink;
474        assert_eq!(sink.policy(), CheckpointPolicy::Flush);
475    }
476
477    // --- Closure blanket impl ---
478
479    #[test]
480    fn closure_sink_captures_records_in_order() {
481        let captured: Arc<Mutex<Vec<Checkpoint>>> = Arc::new(Mutex::new(Vec::new()));
482        let captured_clone = captured.clone();
483        let mut sink = move |c: &Checkpoint| -> io::Result<()> {
484            captured_clone.lock().unwrap().push(c.clone());
485            Ok(())
486        };
487
488        let a = Checkpoint::Sequential(SequentialCheckpoint {
489            schema_version: SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
490            next_chunk_index: 1,
491            bytes_read: 32,
492            patch_name: None,
493            patch_size: None,
494            in_flight: None,
495        });
496        let b = Checkpoint::Sequential(SequentialCheckpoint {
497            schema_version: SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
498            next_chunk_index: 2,
499            bytes_read: 64,
500            patch_name: None,
501            patch_size: None,
502            in_flight: None,
503        });
504
505        sink.record(&a).unwrap();
506        sink.record(&b).unwrap();
507
508        let got = captured.lock().unwrap();
509        assert_eq!(got.len(), 2);
510        assert_eq!(got[0], a);
511        assert_eq!(got[1], b);
512    }
513
514    #[test]
515    fn closure_sink_propagates_io_error_unchanged() {
516        let mut sink = |_: &Checkpoint| -> io::Result<()> { Err(io::Error::other("synthetic")) };
517        let c = Checkpoint::Indexed(IndexedCheckpoint {
518            schema_version: IndexedCheckpoint::CURRENT_SCHEMA_VERSION,
519            plan_crc32: 0,
520            fs_ops_done: false,
521            next_target_idx: 0,
522            next_region_idx: 0,
523            bytes_written: 0,
524        });
525        let err = sink.record(&c).unwrap_err();
526        assert_eq!(err.kind(), io::ErrorKind::Other);
527        assert_eq!(err.to_string(), "synthetic");
528    }
529
530    // --- Schema-version constants ---
531
532    #[test]
533    fn schema_version_constants_have_expected_values() {
534        assert_eq!(SequentialCheckpoint::CURRENT_SCHEMA_VERSION, 1);
535        assert_eq!(IndexedCheckpoint::CURRENT_SCHEMA_VERSION, 2);
536    }
537
538    // --- Serde round-trip (only meaningful under the serde feature) ---
539
540    #[cfg(feature = "serde")]
541    #[test]
542    fn bincode_round_trips_sequential_checkpoint_without_in_flight() {
543        let cp = Checkpoint::Sequential(SequentialCheckpoint {
544            schema_version: SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
545            next_chunk_index: 42,
546            bytes_read: 0x1_0000_0000,
547            patch_name: Some("H2017.07.11.0000.0000a.patch".into()),
548            patch_size: Some(0x2_0000_0000),
549            in_flight: None,
550        });
551
552        let cfg = bincode::config::standard();
553        let bytes = bincode::serde::encode_to_vec(&cp, cfg).unwrap();
554        let (decoded, _): (Checkpoint, _) = bincode::serde::decode_from_slice(&bytes, cfg).unwrap();
555        assert_eq!(cp, decoded);
556    }
557
558    #[cfg(feature = "serde")]
559    #[test]
560    fn bincode_round_trips_sequential_checkpoint_with_in_flight() {
561        let cp = Checkpoint::Sequential(SequentialCheckpoint {
562            schema_version: SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
563            next_chunk_index: 9,
564            bytes_read: 1_048_576,
565            patch_name: Some("patch.patch".into()),
566            patch_size: Some(1_048_576 * 4),
567            in_flight: Some(InFlightAddFile {
568                target_path: PathBuf::from("/install/sqpack/ffxiv/000000.win32.dat0"),
569                file_offset: 0,
570                block_idx: 17,
571                bytes_into_target: 17 * 16_384,
572            }),
573        });
574
575        let cfg = bincode::config::standard();
576        let bytes = bincode::serde::encode_to_vec(&cp, cfg).unwrap();
577        let (decoded, _): (Checkpoint, _) = bincode::serde::decode_from_slice(&bytes, cfg).unwrap();
578        assert_eq!(cp, decoded);
579    }
580
581    #[cfg(feature = "serde")]
582    #[test]
583    fn bincode_round_trips_indexed_checkpoint() {
584        let cp = Checkpoint::Indexed(IndexedCheckpoint {
585            schema_version: IndexedCheckpoint::CURRENT_SCHEMA_VERSION,
586            plan_crc32: 0xA5A5_5A5A,
587            fs_ops_done: true,
588            next_target_idx: 12,
589            next_region_idx: 64,
590            bytes_written: 12345,
591        });
592
593        let cfg = bincode::config::standard();
594        let bytes = bincode::serde::encode_to_vec(&cp, cfg).unwrap();
595        let (decoded, _): (Checkpoint, _) = bincode::serde::decode_from_slice(&bytes, cfg).unwrap();
596        assert_eq!(cp, decoded);
597    }
598
599    #[cfg(feature = "serde")]
600    #[test]
601    fn bincode_round_trips_checkpoint_policy_variants() {
602        for policy in [
603            CheckpointPolicy::Flush,
604            CheckpointPolicy::Fsync,
605            CheckpointPolicy::FsyncEveryN(64),
606            CheckpointPolicy::FsyncEveryN(1),
607        ] {
608            let cfg = bincode::config::standard();
609            let bytes = bincode::serde::encode_to_vec(&policy, cfg).unwrap();
610            let (decoded, _): (CheckpointPolicy, _) =
611                bincode::serde::decode_from_slice(&bytes, cfg).unwrap();
612            assert_eq!(policy, decoded);
613        }
614    }
615}