Skip to main content

xet_client/cas_types/
mod.rs

1use core::fmt;
2use std::cmp::{Ordering, min};
3use std::collections::{HashMap, HashSet};
4use std::marker::PhantomData;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8use serde_repr::{Deserialize_repr, Serialize_repr};
9use thiserror::Error;
10use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree};
11
12mod key;
13pub use key::*;
14
15/// Indicates a "session id" that clients can use to group together related requests
16/// (e.g. all requests made to CAS to support a user-triggered upload (xorbs + shards)).
17pub const SESSION_ID_HEADER: &str = "X-Xet-Session-Id";
18/// Request id generated by CAS for a request.
19pub const REQUEST_ID_HEADER: &str = "X-Request-Id";
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct UploadXorbResponse {
23    pub was_inserted: bool,
24}
25
26/// These types are defined to help differentiate the Range<,> type aliases,
27/// so that they don't silently cast to each other without range adjustments.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Copy)]
29pub struct _C;
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Copy)]
31pub struct _F;
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Copy)]
33pub struct _H;
34
35/// Start and exclusive-end range for chunk content
36pub type ChunkRange = Range<u32, _C>;
37/// Start and exclusive-end range for file content
38pub type FileRange = Range<u64, _F>;
39/// Start and inclusive-end range for HTTP range content
40pub type HttpRange = Range<u64, _H>;
41
42impl FileRange {
43    pub fn full() -> Self {
44        Self::new(0, u64::MAX)
45    }
46
47    // consumes self and split the range into a segment of size `segment_size`
48    // and a remainder.
49    pub fn take_segment(self, segment_size: u64) -> (Self, Option<Self>) {
50        let segment = FileRange {
51            start: self.start,
52            end: min(self.end, self.start + segment_size),
53            _marker: PhantomData,
54        };
55
56        let remainder = if segment.end == self.end {
57            None
58        } else {
59            Some(FileRange {
60                start: segment.end,
61                end: self.end,
62                _marker: PhantomData,
63            })
64        };
65
66        (segment, remainder)
67    }
68
69    pub fn length(&self) -> u64 {
70        self.end - self.start
71    }
72}
73
74impl From<HttpRange> for FileRange {
75    fn from(value: HttpRange) -> Self {
76        // right inclusive to right exclusive
77        FileRange::new(value.start, value.end + 1)
78    }
79}
80
81impl HttpRange {
82    pub fn range_header(&self) -> String {
83        format!("bytes={self}")
84    }
85
86    pub fn length(&self) -> u64 {
87        self.end - self.start + 1
88    }
89}
90
91impl From<FileRange> for HttpRange {
92    fn from(value: FileRange) -> Self {
93        // right exclusive to right inclusive
94        HttpRange::new(value.start, value.end - 1)
95    }
96}
97
98// note that the standard PartialOrd/Ord impls will first check `start` then `end`
99#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord, Default, Hash)]
100pub struct Range<Idx, Kind> {
101    pub start: Idx,
102    pub end: Idx,
103    #[serde(skip)]
104    pub _marker: PhantomData<Kind>,
105}
106
107impl<Idx, _C> fmt::Debug for Range<Idx, _C>
108where
109    Idx: fmt::Debug,
110{
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("Range")
113            .field("start", &self.start)
114            .field("end", &self.end)
115            .finish()
116    }
117}
118
119impl<Idx, Kind> Range<Idx, Kind> {
120    pub fn new(start: Idx, end: Idx) -> Self {
121        Self {
122            start,
123            end,
124            _marker: PhantomData,
125        }
126    }
127}
128
129impl<T: Copy, Kind: Copy> Copy for Range<T, Kind> {}
130
131impl<Idx: fmt::Display, Kind> fmt::Display for Range<Idx, Kind> {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(f, "{}-{}", self.start, self.end)
134    }
135}
136
137#[derive(Error, Debug)]
138pub enum RangeParseError<Idx: std::str::FromStr> {
139    #[error("Invalid format, expect [start]-[end]")]
140    InvalidFormat,
141    #[error("Incorrect number: {0}")]
142    ParseError(Idx::Err),
143}
144
145impl<Idx: FromStr, Kind> TryFrom<&str> for Range<Idx, Kind> {
146    type Error = RangeParseError<Idx>;
147
148    fn try_from(value: &str) -> Result<Self, Self::Error> {
149        let parts: Vec<&str> = value.splitn(2, '-').collect();
150
151        if parts.len() != 2 {
152            return Err(RangeParseError::InvalidFormat);
153        }
154
155        let start = parts[0].parse::<Idx>().map_err(RangeParseError::ParseError)?;
156        let end = parts[1].parse::<Idx>().map_err(RangeParseError::ParseError)?;
157
158        Ok(Range {
159            start,
160            end,
161            _marker: PhantomData,
162        })
163    }
164}
165
166impl<Idx: FromStr, Kind> FromStr for Range<Idx, Kind> {
167    type Err = RangeParseError<Idx>;
168
169    fn from_str(value: &str) -> Result<Self, Self::Err> {
170        Self::try_from(value)
171    }
172}
173
174/// Describes a portion of a reconstructed file, namely the xorb and
175/// a range of chunks within that xorb that are needed.
176///
177/// unpacked_length is used for validation, the result data of this term
178/// should have that field's value as its length
179#[derive(Debug, Serialize, Deserialize, Clone)]
180pub struct XorbReconstructionTerm {
181    pub hash: HexMerkleHash,
182    // the resulting data from deserializing the range in this term
183    // should have a length equal to `unpacked_length`
184    pub unpacked_length: u32,
185    // chunk index start and end in a xorb
186    pub range: ChunkRange,
187}
188
189/// To use a XorbReconstructionFetchInfo fetch info all that's needed
190/// is an http get request on the url with the Range header directly
191/// formed from the url_range values.
192///
193/// the `range` key describes the chunk range within the xorb that the
194/// url is used to fetch
195#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
196pub struct XorbReconstructionFetchInfo {
197    // chunk index start and end in a xorb
198    pub range: ChunkRange,
199    pub url: String,
200    // byte index start and end in a xorb, used exclusively for Range header
201    pub url_range: HttpRange,
202}
203
204#[derive(Debug, Serialize, Deserialize, Clone)]
205pub struct QueryReconstructionResponse {
206    // For range query [a, b) into a file content, the location
207    // of "a" into the first range.
208    pub offset_into_first_range: u64,
209    // Series of terms describing a xorb hash and chunk range to be retrieved
210    // to reconstruct the file
211    pub terms: Vec<XorbReconstructionTerm>,
212    // information to fetch xorb ranges to reconstruct the file
213    // each key is a hash that is present in the `terms` field reconstruction
214    // terms, the values are information we will need to fetch ranges from
215    // each xorb needed to reconstruct the file
216    pub fetch_info: HashMap<HexMerkleHash, Vec<XorbReconstructionFetchInfo>>,
217}
218
219/// V2 reconstruction response - optimized for multi-range fetching.
220/// May provide fewer signed URLs per xorb by combining multiple byte ranges
221/// into a single URL where possible.
222#[derive(Debug, Serialize, Deserialize, Clone)]
223pub struct QueryReconstructionResponseV2 {
224    pub offset_into_first_range: u64,
225    pub terms: Vec<XorbReconstructionTerm>,
226    /// Map from xorb hash -> list of multi-range fetch entries.
227    /// Typically 1 entry per xorb. Multiple entries when the URL length limit
228    /// (~8 KiB, roughly ~500 ranges) forces a split.
229    pub xorbs: HashMap<HexMerkleHash, Vec<XorbMultiRangeFetch>>,
230}
231
232/// A signed multi-range fetch: one URL covering a subset of ranges for a xorb.
233#[derive(Debug, Serialize, Deserialize, Clone)]
234pub struct XorbMultiRangeFetch {
235    /// Signed URL with all byte ranges encoded. Client must send exactly the
236    /// signed range value as the Range header.
237    pub url: String,
238    /// Byte ranges covered by this URL, sorted by chunk start.
239    pub ranges: Vec<XorbRangeDescriptor>,
240}
241
242/// A single byte range within a xorb, mapping chunk indices to physical bytes.
243#[derive(Debug, Serialize, Deserialize, Clone)]
244pub struct XorbRangeDescriptor {
245    /// Chunk index range [start, end) within the xorb.
246    pub chunks: ChunkRange,
247    /// Physical byte range [start, end] (inclusive end) for the HTTP Range header.
248    pub bytes: HttpRange,
249}
250
251impl From<QueryReconstructionResponse> for QueryReconstructionResponseV2 {
252    fn from(v1: QueryReconstructionResponse) -> Self {
253        let xorbs = v1
254            .fetch_info
255            .into_iter()
256            .map(|(hash, fetch_infos)| {
257                let fetch = fetch_infos
258                    .into_iter()
259                    .map(|info| XorbMultiRangeFetch {
260                        url: info.url,
261                        ranges: vec![XorbRangeDescriptor {
262                            chunks: info.range,
263                            bytes: info.url_range,
264                        }],
265                    })
266                    .collect();
267                (hash, fetch)
268            })
269            .collect();
270
271        QueryReconstructionResponseV2 {
272            offset_into_first_range: v1.offset_into_first_range,
273            terms: v1.terms,
274            xorbs,
275        }
276    }
277}
278
279// Request json body type representation for the POST /reconstructions endpoint
280// to get the reconstruction for multiple files at a time.
281// listing of non-duplicate (enforced by HashSet) keys (file ids) to get reconstructions for
282pub type BatchQueryReconstructionRequest = HashSet<HexKey>;
283
284// Response type for querying reconstruction for a batch of files
285#[derive(Debug, Serialize, Deserialize, Clone)]
286pub struct BatchQueryReconstructionResponse {
287    // Map of FileID to series of terms describing a xorb hash and chunk range to be retrieved
288    // to reconstruct the file
289    pub files: HashMap<HexMerkleHash, Vec<XorbReconstructionTerm>>,
290    // information to fetch xorb ranges to reconstruct the file
291    // each key is a hash that is present in the `terms` field reconstruction
292    // terms, the values are information we will need to fetch ranges from
293    // each xorb needed to reconstruct the file
294    pub fetch_info: HashMap<HexMerkleHash, Vec<XorbReconstructionFetchInfo>>,
295}
296
297#[derive(Debug, Serialize_repr, Deserialize_repr, Clone, Copy, PartialEq)]
298#[repr(u8)]
299pub enum UploadShardResponseType {
300    Exists = 0,
301    SyncPerformed = 1,
302}
303
304#[derive(Debug, Serialize, Deserialize, Clone)]
305pub struct UploadShardResponse {
306    pub result: UploadShardResponseType,
307}
308
309/// Sub-stage of the durable-write (commit) phase, so a stalled `committing` stream
310/// identifies whether S3 or DynamoDB is the holdup.
311#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
312#[serde(rename_all = "snake_case")]
313pub enum CommitStage {
314    /// Uploading the shard object to S3.
315    Uploading = 0,
316    /// Registering the shard in DynamoDB (file ids, global dedup, shard list).
317    Syncing = 1,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321#[serde(tag = "type", rename_all = "snake_case")]
322pub enum ShardUploadEvent {
323    /// Verifying the uploaded shard against xorb metadata (the long phase). `verified`
324    /// counts completed verification tasks and `total` the number spawned so far; while the
325    /// shard is still being received `total` grows, so treat the ratio as live, not final.
326    Validating { verified: u64, total: u64 },
327    /// Durably writing the shard; `stage` says which sub-step is running.
328    Committing { stage: CommitStage },
329    /// Terminal success frame.
330    Result,
331    /// Terminal failure frame. The HTTP status is already `200 OK` by the time the
332    /// stream starts, so clients MUST treat this frame as the error signal.
333    Error {
334        message: String,
335        /// When true, the client should retry the upload (transient server/network fault).
336        /// Defaults to `false` when omitted so older/partial error frames still deserialize.
337        #[serde(default)]
338        retryable: bool,
339    },
340    /// Catch-all for unknown future `type` values so older clients keep reading the stream.
341    #[serde(other)]
342    Unknown,
343}
344
345/// Orders by pipeline progression, not field value: lets `precede` detect whether a newly
346/// received frame is stale/out-of-order relative to the last one recorded for a shard.
347/// Deliberately partial: `Error`, `Unknown`, and same-variant pairs are incomparable.
348/// `<`/`>` work too since they're derived from this impl, but `precede` names the intent.
349impl PartialOrd for ShardUploadEvent {
350    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
351        match self {
352            Self::Validating { .. } => match other {
353                Self::Validating { .. } => None,
354                Self::Error { .. } | Self::Unknown => None,
355                _ => Some(Ordering::Less),
356            },
357            Self::Committing { stage } => match other {
358                Self::Validating { .. } => Some(Ordering::Greater),
359                Self::Committing { stage: other_stage } => Some(stage.cmp(other_stage)),
360                Self::Result => Some(Ordering::Less),
361                Self::Error { .. } | Self::Unknown => None,
362            },
363            Self::Result => match other {
364                Self::Result => Some(Ordering::Equal),
365                Self::Error { .. } | Self::Unknown => None,
366                _ => Some(Ordering::Greater),
367            },
368            Self::Error { .. } | Self::Unknown => None,
369        }
370    }
371}
372
373impl ShardUploadEvent {
374    pub fn precede(&self, other: &Self) -> bool {
375        matches!(self.partial_cmp(other), Some(Ordering::Less))
376    }
377}
378
379#[derive(Debug, Serialize, Deserialize, Clone)]
380pub struct QueryChunkResponse {
381    pub shard: MerkleHash,
382}
383
384/// HTTP header carrying the dirty byte ranges to feed to `GET /v2/file-chunk-hashes/{file_id}`.
385///
386/// Distinct from the standard `Range` header (which scopes the response body): this header tags
387/// regions that the client intends to re-chunk, and the response covers the whole file (windows +
388/// gap subtrees). Value uses the same `bytes=A-B,C-D` syntax as `Range`.
389pub const X_RANGE_DIRTY_HEADER: &str = "X-Range-Dirty";
390
391/// One chunk-aligned dirty window of a file, returned by `GET /v2/file-chunk-hashes/{file_id}`.
392///
393/// `dirty_byte_range` is `[start, end)` and is expanded outward to the chunk boundaries that
394/// fully contain the requested dirty range, so the client must re-chunk the entire span.
395#[derive(Debug, Serialize, Deserialize, Clone)]
396#[serde(rename_all = "camelCase")]
397pub struct ChunkWindow {
398    pub dirty_byte_range: [u64; 2],
399}
400
401/// Response shape for `GET /v2/file-chunk-hashes/{file_id}`.
402///
403/// Contains `windows.len()` dirty windows interleaved with `windows.len() + 1` opaque
404/// `MerkleHashSubtree` summaries for the surrounding gaps. To reconstruct the new file hash,
405/// merge `[hash_ranges[0], window0_subtree, hash_ranges[1], window1_subtree, ..., hash_ranges[N]]`
406/// using `MerkleHashSubtree::merge`. Per-chunk hashes are never transferred.
407#[derive(Debug, Serialize, Deserialize, Clone)]
408#[serde(rename_all = "camelCase")]
409pub struct FileChunkHashesResponse {
410    pub total_chunks: u64,
411    pub file_size: u64,
412    pub windows: Vec<ChunkWindow>,
413    pub hash_ranges: Vec<Option<MerkleHashSubtree>>,
414    /// One range hash per **stable original segment** (= a segment that lies in a gap
415    /// between dirty windows or before/after them, in segment order). Wraps each into a
416    /// `FileVerificationEntry` to populate the composed shard's verification section.
417    pub gap_verification: Vec<HexMerkleHash>,
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_file_range_segment() {
426        let file_range = FileRange::full();
427        let segment_size = 824820;
428
429        let (segment, remainder) = file_range.take_segment(segment_size);
430
431        assert_eq!(segment, FileRange::new(0, segment_size));
432        assert_eq!(remainder, Some(FileRange::new(segment_size, u64::MAX)));
433    }
434
435    #[test]
436    fn test_file_range_segment_no_remainder() {
437        let file_range = FileRange::new(50, 100);
438        let segment_size = 40;
439
440        let (s1, remainder) = file_range.take_segment(segment_size);
441
442        assert_eq!(s1, FileRange::new(50, 90));
443        assert_eq!(remainder, Some(FileRange::new(90, 100)));
444
445        let (s2, remainder) = remainder.unwrap().take_segment(segment_size);
446
447        assert_eq!(s2, FileRange::new(90, 100));
448        assert_eq!(remainder, None);
449    }
450
451    #[test]
452    fn test_http_range_type_casting() {
453        assert_eq!(HttpRange::from(FileRange::new(0, 10)), HttpRange::new(0, 9));
454
455        assert_eq!(FileRange::from(HttpRange::new(0, 10)), FileRange::new(0, 11));
456    }
457
458    #[test]
459    fn test_shard_upload_event_validating_json_roundtrip() {
460        let event = ShardUploadEvent::Validating { verified: 3, total: 7 };
461        let json = serde_json::to_string(&event).unwrap();
462        assert_eq!(json, r#"{"type":"validating","verified":3,"total":7}"#);
463        assert_eq!(serde_json::from_str::<ShardUploadEvent>(&json).unwrap(), event);
464    }
465
466    #[test]
467    fn test_shard_upload_event_committing_json_roundtrip() {
468        for (stage, tag) in [(CommitStage::Uploading, "uploading"), (CommitStage::Syncing, "syncing")] {
469            let event = ShardUploadEvent::Committing { stage };
470            let json = serde_json::to_string(&event).unwrap();
471            assert_eq!(json, format!(r#"{{"type":"committing","stage":"{tag}"}}"#));
472            assert_eq!(serde_json::from_str::<ShardUploadEvent>(&json).unwrap(), event);
473        }
474    }
475
476    #[test]
477    fn test_shard_upload_event_result_json_roundtrip() {
478        let event = ShardUploadEvent::Result;
479        let json = serde_json::to_string(&event).unwrap();
480        assert_eq!(json, r#"{"type":"result"}"#);
481        assert_eq!(serde_json::from_str::<ShardUploadEvent>(&json).unwrap(), event);
482    }
483
484    #[test]
485    fn test_shard_upload_event_error_json_roundtrip() {
486        let event = ShardUploadEvent::Error {
487            message: "boom".to_string(),
488            retryable: false,
489        };
490        let json = serde_json::to_string(&event).unwrap();
491        assert_eq!(json, r#"{"type":"error","message":"boom","retryable":false}"#);
492        assert_eq!(serde_json::from_str::<ShardUploadEvent>(&json).unwrap(), event);
493
494        let retryable = ShardUploadEvent::Error {
495            message: "transient".to_string(),
496            retryable: true,
497        };
498        let json = serde_json::to_string(&retryable).unwrap();
499        assert_eq!(json, r#"{"type":"error","message":"transient","retryable":true}"#);
500        assert_eq!(serde_json::from_str::<ShardUploadEvent>(&json).unwrap(), retryable);
501
502        // Older/partial frames may omit `retryable`; treat as non-retryable terminal error.
503        let omitted = serde_json::from_str::<ShardUploadEvent>(r#"{"type":"error","message":"boom"}"#).unwrap();
504        assert_eq!(
505            omitted,
506            ShardUploadEvent::Error {
507                message: "boom".to_string(),
508                retryable: false,
509            }
510        );
511    }
512
513    #[test]
514    fn test_shard_upload_event_partial_cmp_progression_matrix() {
515        // Rows/columns follow the same order: validating, committing(uploading),
516        // committing(syncing), result, error.
517        let cases = [
518            ShardUploadEvent::Validating { verified: 1, total: 2 },
519            ShardUploadEvent::Committing {
520                stage: CommitStage::Uploading,
521            },
522            ShardUploadEvent::Committing {
523                stage: CommitStage::Syncing,
524            },
525            ShardUploadEvent::Result,
526            ShardUploadEvent::Error {
527                message: "boom".to_string(),
528                retryable: false,
529            },
530        ];
531        let labels = [
532            "validating",
533            "committing_uploading",
534            "committing_syncing",
535            "result",
536            "error",
537        ];
538
539        #[rustfmt::skip]
540        let expected: [[Option<Ordering>; 5]; 5] = [
541            /* validating           */ [None,             Some(Ordering::Less),    Some(Ordering::Less),    Some(Ordering::Less),    None],
542            /* committing_uploading */ [Some(Ordering::Greater), Some(Ordering::Equal),   Some(Ordering::Less),    Some(Ordering::Less),    None],
543            /* committing_syncing   */ [Some(Ordering::Greater), Some(Ordering::Greater), Some(Ordering::Equal),   Some(Ordering::Less),    None],
544            /* result               */ [Some(Ordering::Greater), Some(Ordering::Greater), Some(Ordering::Greater), Some(Ordering::Equal),   None],
545            /* error                */ [None,             None,             None,             None,             None],
546        ];
547
548        for (i, a) in cases.iter().enumerate() {
549            for (j, b) in cases.iter().enumerate() {
550                assert_eq!(
551                    a.partial_cmp(b),
552                    expected[i][j],
553                    "{}.partial_cmp({}) should be {:?}",
554                    labels[i],
555                    labels[j],
556                    expected[i][j]
557                );
558                // `precede` (used by `ShardUploadProgress::update` to decide whether a new
559                // event represents forward progress) must agree with a strict "Less" here.
560                assert_eq!(
561                    a.precede(b),
562                    matches!(expected[i][j], Some(Ordering::Less)),
563                    "{}.precede({}) disagrees with its partial_cmp result",
564                    labels[i],
565                    labels[j]
566                );
567            }
568        }
569    }
570
571    #[test]
572    fn test_shard_upload_event_partial_cmp_ignores_payload_within_same_variant() {
573        // Two `Result` events compare as `Equal` (unit variants are identical).
574        let a = ShardUploadEvent::Result;
575        let b = ShardUploadEvent::Result;
576        assert_eq!(a, b);
577        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
578        assert!(!a.precede(&b));
579
580        // Two `Validating` events with different counts are incomparable (`None`), not
581        // ordered by their counts: `ShardUploadProgress::update` relies on `saturating_sub`
582        // over the raw fields for monotonic progress tracking, not on `PartialOrd` here.
583        let low = ShardUploadEvent::Validating { verified: 1, total: 2 };
584        let high = ShardUploadEvent::Validating { verified: 9, total: 9 };
585        assert_eq!(low.partial_cmp(&high), None);
586        assert!(!low.precede(&high));
587        assert!(!high.precede(&low));
588
589        // `Error` / `Unknown` never precede anything, and nothing precedes them.
590        let err_a = ShardUploadEvent::Error {
591            message: "a".to_string(),
592            retryable: false,
593        };
594        let err_b = ShardUploadEvent::Error {
595            message: "b".to_string(),
596            retryable: true,
597        };
598        assert_eq!(err_a.partial_cmp(&err_b), None);
599        assert!(!err_a.precede(&err_b));
600        assert!(!err_b.precede(&err_a));
601
602        assert_eq!(ShardUploadEvent::Unknown.partial_cmp(&ShardUploadEvent::Result), None);
603        assert!(!ShardUploadEvent::Unknown.precede(&ShardUploadEvent::Result));
604        assert!(!ShardUploadEvent::Result.precede(&ShardUploadEvent::Unknown));
605    }
606
607    #[test]
608    fn test_shard_upload_event_unknown_type_deserializes() {
609        let event: ShardUploadEvent = serde_json::from_str(r#"{"type":"heartbeat"}"#).unwrap();
610        assert_eq!(event, ShardUploadEvent::Unknown);
611
612        // Extra fields on an unknown type are fine; the catch-all only keys off `type`.
613        let event: ShardUploadEvent = serde_json::from_str(r#"{"type":"future_stage","detail":{"n":1}}"#).unwrap();
614        assert_eq!(event, ShardUploadEvent::Unknown);
615    }
616
617    #[test]
618    fn test_shard_upload_event_unknown_is_incomparable() {
619        let known = [
620            ShardUploadEvent::Validating { verified: 1, total: 2 },
621            ShardUploadEvent::Committing {
622                stage: CommitStage::Uploading,
623            },
624            ShardUploadEvent::Committing {
625                stage: CommitStage::Syncing,
626            },
627            ShardUploadEvent::Result,
628            ShardUploadEvent::Error {
629                message: "boom".to_string(),
630                retryable: false,
631            },
632            ShardUploadEvent::Unknown,
633        ];
634
635        for other in &known {
636            assert_eq!(ShardUploadEvent::Unknown.partial_cmp(other), None);
637            assert_eq!(other.partial_cmp(&ShardUploadEvent::Unknown), None);
638            assert!(!ShardUploadEvent::Unknown.precede(other));
639            assert!(!other.precede(&ShardUploadEvent::Unknown));
640        }
641    }
642
643    #[test]
644    fn test_shard_upload_event_known_variant_ignores_extra_fields() {
645        // Extra *fields* on a known variant are ignored; only an unknown `type` maps to Unknown.
646        let event: ShardUploadEvent =
647            serde_json::from_str(r#"{"type":"validating","verified":1,"total":2,"extra":true}"#).unwrap();
648        assert_eq!(event, ShardUploadEvent::Validating { verified: 1, total: 2 });
649    }
650}