Skip to main content

nodedb_lite/sync/client/
delta.rs

1//! Delta push / ack / reject.
2
3use nodedb_types::sync::wire::{DeltaAckMsg, DeltaPushMsg, DeltaRejectMsg};
4
5use super::state::SyncClient;
6use crate::engine::crdt::engine::PendingDelta;
7use crate::sync::compensation::CompensationEvent;
8
9impl SyncClient {
10    /// Build DeltaPush messages from pending deltas.
11    ///
12    /// Respects the flow control window: returns at most `next_batch_size()`
13    /// deltas. Each message includes a CRC32C checksum of the delta payload
14    /// for integrity verification at Origin.
15    ///
16    /// `peer_id` is passed in rather than held on the client: it changes when
17    /// a collision refusal rotates this replica's identity, and a delta pushed
18    /// under the previous one is refused again.
19    pub async fn build_delta_pushes(
20        &self,
21        pending: &[PendingDelta],
22        peer_id: u64,
23    ) -> Vec<DeltaPushMsg> {
24        let flow = self.flow.lock().await;
25        let batch_limit = flow.next_batch_size();
26        drop(flow);
27
28        if batch_limit == 0 {
29            return Vec::new();
30        }
31
32        let device_valid_time_ms = crate::runtime::now_millis() as i64;
33
34        // Remember what each in-flight mutation targets. A `DeltaRejectMsg`
35        // carries only the mutation id, so without this the compensation event
36        // reaches the application naming neither the collection nor the
37        // document that was refused — leaving it unable to roll back, retry, or
38        // prompt for the write that failed.
39        {
40            let mut targets = self.delta_targets.lock().await;
41            for delta in pending.iter().take(batch_limit) {
42                targets.insert(
43                    delta.mutation_id,
44                    (delta.collection.clone(), delta.document_id.clone()),
45                );
46            }
47        }
48
49        pending
50            .iter()
51            .take(batch_limit)
52            .map(|delta| DeltaPushMsg {
53                collection: delta.collection.clone(),
54                document_id: delta.document_id.clone(),
55                checksum: crc32c::crc32c(&delta.delta_bytes),
56                delta: delta.delta_bytes.clone(),
57                peer_id,
58                mutation_id: delta.mutation_id,
59                device_valid_time_ms: Some(device_valid_time_ms),
60                // producer_id, epoch, and seq are overwritten with real producer/epoch/stable-seq in push_crdt_deltas.
61                producer_id: 0,
62                epoch: 0,
63                seq: 0,
64                // Lite does not derive delta-signing keys yet, so these carry
65                // the wire's documented unsigned values: an all-zero signature
66                // denotes an unsigned delta. Origin's `signing_required` gate
67                // is what decides whether that is acceptable — Lite must not
68                // fabricate a signature it cannot compute.
69                device_id: 0,
70                delta_signature: [0u8; 32],
71            })
72            .collect()
73    }
74
75    /// Record that deltas were pushed (update flow control in-flight tracking).
76    pub async fn record_push(&self, mutation_ids: &[u64]) {
77        let mut flow = self.flow.lock().await;
78        flow.record_push(mutation_ids);
79        self.metrics.record_push(mutation_ids.len() as u64);
80    }
81
82    /// Process a DeltaAck from Origin.
83    pub async fn handle_delta_ack(&self, ack: &DeltaAckMsg) {
84        // The mutation left the in-flight window; its target is no longer
85        // needed to report a rejection against.
86        self.delta_targets.lock().await.remove(&ack.mutation_id);
87
88        let mut clock = self.clock.lock().await;
89        clock.advance(0, ack.lsn); // peer 0 = Origin convention.
90        drop(clock);
91
92        let mut flow = self.flow.lock().await;
93        if let Some(rtt_ms) = flow.record_ack(ack.mutation_id) {
94            tracing::debug!(
95                mutation_id = ack.mutation_id,
96                lsn = ack.lsn,
97                rtt_ms,
98                batch_size = flow.current_batch_size(),
99                "delta acknowledged"
100            );
101        } else {
102            tracing::debug!(
103                mutation_id = ack.mutation_id,
104                lsn = ack.lsn,
105                "delta acknowledged (no in-flight entry)"
106            );
107        }
108
109        if let Some(skew_ms) = ack.clock_skew_warning_ms {
110            tracing::warn!(
111                mutation_id = ack.mutation_id,
112                skew_ms,
113                "Origin reports device clock skew exceeds tolerance"
114            );
115            self.metrics.record_clock_skew_warning();
116        }
117    }
118
119    /// Process a DeltaReject from Origin.
120    pub async fn handle_delta_reject(&self, reject: &DeltaRejectMsg) {
121        tracing::warn!(
122            mutation_id = reject.mutation_id,
123            reason = %reject.reason,
124            "delta rejected by Origin"
125        );
126
127        {
128            let mut flow = self.flow.lock().await;
129            flow.record_reject(reject.mutation_id);
130        }
131        self.metrics.record_reject();
132
133        let (collection, document_id) = self
134            .delta_targets
135            .lock()
136            .await
137            .remove(&reject.mutation_id)
138            .unwrap_or_default();
139
140        if let Some(hint) = &reject.compensation {
141            use nodedb_types::sync::compensation::CompensationHint;
142            let is_conflict = match hint {
143                CompensationHint::UniqueViolation { .. }
144                | CompensationHint::ForeignKeyMissing { .. }
145                | CompensationHint::SchemaViolation { .. } => true,
146                // A refusal the server cannot resolve for us is a conflict with
147                // another replica as much as a UNIQUE violation is; counting it
148                // is what makes a replica stuck on a collision visible.
149                CompensationHint::Custom { .. } => true,
150                _ => false,
151            };
152            if is_conflict {
153                self.metrics.record_conflict(&reject.reason);
154            }
155
156            self.compensation.dispatch(CompensationEvent {
157                mutation_id: reject.mutation_id,
158                collection,
159                document_id,
160                hint: hint.clone(),
161            });
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::sync::client::SyncConfig;
170    use std::sync::Arc;
171
172    fn make_config() -> SyncConfig {
173        SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
174    }
175
176    #[tokio::test]
177    async fn build_delta_pushes() {
178        let client = SyncClient::new(make_config());
179        let pending = vec![
180            PendingDelta {
181                mutation_id: 1,
182                collection: "orders".into(),
183                document_id: "o1".into(),
184                delta_bytes: vec![1, 2, 3],
185                seq: 0,
186            },
187            PendingDelta {
188                mutation_id: 2,
189                collection: "users".into(),
190                document_id: "u1".into(),
191                delta_bytes: vec![4, 5, 6],
192                seq: 0,
193            },
194        ];
195
196        let msgs = client.build_delta_pushes(&pending, 42).await;
197        assert_eq!(msgs.len(), 2);
198        assert_eq!(msgs[0].peer_id, 42);
199        assert_eq!(msgs[0].mutation_id, 1);
200        assert_eq!(msgs[1].collection, "users");
201        assert!(msgs[0].device_valid_time_ms.is_some());
202        assert!(msgs[0].device_valid_time_ms.unwrap() > 0);
203    }
204
205    #[tokio::test]
206    async fn handle_delta_ack_advances_clock() {
207        let client = SyncClient::new(make_config());
208        client
209            .handle_delta_ack(&DeltaAckMsg {
210                mutation_id: 1,
211                lsn: 42,
212                clock_skew_warning_ms: None,
213                applied_seq: 0,
214                status: nodedb_types::sync::wire::AckStatus::Applied,
215            })
216            .await;
217
218        let clock = client.clock().lock().await;
219        assert_eq!(clock.get(0), 42); // peer 0 = Origin.
220    }
221
222    #[tokio::test]
223    async fn handle_delta_reject_dispatches_compensation() {
224        let client = SyncClient::new(make_config());
225
226        let count = Arc::new(std::sync::atomic::AtomicU32::new(0));
227        let count_clone = count.clone();
228        client.set_compensation_handler(Arc::new(move |_: CompensationEvent| {
229            count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
230        }));
231
232        client
233            .handle_delta_reject(&DeltaRejectMsg {
234                mutation_id: 1,
235                reason: "unique violation".into(),
236                compensation: Some(
237                    nodedb_types::sync::compensation::CompensationHint::UniqueViolation {
238                        field: "email".into(),
239                        conflicting_value: "a@b.com".into(),
240                    },
241                ),
242            })
243            .await;
244
245        assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 1);
246    }
247
248    #[tokio::test]
249    async fn delta_push_includes_crc32c() {
250        let client = SyncClient::new(make_config());
251        let delta_bytes = vec![1, 2, 3, 4, 5];
252        let expected_crc = crc32c::crc32c(&delta_bytes);
253        let pending = vec![PendingDelta {
254            mutation_id: 1,
255            collection: "test".into(),
256            document_id: "d1".into(),
257            delta_bytes,
258            seq: 0,
259        }];
260        let msgs = client.build_delta_pushes(&pending, 42).await;
261        assert_eq!(msgs[0].checksum, expected_crc);
262        assert_ne!(msgs[0].checksum, 0);
263    }
264
265    #[tokio::test]
266    async fn flow_control_pauses_when_window_full() {
267        let client = SyncClient::with_flow_control(
268            make_config(),
269            crate::sync::flow_control::FlowControlConfig {
270                max_in_flight: 2,
271                initial_batch_size: 10,
272                ..Default::default()
273            },
274        );
275        let pending = vec![
276            PendingDelta {
277                mutation_id: 1,
278                collection: "a".into(),
279                document_id: "d1".into(),
280                delta_bytes: vec![1],
281                seq: 0,
282            },
283            PendingDelta {
284                mutation_id: 2,
285                collection: "a".into(),
286                document_id: "d2".into(),
287                delta_bytes: vec![2],
288                seq: 0,
289            },
290            PendingDelta {
291                mutation_id: 3,
292                collection: "a".into(),
293                document_id: "d3".into(),
294                delta_bytes: vec![3],
295                seq: 0,
296            },
297        ];
298
299        let msgs = client.build_delta_pushes(&pending, 42).await;
300        assert_eq!(msgs.len(), 2);
301
302        client.record_push(&[1, 2]).await;
303
304        let msgs = client.build_delta_pushes(&pending, 42).await;
305        assert_eq!(msgs.len(), 0);
306
307        client
308            .handle_delta_ack(&DeltaAckMsg {
309                mutation_id: 1,
310                lsn: 10,
311                clock_skew_warning_ms: None,
312                applied_seq: 0,
313                status: nodedb_types::sync::wire::AckStatus::Applied,
314            })
315            .await;
316        let msgs = client.build_delta_pushes(&pending, 42).await;
317        assert_eq!(msgs.len(), 1);
318    }
319}