Skip to main content

nodedb_lite/sync/array/inbound/
snapshot.rs

1//! Snapshot header and chunk handlers — buffer chunks until complete, then
2//! assemble and apply the contained ops.
3
4use std::collections::hash_map::Entry;
5
6use nodedb_array::sync::apply::ApplyOutcome;
7use nodedb_array::sync::hlc::Hlc;
8use nodedb_array::sync::op_codec;
9use nodedb_array::sync::snapshot::{SnapshotChunk, SnapshotHeader, assemble_chunks};
10use nodedb_types::sync::wire::array::{ArraySnapshotChunkMsg, ArraySnapshotMsg};
11
12use crate::error::LiteError;
13use crate::storage::engine::StorageEngine;
14
15use super::dispatcher::{ArrayInbound, SnapshotAssembly};
16use super::outcome::InboundOutcome;
17
18/// Origin's catch-up server emits chunks of at most 256 KiB.
19const MAX_CHUNK_BYTES: usize = 256 * 1024;
20
21/// Upper bound on the chunk count one snapshot can declare. Matches the bound
22/// Origin enforces on snapshots it receives.
23const MAX_CHUNKS_PER_SNAPSHOT: u32 = 4_096;
24
25impl<S: StorageEngine> ArrayInbound<S> {
26    /// Buffer an incoming snapshot header.
27    ///
28    /// Must arrive before any [`Self::handle_snapshot_chunk`] calls for the
29    /// same `(array, snapshot_hlc)`; Origin sends the header first on the
30    /// same ordered stream. A repeated header restarts the assembly, so a
31    /// transfer Origin re-sends after a reconnect never mixes with stale
32    /// chunks. Returns `SnapshotPartial { received: 0, total }`.
33    pub fn handle_snapshot_header(
34        &self,
35        msg: &ArraySnapshotMsg,
36    ) -> Result<InboundOutcome, LiteError> {
37        let header: SnapshotHeader =
38            zerompk::from_msgpack(&msg.header_payload).map_err(|e| LiteError::Storage {
39                detail: format!("handle_snapshot_header decode: {e}"),
40            })?;
41
42        if header.array != msg.array {
43            return Err(LiteError::Sync {
44                detail: format!(
45                    "snapshot header names array '{}' but the frame names '{}'",
46                    header.array, msg.array
47                ),
48            });
49        }
50        let total = header.total_chunks;
51        check_total_chunks(&msg.array, total)?;
52
53        let key = (msg.array.clone(), header.snapshot_hlc.to_bytes());
54        let mut snapshots = self.snapshots.lock().map_err(|_| LiteError::LockPoisoned)?;
55        snapshots.insert(key, SnapshotAssembly::new(header));
56
57        Ok(InboundOutcome::SnapshotPartial { received: 0, total })
58    }
59
60    /// Buffer a snapshot chunk and, when all chunks have arrived, assemble and
61    /// apply the snapshot.
62    ///
63    /// Returns [`InboundOutcome::SnapshotPartial`] until the last chunk
64    /// arrives, then returns [`InboundOutcome::SnapshotApplied`] after all
65    /// ops in the assembled snapshot have been applied. A chunk that does not
66    /// match its header is an error and drops the assembly. A complete
67    /// assembly leaves the buffer before it is decoded, so a decode or apply
68    /// error never leaves chunks behind.
69    pub fn handle_snapshot_chunk(
70        &self,
71        msg: &ArraySnapshotChunkMsg,
72    ) -> Result<InboundOutcome, LiteError> {
73        check_total_chunks(&msg.array, msg.total_chunks)?;
74        if msg.chunk_index >= msg.total_chunks {
75            return Err(LiteError::Sync {
76                detail: format!(
77                    "snapshot chunk {} for array '{}' is outside total_chunks {}",
78                    msg.chunk_index, msg.array, msg.total_chunks
79                ),
80            });
81        }
82        if msg.payload.len() > MAX_CHUNK_BYTES {
83            return Err(LiteError::Sync {
84                detail: format!(
85                    "snapshot chunk for array '{}' carries {} bytes; the limit is {MAX_CHUNK_BYTES}",
86                    msg.array,
87                    msg.payload.len()
88                ),
89            });
90        }
91
92        let key = (msg.array.clone(), msg.snapshot_hlc_bytes);
93        let (header, mut chunks) = {
94            let mut snapshots = self.snapshots.lock().map_err(|_| LiteError::LockPoisoned)?;
95            let Entry::Occupied(mut slot) = snapshots.entry(key) else {
96                return Err(LiteError::Sync {
97                    detail: format!(
98                        "snapshot chunk for array '{}' arrived before its header",
99                        msg.array
100                    ),
101                });
102            };
103            let total = slot.get().header.total_chunks;
104            if total != msg.total_chunks {
105                slot.remove();
106                return Err(LiteError::Sync {
107                    detail: format!(
108                        "snapshot chunk for array '{}' declares {} chunks; its header declares {total}",
109                        msg.array, msg.total_chunks
110                    ),
111                });
112            }
113
114            let assembly = slot.get_mut();
115            assembly.chunks.insert(
116                msg.chunk_index,
117                SnapshotChunk {
118                    array: msg.array.clone(),
119                    chunk_index: msg.chunk_index,
120                    total_chunks: msg.total_chunks,
121                    payload: msg.payload.clone(),
122                    snapshot_hlc: Hlc::from_bytes(&msg.snapshot_hlc_bytes),
123                },
124            );
125
126            let received = assembly.chunks.len() as u32;
127            if received < total {
128                return Ok(InboundOutcome::SnapshotPartial { received, total });
129            }
130            let complete = slot.remove();
131            (
132                complete.header,
133                complete.chunks.into_values().collect::<Vec<_>>(),
134            )
135        };
136
137        // Assemble snapshot.
138        let snapshot = assemble_chunks(&header, &mut chunks).map_err(|e| LiteError::Storage {
139            detail: format!("assemble_chunks: {e}"),
140        })?;
141
142        // The snapshot's tile_blob is a zerompk-encoded Vec<ArrayOp>.
143        let ops =
144            op_codec::decode_op_batch(&snapshot.tile_blob).map_err(|e| LiteError::Storage {
145                detail: format!("snapshot decode_op_batch: {e}"),
146            })?;
147
148        let mut ops_applied: u64 = 0;
149        for op in &ops {
150            self.replica.observe(op.header.hlc)?;
151            let outcome = self.apply_single_op(op)?;
152            if matches!(outcome, ApplyOutcome::Applied) {
153                ops_applied += 1;
154            }
155        }
156
157        Ok(InboundOutcome::SnapshotApplied { ops_applied })
158    }
159
160    /// Drop every partial snapshot assembly.
161    ///
162    /// Called when a new session starts its catch-up round. Origin re-sends a
163    /// whole snapshot for each catch-up request, so a partial assembly from a
164    /// closed session can never complete.
165    pub fn discard_partial_snapshots(&self) -> Result<(), LiteError> {
166        self.snapshots
167            .lock()
168            .map_err(|_| LiteError::LockPoisoned)?
169            .clear();
170        Ok(())
171    }
172}
173
174/// Check a declared chunk count against [`MAX_CHUNKS_PER_SNAPSHOT`].
175fn check_total_chunks(array: &str, total: u32) -> Result<(), LiteError> {
176    if total == 0 || total > MAX_CHUNKS_PER_SNAPSHOT {
177        return Err(LiteError::Sync {
178            detail: format!(
179                "snapshot for array '{array}' declares {total} chunks; \
180                 the allowed range is 1..={MAX_CHUNKS_PER_SNAPSHOT}"
181            ),
182        });
183    }
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use nodedb_array::sync::op::{ArrayOp, ArrayOpHeader, ArrayOpKind};
190    use nodedb_array::sync::op_codec;
191    use nodedb_array::sync::snapshot::{CoordRange, TileSnapshot, split_into_chunks};
192    use nodedb_array::types::cell_value::value::CellValue;
193    use nodedb_array::types::coord::value::CoordValue;
194    use nodedb_types::sync::wire::array::{ArraySnapshotChunkMsg, ArraySnapshotMsg};
195
196    use super::super::fixtures::{hlc, make_inbound, simple_schema};
197    use super::super::outcome::InboundOutcome;
198
199    #[tokio::test(flavor = "multi_thread")]
200    async fn snapshot_chunks_assemble_and_apply() {
201        let (inbound, schemas, _pending, storage) = make_inbound().await;
202        schemas
203            .put_schema("snap", &simple_schema("snap"))
204            .await
205            .unwrap();
206        {
207            let mut state = inbound.engine.array_state.lock().await;
208            state
209                .create_array(&storage, "snap", simple_schema("snap"), None, None)
210                .await
211                .unwrap();
212        }
213        let schema_hlc = schemas.schema_hlc("snap").unwrap();
214
215        // Build 3 ops and encode them as a blob.
216        let ops: Vec<ArrayOp> = (1u64..=3)
217            .map(|i| ArrayOp {
218                header: ArrayOpHeader {
219                    array: "snap".into(),
220                    hlc: hlc(i * 10),
221                    schema_hlc,
222                    valid_from_ms: 0,
223                    valid_until_ms: -1,
224                    system_from_ms: (i * 10) as i64,
225                },
226                kind: ArrayOpKind::Put,
227                coord: vec![CoordValue::Int64(i as i64)],
228                attrs: Some(vec![CellValue::Float64(i as f64)]),
229            })
230            .collect();
231
232        let blob = op_codec::encode_op_batch(&ops).unwrap();
233        let snapshot_hlc_val = hlc(1000);
234        let tile_snapshot = TileSnapshot {
235            array: "snap".into(),
236            coord_range: CoordRange {
237                lo: vec![CoordValue::Int64(0)],
238                hi: vec![CoordValue::Int64(10)],
239            },
240            tile_blob: blob,
241            snapshot_hlc: snapshot_hlc_val,
242            schema_hlc,
243        };
244
245        // Split into 2 chunks.
246        let (header, wire_chunks) = split_into_chunks(&tile_snapshot, 64).unwrap();
247        assert!(
248            wire_chunks.len() >= 2 || wire_chunks.len() == 1,
249            "expected at least one chunk"
250        );
251
252        // Send header.
253        let header_payload = zerompk::to_msgpack_vec(&header).unwrap();
254        let header_msg = ArraySnapshotMsg {
255            array: "snap".into(),
256            header_payload,
257        };
258        let h_outcome = inbound.handle_snapshot_header(&header_msg).unwrap();
259        assert!(matches!(
260            h_outcome,
261            InboundOutcome::SnapshotPartial { received: 0, .. }
262        ));
263
264        // Send all chunks; last chunk triggers assembly.
265        let total = wire_chunks.len();
266        let mut last_outcome = InboundOutcome::Idempotent;
267        for (i, chunk) in wire_chunks.into_iter().enumerate() {
268            let chunk_msg = ArraySnapshotChunkMsg {
269                array: "snap".into(),
270                snapshot_hlc_bytes: snapshot_hlc_val.to_bytes(),
271                chunk_index: chunk.chunk_index,
272                total_chunks: chunk.total_chunks,
273                payload: chunk.payload,
274            };
275            last_outcome = inbound.handle_snapshot_chunk(&chunk_msg).unwrap();
276            if i + 1 < total {
277                assert!(
278                    matches!(last_outcome, InboundOutcome::SnapshotPartial { .. }),
279                    "expected partial while more chunks remain"
280                );
281            }
282        }
283        assert!(
284            matches!(
285                last_outcome,
286                InboundOutcome::SnapshotApplied { ops_applied: 3 }
287            ),
288            "expected SnapshotApplied{{ops_applied: 3}}, got: {last_outcome:?}"
289        );
290    }
291
292    #[tokio::test(flavor = "multi_thread")]
293    async fn snapshot_partial_returns_partial() {
294        let (inbound, schemas, _pending, storage) = make_inbound().await;
295        schemas.put_schema("p", &simple_schema("p")).await.unwrap();
296        {
297            let mut state = inbound.engine.array_state.lock().await;
298            state
299                .create_array(&storage, "p", simple_schema("p"), None, None)
300                .await
301                .unwrap();
302        }
303        let schema_hlc = schemas.schema_hlc("p").unwrap();
304
305        // Snapshot with 2-chunk blob.
306        let ops: Vec<ArrayOp> = vec![ArrayOp {
307            header: ArrayOpHeader {
308                array: "p".into(),
309                hlc: hlc(1),
310                schema_hlc,
311                valid_from_ms: 0,
312                valid_until_ms: -1,
313                system_from_ms: 1,
314            },
315            kind: ArrayOpKind::Put,
316            coord: vec![CoordValue::Int64(0)],
317            attrs: Some(vec![CellValue::Float64(0.0)]),
318        }];
319        let blob = op_codec::encode_op_batch(&ops).unwrap();
320        // Make the blob large by repeating to guarantee multiple chunks.
321        let big_blob: Vec<u8> = blob.into_iter().cycle().take(200).collect();
322        let snapshot_hlc_val = hlc(500);
323        let tile_snapshot = TileSnapshot {
324            array: "p".into(),
325            coord_range: CoordRange {
326                lo: vec![CoordValue::Int64(0)],
327                hi: vec![CoordValue::Int64(10)],
328            },
329            tile_blob: big_blob,
330            snapshot_hlc: snapshot_hlc_val,
331            schema_hlc,
332        };
333
334        let (header, wire_chunks) = split_into_chunks(&tile_snapshot, 64).unwrap();
335        // Need at least 2 chunks for this test to be meaningful.
336        if wire_chunks.len() < 2 {
337            // Single chunk — skip; the assembly test above covers that path.
338            return;
339        }
340
341        // Send header and only the first chunk.
342        let header_payload = zerompk::to_msgpack_vec(&header).unwrap();
343        let header_msg = ArraySnapshotMsg {
344            array: "p".into(),
345            header_payload,
346        };
347        inbound.handle_snapshot_header(&header_msg).unwrap();
348
349        let first_chunk = &wire_chunks[0];
350        let chunk_msg = ArraySnapshotChunkMsg {
351            array: "p".into(),
352            snapshot_hlc_bytes: snapshot_hlc_val.to_bytes(),
353            chunk_index: first_chunk.chunk_index,
354            total_chunks: first_chunk.total_chunks,
355            payload: first_chunk.payload.clone(),
356        };
357        let outcome = inbound.handle_snapshot_chunk(&chunk_msg).unwrap();
358        assert!(
359            matches!(outcome, InboundOutcome::SnapshotPartial { .. }),
360            "expected Partial while chunks remain, got: {outcome:?}"
361        );
362    }
363
364    fn header_msg(array: &str, snapshot_ms: u64, total_chunks: u32) -> ArraySnapshotMsg {
365        use nodedb_array::sync::snapshot::SnapshotHeader;
366        let header = SnapshotHeader {
367            array: array.into(),
368            coord_range: CoordRange {
369                lo: vec![CoordValue::Int64(0)],
370                hi: vec![CoordValue::Int64(10)],
371            },
372            snapshot_hlc: hlc(snapshot_ms),
373            schema_hlc: hlc(1),
374            total_chunks,
375        };
376        ArraySnapshotMsg {
377            array: array.into(),
378            header_payload: zerompk::to_msgpack_vec(&header).unwrap(),
379        }
380    }
381
382    fn chunk_msg(array: &str, snapshot_ms: u64, index: u32, total: u32) -> ArraySnapshotChunkMsg {
383        ArraySnapshotChunkMsg {
384            array: array.into(),
385            snapshot_hlc_bytes: hlc(snapshot_ms).to_bytes(),
386            chunk_index: index,
387            total_chunks: total,
388            payload: vec![0xAB; 8],
389        }
390    }
391
392    #[tokio::test(flavor = "multi_thread")]
393    async fn chunk_before_header_is_an_error_and_buffers_nothing() {
394        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
395        assert!(
396            inbound
397                .handle_snapshot_chunk(&chunk_msg("a", 50, 0, 2))
398                .is_err()
399        );
400        assert!(inbound.snapshots.lock().unwrap().is_empty());
401    }
402
403    #[tokio::test(flavor = "multi_thread")]
404    async fn chunk_with_conflicting_total_drops_the_assembly() {
405        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
406        inbound
407            .handle_snapshot_header(&header_msg("a", 50, 3))
408            .unwrap();
409        assert!(
410            inbound
411                .handle_snapshot_chunk(&chunk_msg("a", 50, 0, 2))
412                .is_err()
413        );
414        assert!(inbound.snapshots.lock().unwrap().is_empty());
415    }
416
417    #[tokio::test(flavor = "multi_thread")]
418    async fn chunk_index_outside_total_is_an_error() {
419        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
420        inbound
421            .handle_snapshot_header(&header_msg("a", 50, 2))
422            .unwrap();
423        assert!(
424            inbound
425                .handle_snapshot_chunk(&chunk_msg("a", 50, 2, 2))
426                .is_err()
427        );
428    }
429
430    #[tokio::test(flavor = "multi_thread")]
431    async fn header_with_zero_chunks_is_an_error() {
432        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
433        assert!(
434            inbound
435                .handle_snapshot_header(&header_msg("a", 50, 0))
436                .is_err()
437        );
438    }
439
440    #[tokio::test(flavor = "multi_thread")]
441    async fn complete_assembly_that_fails_to_decode_leaves_no_buffered_state() {
442        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
443        inbound
444            .handle_snapshot_header(&header_msg("a", 50, 1))
445            .unwrap();
446        // The payload is not a valid tile blob, so assembly or decode fails.
447        assert!(
448            inbound
449                .handle_snapshot_chunk(&chunk_msg("a", 50, 0, 1))
450                .is_err()
451        );
452        assert!(inbound.snapshots.lock().unwrap().is_empty());
453    }
454
455    #[tokio::test(flavor = "multi_thread")]
456    async fn discard_partial_snapshots_clears_every_assembly() {
457        let (inbound, _schemas, _pending, _storage) = make_inbound().await;
458        inbound
459            .handle_snapshot_header(&header_msg("a", 50, 2))
460            .unwrap();
461        inbound
462            .handle_snapshot_header(&header_msg("b", 60, 2))
463            .unwrap();
464        inbound
465            .handle_snapshot_chunk(&chunk_msg("a", 50, 0, 2))
466            .unwrap();
467        inbound.discard_partial_snapshots().unwrap();
468        assert!(inbound.snapshots.lock().unwrap().is_empty());
469    }
470}