1use 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
18const MAX_CHUNK_BYTES: usize = 256 * 1024;
20
21const MAX_CHUNKS_PER_SNAPSHOT: u32 = 4_096;
24
25impl<S: StorageEngine> ArrayInbound<S> {
26 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 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 let snapshot = assemble_chunks(&header, &mut chunks).map_err(|e| LiteError::Storage {
139 detail: format!("assemble_chunks: {e}"),
140 })?;
141
142 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 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
174fn 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 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 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 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 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 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 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 if wire_chunks.len() < 2 {
337 return;
339 }
340
341 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 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}