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