1use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::sync::Mutex;
15
16use nodedb_types::sync::wire::{
17 DeltaAckMsg, DeltaPushMsg, DeltaRejectMsg, HandshakeAckMsg, HandshakeMsg, PingPongMsg,
18 ResyncReason, ResyncRequestMsg, ShapeDeltaMsg, ShapeSnapshotMsg, SyncFrame, SyncMessageType,
19 VectorClockSyncMsg,
20};
21
22use super::clock::VectorClock;
23use super::compensation::{CompensationEvent, CompensationRegistry};
24use super::flow_control::{FlowControlConfig, FlowController, SyncMetrics, SyncMetricsSnapshot};
25use super::shapes::ShapeManager;
26use crate::engine::crdt::engine::PendingDelta;
27
28pub type TokenProvider = Arc<
41 dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>
42 + Send
43 + Sync,
44>;
45
46#[derive(Clone)]
48pub struct SyncConfig {
49 pub url: String,
51 pub jwt_token: String,
53 pub client_version: String,
55 pub min_backoff: Duration,
57 pub max_backoff: Duration,
59 pub ping_interval: Duration,
61 pub max_batch_size: usize,
63 pub token_provider: Option<TokenProvider>,
65 pub token_lifetime_secs: u64,
68}
69
70impl std::fmt::Debug for SyncConfig {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.debug_struct("SyncConfig")
73 .field("url", &self.url)
74 .field("jwt_token", &"[REDACTED]")
75 .field("client_version", &self.client_version)
76 .field("min_backoff", &self.min_backoff)
77 .field("max_backoff", &self.max_backoff)
78 .field("ping_interval", &self.ping_interval)
79 .field("max_batch_size", &self.max_batch_size)
80 .field("token_provider", &self.token_provider.is_some())
81 .field("token_lifetime_secs", &self.token_lifetime_secs)
82 .finish()
83 }
84}
85
86impl SyncConfig {
87 pub fn new(url: impl Into<String>, jwt_token: impl Into<String>) -> Self {
88 Self {
89 url: url.into(),
90 jwt_token: jwt_token.into(),
91 client_version: env!("CARGO_PKG_VERSION").to_string(),
92 min_backoff: Duration::from_secs(1),
93 max_backoff: Duration::from_secs(60),
94 ping_interval: Duration::from_secs(30),
95 max_batch_size: 100,
96 token_provider: None,
97 token_lifetime_secs: 0,
98 }
99 }
100
101 pub fn with_token_provider(mut self, provider: TokenProvider, lifetime_secs: u64) -> Self {
103 self.token_provider = Some(provider);
104 self.token_lifetime_secs = lifetime_secs;
105 self
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SyncState {
112 Disconnected,
114 Connecting,
116 Connected,
118 Reconnecting,
120}
121
122pub struct SyncClient {
132 config: SyncConfig,
133 state: Arc<Mutex<SyncState>>,
134 clock: Arc<Mutex<VectorClock>>,
135 shapes: Arc<Mutex<ShapeManager>>,
136 compensation: Arc<CompensationRegistry>,
137 session_id: Arc<Mutex<Option<String>>>,
139 peer_id: u64,
141 lite_id: Option<String>,
143 epoch: Option<u64>,
145 last_seen_lsn: Arc<Mutex<std::collections::HashMap<String, u64>>>,
148 resync_requested: Arc<Mutex<bool>>,
151 pending_resync: Arc<Mutex<Option<ResyncRequestMsg>>>,
154 flow: Arc<Mutex<FlowController>>,
156 metrics: Arc<SyncMetrics>,
158 token_set_at_ms: Arc<Mutex<u64>>,
160 token_refresh_pending: Arc<Mutex<bool>>,
162 push_paused_for_auth: Arc<Mutex<bool>>,
164}
165
166impl SyncClient {
167 pub fn new(config: SyncConfig, peer_id: u64) -> Self {
169 Self::with_flow_control(config, peer_id, FlowControlConfig::default())
170 }
171
172 pub fn with_flow_control(
174 config: SyncConfig,
175 peer_id: u64,
176 flow_config: FlowControlConfig,
177 ) -> Self {
178 Self {
179 config,
180 state: Arc::new(Mutex::new(SyncState::Disconnected)),
181 clock: Arc::new(Mutex::new(VectorClock::new())),
182 shapes: Arc::new(Mutex::new(ShapeManager::new())),
183 compensation: Arc::new(CompensationRegistry::new()),
184 session_id: Arc::new(Mutex::new(None)),
185 peer_id,
186 lite_id: None,
187 epoch: None,
188 last_seen_lsn: Arc::new(Mutex::new(std::collections::HashMap::new())),
189 resync_requested: Arc::new(Mutex::new(false)),
190 pending_resync: Arc::new(Mutex::new(None)),
191 flow: Arc::new(Mutex::new(FlowController::new(flow_config))),
192 metrics: Arc::new(SyncMetrics::new()),
193 token_set_at_ms: Arc::new(Mutex::new(crate::runtime::now_millis())),
194 token_refresh_pending: Arc::new(Mutex::new(false)),
195 push_paused_for_auth: Arc::new(Mutex::new(false)),
196 }
197 }
198
199 pub fn set_identity(&mut self, lite_id: String, epoch: u64) {
201 self.lite_id = Some(lite_id);
202 self.epoch = Some(epoch);
203 }
204
205 pub async fn state(&self) -> SyncState {
207 *self.state.lock().await
208 }
209
210 pub fn set_compensation_handler(
212 &self,
213 handler: Arc<dyn super::compensation::CompensationHandler>,
214 ) {
215 self.compensation.set_handler(handler);
216 }
217
218 pub fn shapes(&self) -> &Arc<Mutex<ShapeManager>> {
220 &self.shapes
221 }
222
223 pub fn clock(&self) -> &Arc<Mutex<VectorClock>> {
225 &self.clock
226 }
227
228 pub async fn build_handshake(&self) -> HandshakeMsg {
230 let clock = self.clock.lock().await;
231 let shapes = self.shapes.lock().await;
232
233 let wire_clock = clock.to_wire();
235 let mut vector_clock = std::collections::HashMap::new();
236 vector_clock.insert("_global".to_string(), wire_clock);
239
240 HandshakeMsg {
241 jwt_token: self.config.jwt_token.clone(),
242 vector_clock,
243 subscribed_shapes: shapes.active_shape_ids(),
244 client_version: self.config.client_version.clone(),
245 lite_id: self.lite_id.clone().unwrap_or_default(),
246 epoch: self.epoch.unwrap_or(0),
247 wire_version: 1,
248 }
249 }
250
251 pub async fn handle_handshake_ack(&self, ack: &HandshakeAckMsg) -> bool {
253 if !ack.success {
254 tracing::warn!(
255 error = ack.error.as_deref().unwrap_or("unknown"),
256 "handshake rejected by Origin"
257 );
258 return false;
259 }
260
261 *self.session_id.lock().await = Some(ack.session_id.clone());
263
264 let mut clock = self.clock.lock().await;
266 for (peer_hex, &counter) in &ack.server_clock {
267 if let Ok(peer_id) = u64::from_str_radix(peer_hex, 16) {
268 clock.advance(peer_id, counter);
269 }
270 }
271
272 *self.state.lock().await = SyncState::Connected;
273 tracing::info!(session = %ack.session_id, "sync handshake accepted");
274 true
275 }
276
277 pub async fn build_delta_pushes(&self, pending: &[PendingDelta]) -> Vec<DeltaPushMsg> {
283 let flow = self.flow.lock().await;
284 let batch_limit = flow.next_batch_size();
285 drop(flow);
286
287 if batch_limit == 0 {
288 return Vec::new();
289 }
290
291 pending
292 .iter()
293 .take(batch_limit)
294 .map(|delta| DeltaPushMsg {
295 collection: delta.collection.clone(),
296 document_id: delta.document_id.clone(),
297 checksum: crc32c::crc32c(&delta.delta_bytes),
298 delta: delta.delta_bytes.clone(),
299 peer_id: self.peer_id,
300 mutation_id: delta.mutation_id,
301 })
302 .collect()
303 }
304
305 pub async fn record_push(&self, mutation_ids: &[u64]) {
307 let mut flow = self.flow.lock().await;
308 flow.record_push(mutation_ids);
309 self.metrics.record_push(mutation_ids.len() as u64);
310 }
311
312 pub async fn handle_delta_ack(&self, ack: &DeltaAckMsg) {
314 let mut clock = self.clock.lock().await;
315 clock.advance(0, ack.lsn); drop(clock);
318
319 let mut flow = self.flow.lock().await;
321 if let Some(rtt_ms) = flow.record_ack(ack.mutation_id) {
322 tracing::debug!(
323 mutation_id = ack.mutation_id,
324 lsn = ack.lsn,
325 rtt_ms,
326 batch_size = flow.current_batch_size(),
327 "delta acknowledged"
328 );
329 } else {
330 tracing::debug!(
331 mutation_id = ack.mutation_id,
332 lsn = ack.lsn,
333 "delta acknowledged (no in-flight entry)"
334 );
335 }
336 }
337
338 pub async fn handle_delta_reject(&self, reject: &DeltaRejectMsg) {
340 tracing::warn!(
341 mutation_id = reject.mutation_id,
342 reason = %reject.reason,
343 "delta rejected by Origin"
344 );
345
346 {
348 let mut flow = self.flow.lock().await;
349 flow.record_reject(reject.mutation_id);
350 }
351 self.metrics.record_reject();
352
353 if let Some(hint) = &reject.compensation {
355 use nodedb_types::sync::compensation::CompensationHint;
356 let is_conflict = matches!(
357 hint,
358 CompensationHint::UniqueViolation { .. }
359 | CompensationHint::ForeignKeyMissing { .. }
360 | CompensationHint::SchemaViolation { .. }
361 );
362 if is_conflict {
363 self.metrics.record_conflict(&reject.reason);
365 }
366
367 self.compensation.dispatch(CompensationEvent {
368 mutation_id: reject.mutation_id,
369 collection: String::new(),
370 document_id: String::new(),
371 hint: hint.clone(),
372 });
373 }
374 }
375
376 pub async fn handle_shape_snapshot(&self, msg: &ShapeSnapshotMsg) {
378 let mut shapes = self.shapes.lock().await;
379 shapes.mark_snapshot_loaded(&msg.shape_id, msg.snapshot_lsn);
380 tracing::info!(
381 shape_id = %msg.shape_id,
382 lsn = msg.snapshot_lsn,
383 doc_count = msg.doc_count,
384 "shape snapshot received"
385 );
386 }
387
388 pub async fn handle_shape_delta(&self, msg: &ShapeDeltaMsg) {
390 let mut shapes = self.shapes.lock().await;
391 shapes.advance_lsn(&msg.shape_id, msg.lsn);
392 tracing::debug!(
393 shape_id = %msg.shape_id,
394 collection = %msg.collection,
395 doc_id = %msg.document_id,
396 lsn = msg.lsn,
397 "shape delta received"
398 );
399 }
400
401 pub async fn handle_clock_sync(&self, msg: &VectorClockSyncMsg) {
403 let mut clock = self.clock.lock().await;
404 for (peer_hex, &counter) in &msg.clocks {
405 if let Ok(peer_id) = u64::from_str_radix(peer_hex, 16) {
406 clock.advance(peer_id, counter);
407 }
408 }
409 }
410
411 pub async fn check_sequence_gap(&self, shape_id: &str, lsn: u64) -> Option<ResyncRequestMsg> {
422 if *self.resync_requested.lock().await {
424 return None;
425 }
426
427 let mut tracker = self.last_seen_lsn.lock().await;
428 if let Some(&last_lsn) = tracker.get(shape_id)
429 && lsn > last_lsn + 1
430 {
431 tracing::warn!(
433 shape_id,
434 expected = last_lsn + 1,
435 received = lsn,
436 "sequence gap detected in incoming delta stream"
437 );
438 tracker.insert(shape_id.to_string(), lsn);
439
440 *self.resync_requested.lock().await = true;
442
443 return Some(ResyncRequestMsg {
444 reason: ResyncReason::SequenceGap {
445 expected: last_lsn + 1,
446 received: lsn,
447 },
448 from_mutation_id: last_lsn + 1,
449 collection: String::new(), });
451 }
452 tracker.insert(shape_id.to_string(), lsn);
453 None
454 }
455
456 pub async fn reset_sequence_tracking(&self) {
458 self.last_seen_lsn.lock().await.clear();
459 *self.resync_requested.lock().await = false;
460 *self.pending_resync.lock().await = None;
461 }
462
463 pub async fn set_pending_resync(&self, msg: ResyncRequestMsg) {
465 *self.pending_resync.lock().await = Some(msg);
466 }
467
468 pub async fn take_pending_resync(&self) -> Option<ResyncRequestMsg> {
470 self.pending_resync.lock().await.take()
471 }
472
473 pub fn build_ping(&self) -> SyncFrame {
475 let ping = PingPongMsg {
476 timestamp_ms: crate::runtime::now_millis(),
477 is_pong: false,
478 };
479 SyncFrame::encode_or_empty(SyncMessageType::PingPong, &ping)
480 }
481
482 pub fn backoff_duration(&self, attempt: u32) -> Duration {
484 let base = self.config.min_backoff.as_millis() as u64;
485 let max = self.config.max_backoff.as_millis() as u64;
486 let delay = (base * 2u64.saturating_pow(attempt)).min(max);
487 Duration::from_millis(delay)
488 }
489
490 pub async fn set_state(&self, new_state: SyncState) {
492 *self.state.lock().await = new_state;
493 }
494
495 pub fn compensation(&self) -> &Arc<CompensationRegistry> {
497 &self.compensation
498 }
499
500 pub fn config(&self) -> &SyncConfig {
502 &self.config
503 }
504
505 pub fn peer_id(&self) -> u64 {
507 self.peer_id
508 }
509
510 pub fn flow(&self) -> &Arc<Mutex<FlowController>> {
512 &self.flow
513 }
514
515 pub fn metrics(&self) -> &Arc<SyncMetrics> {
517 &self.metrics
518 }
519
520 pub async fn update_pending_stats(&self, count: usize, bytes: usize) {
523 let mut flow = self.flow.lock().await;
524 flow.update_pending(count, bytes);
525 }
526
527 pub async fn is_queue_full(&self) -> bool {
529 let flow = self.flow.lock().await;
530 flow.is_queue_full()
531 }
532
533 pub async fn sync_metrics(&self) -> SyncMetricsSnapshot {
535 let state = *self.state.lock().await;
536 let state_str = match state {
537 SyncState::Disconnected => "disconnected",
538 SyncState::Connecting => "connecting",
539 SyncState::Connected => "connected",
540 SyncState::Reconnecting => "reconnecting",
541 };
542 let flow = self.flow.lock().await;
543 flow.snapshot(state_str, &self.metrics)
544 }
545
546 pub async fn reset_flow_control(&self) {
548 let mut flow = self.flow.lock().await;
549 flow.reset();
550 self.metrics.record_reconnect();
551 }
552
553 pub async fn should_refresh_token(&self) -> bool {
560 if self.config.token_provider.is_none() || self.config.token_lifetime_secs == 0 {
561 return false;
562 }
563 if *self.token_refresh_pending.lock().await {
564 return false;
565 }
566 let set_at = *self.token_set_at_ms.lock().await;
567 let now = crate::runtime::now_millis();
568 let elapsed_ms = now.saturating_sub(set_at);
569 let threshold_ms = self.config.token_lifetime_secs * 800; elapsed_ms >= threshold_ms
571 }
572
573 pub async fn initiate_token_refresh(
578 &self,
579 ) -> Option<nodedb_types::sync::wire::TokenRefreshMsg> {
580 let provider = self.config.token_provider.as_ref()?;
581 *self.token_refresh_pending.lock().await = true;
582
583 tracing::info!("initiating proactive JWT token refresh");
584 let new_token = provider().await?;
585
586 Some(nodedb_types::sync::wire::TokenRefreshMsg { new_token })
587 }
588
589 pub async fn handle_token_refresh_ack(
591 &self,
592 ack: &nodedb_types::sync::wire::TokenRefreshAckMsg,
593 ) {
594 *self.token_refresh_pending.lock().await = false;
595
596 if ack.success {
597 *self.token_set_at_ms.lock().await = crate::runtime::now_millis();
598 *self.push_paused_for_auth.lock().await = false;
599 tracing::info!(
600 expires_in_secs = ack.expires_in_secs,
601 "JWT token refresh accepted by Origin"
602 );
603 } else {
604 tracing::warn!(
605 error = ack.error.as_deref().unwrap_or("unknown"),
606 "JWT token refresh rejected by Origin"
607 );
608 }
609 }
610
611 pub async fn pause_for_auth(&self) {
614 *self.push_paused_for_auth.lock().await = true;
615 tracing::warn!("delta push paused — auth failure, awaiting token refresh");
616 }
617
618 pub async fn is_push_paused_for_auth(&self) -> bool {
620 *self.push_paused_for_auth.lock().await
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 fn make_config() -> SyncConfig {
629 SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
630 }
631
632 #[tokio::test]
633 async fn initial_state_is_disconnected() {
634 let client = SyncClient::new(make_config(), 1);
635 assert_eq!(client.state().await, SyncState::Disconnected);
636 }
637
638 #[tokio::test]
639 async fn build_handshake() {
640 let client = SyncClient::new(make_config(), 1);
641
642 {
643 let mut shapes = client.shapes().lock().await;
644 shapes.subscribe(nodedb_types::sync::shape::ShapeDefinition {
645 shape_id: "s1".into(),
646 tenant_id: 1,
647 shape_type: nodedb_types::sync::shape::ShapeType::Document {
648 collection: "orders".into(),
649 predicate: Vec::new(),
650 },
651 description: "test".into(),
652 field_filter: vec![],
653 });
654 }
655
656 let hs = client.build_handshake().await;
657 assert_eq!(hs.jwt_token, "test.jwt.token");
658 assert_eq!(hs.subscribed_shapes, vec!["s1"]);
659 }
660
661 #[tokio::test]
662 async fn handle_handshake_ack_success() {
663 let client = SyncClient::new(make_config(), 1);
664 let ack = HandshakeAckMsg {
665 success: true,
666 session_id: "sess-123".into(),
667 server_clock: std::collections::HashMap::new(),
668 error: None,
669 fork_detected: false,
670 server_wire_version: 1,
671 };
672
673 assert!(client.handle_handshake_ack(&ack).await);
674 assert_eq!(client.state().await, SyncState::Connected);
675 }
676
677 #[tokio::test]
678 async fn handle_handshake_ack_failure() {
679 let client = SyncClient::new(make_config(), 1);
680 let ack = HandshakeAckMsg {
681 success: false,
682 session_id: String::new(),
683 server_clock: std::collections::HashMap::new(),
684 error: Some("invalid token".into()),
685 fork_detected: false,
686 server_wire_version: 1,
687 };
688
689 assert!(!client.handle_handshake_ack(&ack).await);
690 assert_eq!(client.state().await, SyncState::Disconnected);
691 }
692
693 #[tokio::test]
694 async fn build_delta_pushes() {
695 let client = SyncClient::new(make_config(), 42);
696 let pending = vec![
697 PendingDelta {
698 mutation_id: 1,
699 collection: "orders".into(),
700 document_id: "o1".into(),
701 delta_bytes: vec![1, 2, 3],
702 },
703 PendingDelta {
704 mutation_id: 2,
705 collection: "users".into(),
706 document_id: "u1".into(),
707 delta_bytes: vec![4, 5, 6],
708 },
709 ];
710
711 let msgs = client.build_delta_pushes(&pending).await;
712 assert_eq!(msgs.len(), 2);
713 assert_eq!(msgs[0].peer_id, 42);
714 assert_eq!(msgs[0].mutation_id, 1);
715 assert_eq!(msgs[1].collection, "users");
716 }
717
718 #[tokio::test]
719 async fn handle_delta_ack_advances_clock() {
720 let client = SyncClient::new(make_config(), 1);
721 client
722 .handle_delta_ack(&DeltaAckMsg {
723 mutation_id: 1,
724 lsn: 42,
725 })
726 .await;
727
728 let clock = client.clock().lock().await;
729 assert_eq!(clock.get(0), 42); }
731
732 #[tokio::test]
733 async fn handle_delta_reject_dispatches_compensation() {
734 let client = SyncClient::new(make_config(), 1);
735
736 let count = Arc::new(std::sync::atomic::AtomicU32::new(0));
737 let count_clone = count.clone();
738 client.set_compensation_handler(Arc::new(move |_: CompensationEvent| {
739 count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
740 }));
741
742 client
743 .handle_delta_reject(&DeltaRejectMsg {
744 mutation_id: 1,
745 reason: "unique violation".into(),
746 compensation: Some(
747 nodedb_types::sync::compensation::CompensationHint::UniqueViolation {
748 field: "email".into(),
749 conflicting_value: "a@b.com".into(),
750 },
751 ),
752 })
753 .await;
754
755 assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 1);
756 }
757
758 #[test]
759 fn backoff_exponential_with_cap() {
760 let client = SyncClient::new(make_config(), 1);
761 assert_eq!(client.backoff_duration(0), Duration::from_secs(1));
762 assert_eq!(client.backoff_duration(1), Duration::from_secs(2));
763 assert_eq!(client.backoff_duration(2), Duration::from_secs(4));
764 assert_eq!(client.backoff_duration(3), Duration::from_secs(8));
765 assert_eq!(client.backoff_duration(10), Duration::from_secs(60));
767 }
768
769 #[tokio::test]
770 async fn shape_snapshot_updates_manager() {
771 let client = SyncClient::new(make_config(), 1);
772 {
773 let mut shapes = client.shapes().lock().await;
774 shapes.subscribe(nodedb_types::sync::shape::ShapeDefinition {
775 shape_id: "s1".into(),
776 tenant_id: 1,
777 shape_type: nodedb_types::sync::shape::ShapeType::Vector {
778 collection: "vecs".into(),
779 field_name: None,
780 },
781 description: "test".into(),
782 field_filter: vec![],
783 });
784 }
785
786 client
787 .handle_shape_snapshot(&ShapeSnapshotMsg {
788 shape_id: "s1".into(),
789 data: Vec::new(),
790 snapshot_lsn: 100,
791 doc_count: 50,
792 })
793 .await;
794
795 let shapes = client.shapes().lock().await;
796 let sub = shapes.get("s1").unwrap();
797 assert!(sub.snapshot_loaded);
798 assert_eq!(sub.last_lsn, 100);
799 }
800
801 #[test]
802 fn ping_frame_is_valid() {
803 let client = SyncClient::new(make_config(), 1);
804 let frame = client.build_ping();
805 assert_eq!(frame.msg_type, SyncMessageType::PingPong);
806 assert!(!frame.body.is_empty());
807 }
808
809 #[tokio::test]
810 async fn sequence_gap_detection_no_gap() {
811 let client = SyncClient::new(make_config(), 1);
812 assert!(client.check_sequence_gap("s1", 1).await.is_none());
814 assert!(client.check_sequence_gap("s1", 2).await.is_none());
815 assert!(client.check_sequence_gap("s1", 3).await.is_none());
816 }
817
818 #[tokio::test]
819 async fn sequence_gap_detection_with_gap() {
820 let client = SyncClient::new(make_config(), 1);
821 assert!(client.check_sequence_gap("s1", 1).await.is_none());
823 let resync = client.check_sequence_gap("s1", 5).await;
825 assert!(resync.is_some());
826 let msg = resync.unwrap();
827 assert_eq!(msg.from_mutation_id, 2); assert!(matches!(
829 msg.reason,
830 ResyncReason::SequenceGap {
831 expected: 2,
832 received: 5
833 }
834 ));
835 }
836
837 #[tokio::test]
838 async fn sequence_gap_only_one_resync_per_connection() {
839 let client = SyncClient::new(make_config(), 1);
840 assert!(client.check_sequence_gap("s1", 1).await.is_none());
841 assert!(client.check_sequence_gap("s1", 10).await.is_some());
843 assert!(client.check_sequence_gap("s1", 20).await.is_none());
845 }
846
847 #[tokio::test]
848 async fn reset_sequence_tracking_clears_state() {
849 let client = SyncClient::new(make_config(), 1);
850 assert!(client.check_sequence_gap("s1", 1).await.is_none());
851 assert!(client.check_sequence_gap("s1", 10).await.is_some());
852
853 client.reset_sequence_tracking().await;
855
856 assert!(client.check_sequence_gap("s1", 1).await.is_none());
858 assert!(client.check_sequence_gap("s1", 5).await.is_some());
859 }
860
861 #[tokio::test]
862 async fn delta_push_includes_crc32c() {
863 let client = SyncClient::new(make_config(), 42);
864 let delta_bytes = vec![1, 2, 3, 4, 5];
865 let expected_crc = crc32c::crc32c(&delta_bytes);
866 let pending = vec![PendingDelta {
867 mutation_id: 1,
868 collection: "test".into(),
869 document_id: "d1".into(),
870 delta_bytes,
871 }];
872 let msgs = client.build_delta_pushes(&pending).await;
873 assert_eq!(msgs[0].checksum, expected_crc);
874 assert_ne!(msgs[0].checksum, 0);
875 }
876
877 #[tokio::test]
878 async fn flow_control_pauses_when_window_full() {
879 let client = SyncClient::with_flow_control(
880 make_config(),
881 1,
882 super::super::flow_control::FlowControlConfig {
883 max_in_flight: 2,
884 initial_batch_size: 10,
885 ..Default::default()
886 },
887 );
888 let pending = vec![
889 PendingDelta {
890 mutation_id: 1,
891 collection: "a".into(),
892 document_id: "d1".into(),
893 delta_bytes: vec![1],
894 },
895 PendingDelta {
896 mutation_id: 2,
897 collection: "a".into(),
898 document_id: "d2".into(),
899 delta_bytes: vec![2],
900 },
901 PendingDelta {
902 mutation_id: 3,
903 collection: "a".into(),
904 document_id: "d3".into(),
905 delta_bytes: vec![3],
906 },
907 ];
908
909 let msgs = client.build_delta_pushes(&pending).await;
911 assert_eq!(msgs.len(), 2);
912
913 client.record_push(&[1, 2]).await;
915
916 let msgs = client.build_delta_pushes(&pending).await;
918 assert_eq!(msgs.len(), 0);
919
920 client
922 .handle_delta_ack(&DeltaAckMsg {
923 mutation_id: 1,
924 lsn: 10,
925 })
926 .await;
927 let msgs = client.build_delta_pushes(&pending).await;
928 assert_eq!(msgs.len(), 1);
929 }
930
931 #[tokio::test]
932 async fn sync_metrics_snapshot() {
933 let client = SyncClient::new(make_config(), 1);
934 let snap = client.sync_metrics().await;
935 assert_eq!(snap.state, "disconnected");
936 assert_eq!(snap.pending_count, 0);
937 assert_eq!(snap.deltas_pushed, 0);
938 }
939}