Skip to main content

nodedb_lite/sync/client/
receive.rs

1//! Receive-path handlers: shape snapshot/delta, clock sync, sequence gap detection, resync.
2
3use nodedb_types::sync::wire::{
4    ArrayAckMsg, ArrayCatchupRequestMsg, ResyncReason, ResyncRequestMsg, ShapeDeltaMsg,
5    ShapeSnapshotMsg, VectorClockSyncMsg,
6};
7
8use super::state::SyncClient;
9
10impl SyncClient {
11    /// Process a ShapeSnapshot from Origin.
12    ///
13    /// Marks the shape as snapshot-loaded and re-bases the sequence tracker
14    /// so that gap detection restarts from the snapshot LSN rather than any
15    /// stale watermark from before the resync. Also clears the resync gate so
16    /// a future gap on this or another shape can trigger a new request.
17    pub async fn handle_shape_snapshot(&self, msg: &ShapeSnapshotMsg) {
18        let mut shapes = self.shapes.lock().await;
19        shapes.mark_snapshot_loaded(&msg.shape_id, msg.snapshot_lsn);
20        drop(shapes);
21
22        // Re-base the per-shape LSN tracker at the snapshot watermark so the
23        // gap detector does not immediately fire again after the resync.
24        self.last_seen_lsn
25            .lock()
26            .await
27            .insert(msg.shape_id.clone(), msg.snapshot_lsn);
28
29        // Clear the resync gate so future gaps can trigger new requests.
30        *self.resync_requested.lock().await = false;
31        *self.pending_resync.lock().await = None;
32
33        tracing::info!(
34            shape_id = %msg.shape_id,
35            lsn = msg.snapshot_lsn,
36            doc_count = msg.doc_count,
37            "shape snapshot received"
38        );
39    }
40
41    /// Process a ShapeDelta from Origin.
42    pub async fn handle_shape_delta(&self, msg: &ShapeDeltaMsg) {
43        let mut shapes = self.shapes.lock().await;
44        shapes.advance_lsn(&msg.shape_id, msg.lsn);
45        tracing::debug!(
46            shape_id = %msg.shape_id,
47            collection = %msg.collection,
48            doc_id = %msg.document_id,
49            lsn = msg.lsn,
50            "shape delta received"
51        );
52    }
53
54    /// Process a VectorClockSync from Origin.
55    pub async fn handle_clock_sync(&self, msg: &VectorClockSyncMsg) {
56        let mut clock = self.clock.lock().await;
57        for (peer_hex, &counter) in &msg.clocks {
58            if let Ok(peer_id) = u64::from_str_radix(peer_hex, 16) {
59                clock.advance(peer_id, counter);
60            }
61        }
62    }
63
64    /// Check an incoming ShapeDelta for sequence gaps.
65    ///
66    /// For each shape, we track the last LSN received. If the incoming LSN
67    /// is not contiguous (gap > 1), this indicates missing deltas in the stream.
68    /// Returns `Some(ResyncRequestMsg)` if a gap is detected, `None` otherwise.
69    pub async fn check_sequence_gap(&self, shape_id: &str, lsn: u64) -> Option<ResyncRequestMsg> {
70        if *self.resync_requested.lock().await {
71            return None;
72        }
73
74        let mut tracker = self.last_seen_lsn.lock().await;
75        if let Some(&last_lsn) = tracker.get(shape_id)
76            && lsn > last_lsn + 1
77        {
78            tracing::warn!(
79                shape_id,
80                expected = last_lsn + 1,
81                received = lsn,
82                "sequence gap detected in incoming delta stream"
83            );
84            tracker.insert(shape_id.to_string(), lsn);
85
86            *self.resync_requested.lock().await = true;
87
88            return Some(ResyncRequestMsg {
89                reason: ResyncReason::SequenceGap {
90                    expected: last_lsn + 1,
91                    received: lsn,
92                },
93                from_mutation_id: last_lsn + 1,
94                collection: String::new(),
95                shape_id: shape_id.to_string(),
96            });
97        }
98        tracker.insert(shape_id.to_string(), lsn);
99        None
100    }
101
102    /// Reset sequence tracking state on reconnect.
103    pub async fn reset_sequence_tracking(&self) {
104        self.last_seen_lsn.lock().await.clear();
105        *self.resync_requested.lock().await = false;
106        *self.pending_resync.lock().await = None;
107    }
108
109    /// Store a pending re-sync request (set by gap detection in receive loop).
110    pub async fn set_pending_resync(&self, msg: ResyncRequestMsg) {
111        *self.pending_resync.lock().await = Some(msg);
112    }
113
114    /// Take the pending re-sync request (consumed by delta push loop).
115    pub async fn take_pending_resync(&self) -> Option<ResyncRequestMsg> {
116        self.pending_resync.lock().await.take()
117    }
118
119    /// Merge an `ArrayAck` into the pending-ack map.
120    ///
121    /// Per-array, only the ack with the highest HLC is kept — `ack_hlc_bytes` is
122    /// stored in the same 18-byte big-endian layout used by `nodedb_array::sync::Hlc`,
123    /// so byte-wise comparison gives the correct temporal ordering. This means no
124    /// ack is silently lost: if two acks arrive for the same array before the push
125    /// loop drains them, the one with the higher frontier is retained, which is
126    /// exactly what Origin needs to advance its GC cursor.
127    pub async fn set_pending_array_ack(&self, msg: ArrayAckMsg) {
128        let mut map = self.pending_array_ack.lock().await;
129        let entry = map.entry(msg.array.clone()).or_insert_with(|| msg.clone());
130        if msg.ack_hlc_bytes > entry.ack_hlc_bytes {
131            *entry = msg;
132        }
133    }
134
135    /// Drain all pending `ArrayAck`s (consumed by the push loop).
136    ///
137    /// Returns all per-array acks accumulated since the last drain.  The map is
138    /// cleared so new acks can accumulate for the next tick.
139    pub async fn drain_pending_array_acks(&self) -> Vec<ArrayAckMsg> {
140        let mut map = self.pending_array_ack.lock().await;
141        if map.is_empty() {
142            return Vec::new();
143        }
144        let drained: Vec<ArrayAckMsg> = map.drain().map(|(_, v)| v).collect();
145        drained
146    }
147
148    /// Replace every pending `ArrayCatchupRequest` with `requests`.
149    ///
150    /// Called once per session after the handshake: the new round supersedes
151    /// any request the previous session did not send.
152    pub async fn reset_array_catchups(&self, requests: Vec<ArrayCatchupRequestMsg>) {
153        let mut map = self.pending_array_catchups.lock().await;
154        map.clear();
155        map.extend(requests.into_iter().map(|r| (r.array.clone(), r)));
156    }
157
158    /// Queue an `ArrayCatchupRequest` for the next push tick, replacing an
159    /// unsent request for the same array.
160    pub async fn queue_array_catchup(&self, request: ArrayCatchupRequestMsg) {
161        self.pending_array_catchups
162            .lock()
163            .await
164            .insert(request.array.clone(), request);
165    }
166
167    /// Drain every pending `ArrayCatchupRequest` in array-name order
168    /// (consumed by the push loop).
169    pub async fn drain_array_catchups(&self) -> Vec<ArrayCatchupRequestMsg> {
170        std::mem::take(&mut *self.pending_array_catchups.lock().await)
171            .into_values()
172            .collect()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::sync::client::SyncConfig;
180
181    fn make_config() -> SyncConfig {
182        SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
183    }
184
185    #[tokio::test]
186    async fn shape_snapshot_updates_manager() {
187        let client = SyncClient::new(make_config());
188        {
189            let mut shapes = client.shapes().lock().await;
190            shapes.subscribe(nodedb_types::sync::shape::ShapeDefinition {
191                shape_id: "s1".into(),
192                tenant_id: 1,
193                shape_type: nodedb_types::sync::shape::ShapeType::Vector {
194                    collection: "vecs".into(),
195                    field_name: None,
196                },
197                description: "test".into(),
198                field_filter: vec![],
199            });
200        }
201
202        client
203            .handle_shape_snapshot(&ShapeSnapshotMsg {
204                shape_id: "s1".into(),
205                data: Vec::new(),
206                snapshot_lsn: 100,
207                doc_count: 50,
208            })
209            .await;
210
211        let shapes = client.shapes().lock().await;
212        let sub = shapes.get("s1").unwrap();
213        assert!(sub.snapshot_loaded);
214        assert_eq!(sub.last_lsn, 100);
215    }
216
217    #[tokio::test]
218    async fn sequence_gap_detection_no_gap() {
219        let client = SyncClient::new(make_config());
220        assert!(client.check_sequence_gap("s1", 1).await.is_none());
221        assert!(client.check_sequence_gap("s1", 2).await.is_none());
222        assert!(client.check_sequence_gap("s1", 3).await.is_none());
223    }
224
225    #[tokio::test]
226    async fn sequence_gap_detection_with_gap() {
227        let client = SyncClient::new(make_config());
228        assert!(client.check_sequence_gap("s1", 1).await.is_none());
229        let resync = client.check_sequence_gap("s1", 5).await;
230        assert!(resync.is_some());
231        let msg = resync.unwrap();
232        assert_eq!(msg.from_mutation_id, 2);
233        assert!(matches!(
234            msg.reason,
235            ResyncReason::SequenceGap {
236                expected: 2,
237                received: 5
238            }
239        ));
240    }
241
242    #[tokio::test]
243    async fn sequence_gap_only_one_resync_per_connection() {
244        let client = SyncClient::new(make_config());
245        assert!(client.check_sequence_gap("s1", 1).await.is_none());
246        assert!(client.check_sequence_gap("s1", 10).await.is_some());
247        assert!(client.check_sequence_gap("s1", 20).await.is_none());
248    }
249
250    #[tokio::test]
251    async fn array_catchup_queue_keeps_one_request_per_array_in_name_order() {
252        use nodedb_types::sync::wire::ArrayCatchupRequestMsg;
253        let req = |array: &str, byte: u8| ArrayCatchupRequestMsg {
254            array: array.into(),
255            from_hlc_bytes: [byte; 18],
256        };
257        let client = SyncClient::new(make_config());
258
259        client.queue_array_catchup(req("stale", 1)).await;
260        client
261            .reset_array_catchups(vec![req("b", 2), req("a", 3)])
262            .await;
263        client.queue_array_catchup(req("b", 0)).await;
264
265        let drained = client.drain_array_catchups().await;
266        assert_eq!(drained, vec![req("a", 3), req("b", 0)]);
267        assert!(client.drain_array_catchups().await.is_empty());
268    }
269
270    #[tokio::test]
271    async fn array_ack_merge_keeps_highest_hlc() {
272        let client = SyncClient::new(make_config());
273
274        // Lower HLC bytes first.
275        let lower = ArrayAckMsg {
276            array: "arr1".into(),
277            replica_id: 1,
278            ack_hlc_bytes: [0x00; 18],
279            applied_seq: 0,
280            status: nodedb_types::sync::wire::AckStatus::Applied,
281        };
282        let higher = ArrayAckMsg {
283            array: "arr1".into(),
284            replica_id: 1,
285            ack_hlc_bytes: [0xFF; 18],
286            applied_seq: 0,
287            status: nodedb_types::sync::wire::AckStatus::Applied,
288        };
289
290        client.set_pending_array_ack(lower).await;
291        client.set_pending_array_ack(higher.clone()).await;
292
293        let drained = client.drain_pending_array_acks().await;
294        assert_eq!(drained.len(), 1);
295        assert_eq!(drained[0].ack_hlc_bytes, higher.ack_hlc_bytes);
296    }
297
298    #[tokio::test]
299    async fn array_ack_merge_keeps_higher_over_lower() {
300        let client = SyncClient::new(make_config());
301
302        // Insert higher first, then lower — should still keep higher.
303        let mut higher_bytes = [0x00u8; 18];
304        higher_bytes[0] = 0x10;
305        let mut lower_bytes = [0x00u8; 18];
306        lower_bytes[0] = 0x01;
307
308        client
309            .set_pending_array_ack(ArrayAckMsg {
310                array: "arr2".into(),
311                replica_id: 1,
312                ack_hlc_bytes: higher_bytes,
313                applied_seq: 0,
314                status: nodedb_types::sync::wire::AckStatus::Applied,
315            })
316            .await;
317        client
318            .set_pending_array_ack(ArrayAckMsg {
319                array: "arr2".into(),
320                replica_id: 1,
321                ack_hlc_bytes: lower_bytes,
322                applied_seq: 0,
323                status: nodedb_types::sync::wire::AckStatus::Applied,
324            })
325            .await;
326
327        let drained = client.drain_pending_array_acks().await;
328        assert_eq!(drained.len(), 1);
329        assert_eq!(drained[0].ack_hlc_bytes, higher_bytes);
330    }
331
332    #[tokio::test]
333    async fn array_ack_merge_separate_arrays_both_drained() {
334        let client = SyncClient::new(make_config());
335
336        client
337            .set_pending_array_ack(ArrayAckMsg {
338                array: "arr_a".into(),
339                replica_id: 1,
340                ack_hlc_bytes: [0x01; 18],
341                applied_seq: 0,
342                status: nodedb_types::sync::wire::AckStatus::Applied,
343            })
344            .await;
345        client
346            .set_pending_array_ack(ArrayAckMsg {
347                array: "arr_b".into(),
348                replica_id: 1,
349                ack_hlc_bytes: [0x02; 18],
350                applied_seq: 0,
351                status: nodedb_types::sync::wire::AckStatus::Applied,
352            })
353            .await;
354
355        let mut drained = client.drain_pending_array_acks().await;
356        drained.sort_by(|a, b| a.array.cmp(&b.array));
357        assert_eq!(drained.len(), 2);
358        assert_eq!(drained[0].array, "arr_a");
359        assert_eq!(drained[1].array, "arr_b");
360    }
361
362    #[tokio::test]
363    async fn reset_sequence_tracking_clears_state() {
364        let client = SyncClient::new(make_config());
365        assert!(client.check_sequence_gap("s1", 1).await.is_none());
366        assert!(client.check_sequence_gap("s1", 10).await.is_some());
367
368        client.reset_sequence_tracking().await;
369
370        assert!(client.check_sequence_gap("s1", 1).await.is_none());
371        assert!(client.check_sequence_gap("s1", 5).await.is_some());
372    }
373}