1use std::{
4 collections::{BTreeMap, HashMap},
5 sync::Arc,
6 time::{Duration, SystemTime, UNIX_EPOCH},
7};
8
9use futures_util::{StreamExt, future::BoxFuture, stream};
10use inline_sdk::proto;
11use prost::Message as _;
12use tokio::sync::{Mutex, Semaphore};
13
14use crate::{
15 BackendError, BackendResult, ClientErrorCategory, ClientEvent, ClientEventDelivery,
16 ClientStore, InlineId, PendingSyncBatch, StoreError, SyncBucketKey, SyncBucketPeer,
17 SyncBucketState, SyncState,
18};
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct SyncConfig {
23 pub last_sync_safety_gap_seconds: i64,
25 pub max_concurrent_bucket_fetches: usize,
27 pub initial_lookback_seconds: i64,
29 pub stale_state_max_age_seconds: i64,
31 pub max_total_updates: i32,
33 pub page_limit: i32,
35 pub cold_start_total_limit: i32,
37 pub inconsistent_empty_retry_attempts: u32,
40 pub inconsistent_empty_retry_delay_ms: u64,
42}
43
44impl Default for SyncConfig {
45 fn default() -> Self {
46 Self {
47 last_sync_safety_gap_seconds: 15,
48 max_concurrent_bucket_fetches: 4,
49 initial_lookback_seconds: 5 * 24 * 60 * 60,
50 stale_state_max_age_seconds: 14 * 24 * 60 * 60,
51 max_total_updates: 1_000,
52 page_limit: 200,
53 cold_start_total_limit: 50,
54 inconsistent_empty_retry_attempts: 3,
55 inconsistent_empty_retry_delay_ms: 250,
56 }
57 }
58}
59
60pub(crate) trait SyncHost: Clone + Send + Sync + 'static {
61 fn get_updates_state(
62 &self,
63 date: i64,
64 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesStateResult>>;
65
66 fn get_updates(
67 &self,
68 input: proto::GetUpdatesInput,
69 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesResult>>;
70
71 fn apply_sync_batch(
72 &self,
73 updates: Vec<proto::Update>,
74 sidecars: Option<proto::UpdateSidecars>,
75 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>>;
76
77 fn repair_bucket(
80 &self,
81 key: SyncBucketKey,
82 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>>;
83}
84
85#[derive(Debug)]
86pub(crate) struct SyncManager {
87 store: Arc<dyn ClientStore>,
88 config: SyncConfig,
89 bucket_locks: Mutex<HashMap<SyncBucketKey, Arc<Mutex<()>>>>,
90 fetch_limiter: Arc<Semaphore>,
91 sync_state_lock: Mutex<()>,
92}
93
94impl SyncManager {
95 pub(crate) fn new(store: Arc<dyn ClientStore>, config: SyncConfig) -> Self {
96 let max_fetches = config.max_concurrent_bucket_fetches.max(1);
97 Self {
98 store,
99 config,
100 bucket_locks: Mutex::new(HashMap::new()),
101 fetch_limiter: Arc::new(Semaphore::new(max_fetches)),
102 sync_state_lock: Mutex::new(()),
103 }
104 }
105
106 pub(crate) async fn discover<H: SyncHost>(
107 &self,
108 host: &H,
109 ) -> BackendResult<Vec<ClientEventDelivery>> {
110 let mut events = self.recover_pending_batches(host).await?;
111 let state = self.prepared_sync_state().await?;
112 log::debug!(
113 "starting Inline update discovery from date={}",
114 state.last_sync_date
115 );
116 let result = host.get_updates_state(state.last_sync_date).await?;
117 validate_core_sync_schema(result.core_sync_schema_revision)?;
118 if result.updates_found == Some(false) {
119 self.update_last_sync_date(result.date).await?;
120 }
121 events.extend(
122 self.process_bucket(host, SyncBucketKey::User, Vec::new(), None, true)
123 .await?,
124 );
125 log::debug!(
126 "finished Inline update discovery date={} events={}",
127 result.date,
128 events.len()
129 );
130 Ok(events)
131 }
132
133 pub(crate) async fn process_realtime<H: SyncHost>(
134 &self,
135 host: &H,
136 updates: Vec<proto::Update>,
137 ) -> BackendResult<Vec<ClientEventDelivery>> {
138 let mut events = self.recover_pending_batches(host).await?;
139 let received_count = updates.len();
140 let mut direct = Vec::new();
141 let mut buckets = HashMap::<SyncBucketKey, Vec<proto::Update>>::new();
142 let mut targets = HashMap::<SyncBucketKey, i64>::new();
143
144 for update in updates {
145 match update.update.as_ref() {
146 Some(proto::update::Update::ChatHasNewUpdates(hint)) => {
147 if let Some(key) = chat_hint_bucket_key(hint) {
148 targets
149 .entry(key)
150 .and_modify(|seq| *seq = (*seq).max(i64::from(hint.update_seq)))
151 .or_insert(i64::from(hint.update_seq));
152 }
153 }
154 Some(proto::update::Update::SpaceHasNewUpdates(hint)) => {
155 let key = SyncBucketKey::Space {
156 space_id: InlineId::new(hint.space_id),
157 };
158 targets
159 .entry(key)
160 .and_modify(|seq| *seq = (*seq).max(i64::from(hint.update_seq)))
161 .or_insert(i64::from(hint.update_seq));
162 }
163 _ => {
164 let seq = update_seq(&update);
165 if seq > 0
166 && let Some(key) = bucket_key_for_update(&update)
167 {
168 buckets.entry(key).or_default().push(update);
169 } else {
170 direct.push(update);
171 }
172 }
173 }
174 }
175
176 if !direct.is_empty() {
177 let max_date = max_update_date(&direct);
178 let applied = host.apply_sync_batch(direct, None).await?;
179 let applied = self
180 .store
181 .append_client_events(applied)
182 .await
183 .map_err(store_error_to_backend)?;
184 self.update_last_sync_date(max_date).await?;
185 events.extend(applied);
186 }
187
188 for key in targets.keys() {
189 buckets.entry(*key).or_default();
190 }
191
192 let mut ordered = buckets
193 .into_iter()
194 .map(|(key, updates)| {
195 let target = targets.get(&key).copied();
196 (key, updates, target)
197 })
198 .collect::<Vec<_>>();
199 let bucket_count = ordered.len();
200 ordered.sort_by_key(|(key, _, _)| bucket_sort_key(*key));
201
202 if let Some(user_index) = ordered
203 .iter()
204 .position(|(key, _, _)| *key == SyncBucketKey::User)
205 {
206 let (key, updates, target) = ordered.remove(user_index);
207 events.extend(
208 self.process_bucket(host, key, updates, target, false)
209 .await?,
210 );
211 }
212
213 let concurrency = self.config.max_concurrent_bucket_fetches.max(1);
214 let mut results = stream::iter(ordered)
215 .map(|(key, updates, target)| async move {
216 (
217 bucket_sort_key(key),
218 self.process_bucket(host, key, updates, target, false).await,
219 )
220 })
221 .buffer_unordered(concurrency)
222 .collect::<Vec<_>>()
223 .await;
224 results.sort_by_key(|(sort_key, _)| *sort_key);
225 for (_, result) in results {
226 events.extend(result?);
227 }
228 log::debug!(
229 "processed Inline realtime batch received={received_count} buckets={bucket_count} events={}",
230 events.len()
231 );
232 Ok(events)
233 }
234
235 async fn process_bucket<H: SyncHost>(
236 &self,
237 host: &H,
238 key: SyncBucketKey,
239 realtime_updates: Vec<proto::Update>,
240 target_seq: Option<i64>,
241 force_fetch: bool,
242 ) -> BackendResult<Vec<ClientEventDelivery>> {
243 let bucket_lock = self.bucket_lock(key).await;
244 let _bucket_guard = bucket_lock.lock().await;
245 let mut state = self
246 .store
247 .sync_bucket_state(key)
248 .await
249 .map_err(store_error_to_backend)?;
250 let mut buffered = BTreeMap::new();
251 for update in realtime_updates {
252 let seq = update_seq(&update);
253 if seq > state.seq {
254 insert_unique_update(&mut buffered, seq, update, "realtime buffer")?;
255 }
256 }
257
258 let mut events = self
259 .drain_contiguous(host, key, &mut state, &mut buffered)
260 .await?;
261 let target_seq = target_seq
262 .filter(|target| *target > state.seq)
263 .or_else(|| buffered.last_key_value().map(|(seq, _)| *seq));
264 if !force_fetch && target_seq.is_none() && buffered.is_empty() && state.seq > 0 {
265 return Ok(events);
266 }
267
268 events.extend(
269 self.fetch_bucket(host, key, state, buffered, target_seq)
270 .await?,
271 );
272 Ok(events)
273 }
274
275 async fn drain_contiguous<H: SyncHost>(
276 &self,
277 host: &H,
278 key: SyncBucketKey,
279 state: &mut SyncBucketState,
280 buffered: &mut BTreeMap<i64, proto::Update>,
281 ) -> BackendResult<Vec<ClientEventDelivery>> {
282 let mut updates = Vec::new();
283 let mut next_seq = state.seq + 1;
284 while let Some(update) = buffered.remove(&next_seq) {
285 updates.push(update);
286 next_seq += 1;
287 }
288 if updates.is_empty() {
289 return Ok(Vec::new());
290 }
291
292 let committed = SyncBucketState {
293 seq: next_seq - 1,
294 date: state.date.max(max_update_date(&updates)),
295 };
296 let events = self
297 .commit_sync_batch(host, key, committed, updates, None)
298 .await?;
299 self.update_last_sync_date(committed.date).await?;
300 *state = committed;
301 Ok(events)
302 }
303
304 async fn fetch_bucket<H: SyncHost>(
305 &self,
306 host: &H,
307 key: SyncBucketKey,
308 initial: SyncBucketState,
309 buffered: BTreeMap<i64, proto::Update>,
310 target_seq: Option<i64>,
311 ) -> BackendResult<Vec<ClientEventDelivery>> {
312 let _permit = self
313 .fetch_limiter
314 .clone()
315 .acquire_owned()
316 .await
317 .map_err(|_| BackendError::new(ClientErrorCategory::Internal, "sync fetch stopped"))?;
318 let mut cold_start = initial.seq == 0 || initial.date == 0;
319 let mut committed_floor = initial.seq;
320 let mut current_seq = initial.seq;
321 let mut final_date = initial.date;
322 let hard_end = target_seq.or_else(|| buffered.last_key_value().map(|(seq, _)| *seq));
323 let mut buffered = buffered;
324 let mut slice_end = None;
325 let mut fetched = BTreeMap::<i64, proto::Update>::new();
326 let mut sidecars = proto::UpdateSidecars::default();
327 let mut page_count = 0_u64;
328 let mut inconsistent_empty_attempts = 0_u32;
329 let mut deliveries = Vec::new();
330
331 log::debug!(
332 "starting Inline bucket fetch kind={} start_seq={} target_seq={:?} cold_start={cold_start}",
333 bucket_kind(key),
334 current_seq,
335 hard_end
336 );
337
338 loop {
339 page_count += 1;
340 let request_end = slice_end.or(hard_end).filter(|end| *end > current_seq);
341 let response = host
342 .get_updates(proto::GetUpdatesInput {
343 bucket: Some(protocol_bucket(key)),
344 start_seq: current_seq,
345 total_limit: if cold_start && slice_end.is_none() {
346 self.config.cold_start_total_limit
347 } else {
348 self.config.max_total_updates
349 },
350 seq_end: request_end.unwrap_or_default(),
351 limit: self.config.page_limit,
352 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
353 })
354 .await?;
355 validate_core_sync_schema(response.core_sync_schema_revision)?;
356 let result_type = proto::get_updates_result::ResultType::try_from(response.result_type)
357 .unwrap_or(proto::get_updates_result::ResultType::Unspecified);
358
359 if result_type == proto::get_updates_result::ResultType::TooLong {
360 if response.seq <= current_seq {
361 return Err(BackendError::new(
362 ClientErrorCategory::ProtocolMismatch,
363 "bucket sync received a non-advancing TOO_LONG pointer",
364 ));
365 }
366 if cold_start {
367 let target_seq = response.seq;
368 let target_date = final_date.max(response.date);
369 log::warn!(
370 "repairing cold Inline bucket after TOO_LONG kind={} target_seq={target_seq}",
371 bucket_kind(key)
372 );
373 deliveries.extend(
374 self.repair_cold_bucket(host, key, target_seq, target_date)
375 .await?,
376 );
377 cold_start = false;
378 committed_floor = target_seq;
379 current_seq = target_seq;
380 final_date = target_date;
381 fetched.clear();
382 sidecars = proto::UpdateSidecars::default();
383 slice_end = None;
384 buffered.retain(|seq, _| *seq > target_seq);
385 if hard_end.is_none_or(|end| target_seq >= end) {
386 return Ok(deliveries);
387 }
388 continue;
389 }
390 let max_slice = current_seq + i64::from(self.config.max_total_updates);
391 slice_end = Some(response.seq.min(max_slice));
392 continue;
393 }
394
395 if response.seq < current_seq {
396 return Err(BackendError::new(
397 ClientErrorCategory::ProtocolMismatch,
398 "bucket sync server cursor moved backwards",
399 ));
400 }
401 let previous_seq = current_seq;
402 let accounting = validate_response_page(previous_seq, &response)?;
403 current_seq = response.seq;
404 final_date = final_date.max(response.date);
405 let skipped_sequences = response
406 .skipped_sequences
407 .iter()
408 .map(|skipped| skipped.seq)
409 .collect::<std::collections::BTreeSet<_>>();
410 let page_sidecars = response.sidecars;
411 let page_updates = response.updates;
412
413 let empty = result_type == proto::get_updates_result::ResultType::Empty;
414 if accounting.requires_snapshot_repair {
415 log::warn!(
416 "repairing Inline bucket after server-classified snapshot gap kind={} target_seq={current_seq}",
417 bucket_kind(key)
418 );
419 deliveries.extend(
420 self.repair_cold_bucket(host, key, current_seq, final_date)
421 .await?,
422 );
423 return Ok(deliveries);
424 }
425
426 if cold_start {
427 if let Some(page_sidecars) = page_sidecars {
428 merge_sidecars(&mut sidecars, page_sidecars);
429 }
430 for update in page_updates {
431 let seq = update_seq(&update);
432 if seq > committed_floor {
433 insert_unique_update(&mut fetched, seq, update, "fetched page")?;
434 }
435 }
436 } else if current_seq > previous_seq {
437 let mut page = BTreeMap::<i64, proto::Update>::new();
438 for update in page_updates {
439 let seq = update_seq(&update);
440 insert_unique_update(&mut page, seq, update, "fetched page")?;
441 }
442 let buffered_sequences = buffered
443 .range(..=current_seq)
444 .map(|(seq, _)| *seq)
445 .collect::<Vec<_>>();
446 for seq in &buffered_sequences {
447 if skipped_sequences.contains(seq) {
448 return Err(BackendError::new(
449 ClientErrorCategory::ProtocolMismatch,
450 "realtime update conflicted with a server-classified skipped sequence",
451 ));
452 }
453 if let Some(update) = buffered.get(seq).cloned() {
454 insert_unique_update(&mut page, *seq, update, "fetched/realtime merge")?;
455 }
456 }
457 let updates = page.into_values().collect::<Vec<_>>();
458 let committed = SyncBucketState {
459 seq: current_seq,
460 date: final_date.max(max_update_date(&updates)),
461 };
462 let has_sidecars = page_sidecars.as_ref().is_some_and(has_sidecars);
463 let page_deliveries = self
464 .commit_sync_batch(
465 host,
466 key,
467 committed,
468 updates,
469 has_sidecars.then_some(page_sidecars).flatten(),
470 )
471 .await?;
472 self.update_last_sync_date(committed.date).await?;
473 deliveries.extend(page_deliveries);
474 for seq in buffered_sequences {
475 buffered.remove(&seq);
476 }
477 committed_floor = committed.seq;
478 }
479 let final_page = response.r#final.unwrap_or(false) || empty;
480 if final_page && hard_end.is_some_and(|target| current_seq < target) {
481 if current_seq > previous_seq {
482 inconsistent_empty_attempts = 0;
483 }
484 inconsistent_empty_attempts += 1;
485 if inconsistent_empty_attempts <= self.config.inconsistent_empty_retry_attempts {
486 log::warn!(
487 "retrying Inline final page behind hint kind={} server_seq={} target_seq={:?} attempt={}",
488 bucket_kind(key),
489 current_seq,
490 hard_end,
491 inconsistent_empty_attempts
492 );
493 if self.config.inconsistent_empty_retry_delay_ms > 0 {
494 tokio::time::sleep(Duration::from_millis(
495 self.config.inconsistent_empty_retry_delay_ms,
496 ))
497 .await;
498 }
499 continue;
500 }
501 return Err(BackendError::new(
502 ClientErrorCategory::ProtocolMismatch,
503 "bucket sync final response remained behind the requested target",
504 ));
505 }
506 inconsistent_empty_attempts = 0;
507 if current_seq == previous_seq && !final_page {
508 return Err(BackendError::new(
509 ClientErrorCategory::ProtocolMismatch,
510 "bucket sync received a non-progress response",
511 ));
512 }
513 if let Some(end) = slice_end
514 && current_seq >= end
515 {
516 slice_end = None;
517 if hard_end.is_some_and(|target| current_seq < target) {
518 continue;
519 }
520 }
521 if hard_end.is_some_and(|target| current_seq >= target) || final_page {
522 break;
523 }
524 }
525
526 if !cold_start {
527 log::debug!(
528 "finished incremental Inline bucket fetch kind={} pages={page_count} end_seq={} events={}",
529 bucket_kind(key),
530 committed_floor,
531 deliveries.len()
532 );
533 return Ok(deliveries);
534 }
535
536 for (seq, update) in buffered {
537 if seq > committed_floor && seq <= current_seq {
538 insert_unique_update(&mut fetched, seq, update, "fetched/realtime merge")?;
539 }
540 }
541 let updates = fetched.into_values().collect::<Vec<_>>();
542 let max_delivered_seq = updates.iter().map(update_seq).max().unwrap_or(initial.seq);
543 if current_seq > max_delivered_seq {
544 log::warn!(
545 "advancing cold Inline bucket pointer with explicit sequence accounting kind={} delivered_seq={max_delivered_seq} pointer_seq={current_seq}",
546 bucket_kind(key)
547 );
548 }
549 let committed = SyncBucketState {
550 seq: current_seq,
551 date: final_date.max(max_update_date(&updates)),
552 };
553 if cold_start {
554 let events = self
555 .repair_cold_bucket(host, key, committed.seq, committed.date)
556 .await?;
557 log::debug!(
558 "finished cold Inline bucket snapshot kind={} pages={page_count} end_seq={} events={}",
559 bucket_kind(key),
560 committed.seq,
561 events.len()
562 );
563 deliveries.extend(events);
564 return Ok(deliveries);
565 }
566 let has_sidecars = has_sidecars(&sidecars);
567 let update_count = updates.len();
568 let events = self
569 .commit_sync_batch(
570 host,
571 key,
572 committed,
573 updates,
574 has_sidecars.then_some(sidecars),
575 )
576 .await?;
577 self.update_last_sync_date(committed.date).await?;
578 log::debug!(
579 "finished Inline bucket fetch kind={} pages={page_count} end_seq={} updates={} events={}",
580 bucket_kind(key),
581 committed.seq,
582 update_count,
583 events.len()
584 );
585 deliveries.extend(events);
586 Ok(deliveries)
587 }
588
589 async fn repair_cold_bucket<H: SyncHost>(
590 &self,
591 host: &H,
592 key: SyncBucketKey,
593 target_seq: i64,
594 target_date: i64,
595 ) -> BackendResult<Vec<ClientEventDelivery>> {
596 let events = host.repair_bucket(key).await?;
597 self.commit_cold_pointer(key, target_seq, target_date, events)
598 .await
599 }
600
601 async fn commit_cold_pointer(
602 &self,
603 key: SyncBucketKey,
604 target_seq: i64,
605 target_date: i64,
606 events: Vec<ClientEvent>,
607 ) -> BackendResult<Vec<ClientEventDelivery>> {
608 let committed = SyncBucketState {
609 seq: target_seq,
610 date: target_date,
611 };
612 let deliveries = self
616 .store
617 .commit_pending_sync_batch_with_events(key, committed, events)
618 .await
619 .map_err(store_error_to_backend)?;
620 self.update_last_sync_date(committed.date).await?;
621 Ok(deliveries)
622 }
623
624 async fn commit_sync_batch<H: SyncHost>(
625 &self,
626 host: &H,
627 key: SyncBucketKey,
628 committed_state: SyncBucketState,
629 updates: Vec<proto::Update>,
630 sidecars: Option<proto::UpdateSidecars>,
631 ) -> BackendResult<Vec<ClientEventDelivery>> {
632 validate_journal_updates(&updates)?;
633 let payload = encode_pending_payload(&updates, sidecars.as_ref())?;
634 self.store
635 .save_pending_sync_batch(PendingSyncBatch {
636 key,
637 committed_state,
638 payload,
639 })
640 .await
641 .map_err(store_error_to_backend)?;
642 let events = match host.apply_sync_batch(updates, sidecars).await {
643 Ok(events) => events,
644 Err(error) => {
645 if is_permanent_sync_error(&error) {
646 self.store
647 .discard_pending_sync_batch(key)
648 .await
649 .map_err(store_error_to_backend)?;
650 }
651 return Err(error);
652 }
653 };
654 let deliveries = self
655 .store
656 .commit_pending_sync_batch_with_events(key, committed_state, events)
657 .await
658 .map_err(store_error_to_backend)?;
659 Ok(deliveries)
660 }
661
662 async fn recover_pending_batches<H: SyncHost>(
663 &self,
664 host: &H,
665 ) -> BackendResult<Vec<ClientEventDelivery>> {
666 let batches = self
667 .store
668 .pending_sync_batches()
669 .await
670 .map_err(store_error_to_backend)?;
671 let mut events = Vec::new();
672 let mut refetch = BTreeMap::<(u8, i64), (SyncBucketKey, i64)>::new();
673 for batch in batches {
674 let decoded = decode_pending_payload(&batch.payload).and_then(|(updates, sidecars)| {
675 validate_journal_updates(&updates)?;
676 Ok((updates, sidecars))
677 });
678 let (updates, sidecars) = match decoded {
679 Ok(decoded) => decoded,
680 Err(error) if is_permanent_sync_error(&error) => {
681 log::warn!(
682 "discarding incompatible Inline sync journal kind={} target_seq={}",
683 bucket_kind(batch.key),
684 batch.committed_state.seq
685 );
686 self.store
687 .discard_pending_sync_batch(batch.key)
688 .await
689 .map_err(store_error_to_backend)?;
690 refetch.insert(
691 bucket_sort_key(batch.key),
692 (batch.key, batch.committed_state.seq),
693 );
694 continue;
695 }
696 Err(error) => return Err(error),
697 };
698 let applied = match host.apply_sync_batch(updates, sidecars).await {
699 Ok(applied) => applied,
700 Err(error) if is_permanent_sync_error(&error) => {
701 log::warn!(
702 "discarding permanently failing Inline sync journal kind={} target_seq={}",
703 bucket_kind(batch.key),
704 batch.committed_state.seq
705 );
706 self.store
707 .discard_pending_sync_batch(batch.key)
708 .await
709 .map_err(store_error_to_backend)?;
710 refetch.insert(
711 bucket_sort_key(batch.key),
712 (batch.key, batch.committed_state.seq),
713 );
714 continue;
715 }
716 Err(error) => return Err(error),
717 };
718 events.extend(
719 self.store
720 .commit_pending_sync_batch_with_events(
721 batch.key,
722 batch.committed_state,
723 applied,
724 )
725 .await
726 .map_err(store_error_to_backend)?,
727 );
728 self.update_last_sync_date(batch.committed_state.date)
729 .await?;
730 }
731 for (_, (key, target_seq)) in refetch {
732 events.extend(
733 self.process_bucket(host, key, Vec::new(), Some(target_seq), true)
734 .await?,
735 );
736 }
737 Ok(events)
738 }
739
740 async fn bucket_lock(&self, key: SyncBucketKey) -> Arc<Mutex<()>> {
741 let mut locks = self.bucket_locks.lock().await;
742 locks
743 .entry(key)
744 .or_insert_with(|| Arc::new(Mutex::new(())))
745 .clone()
746 }
747
748 async fn prepared_sync_state(&self) -> BackendResult<SyncState> {
749 let mut state = self
750 .store
751 .sync_state()
752 .await
753 .map_err(store_error_to_backend)?;
754 let now = now_seconds();
755 if state.last_sync_date == 0
756 || now.saturating_sub(state.last_sync_date) > self.config.stale_state_max_age_seconds
757 {
758 state.last_sync_date = now.saturating_sub(self.config.initial_lookback_seconds);
759 self.store
760 .save_sync_state(state)
761 .await
762 .map_err(store_error_to_backend)?;
763 }
764 Ok(state)
765 }
766
767 async fn update_last_sync_date(&self, max_applied_date: i64) -> BackendResult<()> {
768 if max_applied_date <= 0 {
769 return Ok(());
770 }
771 let _guard = self.sync_state_lock.lock().await;
772 let current = self
773 .store
774 .sync_state()
775 .await
776 .map_err(store_error_to_backend)?;
777 let proposed = max_applied_date.saturating_sub(self.config.last_sync_safety_gap_seconds);
778 if proposed <= current.last_sync_date {
779 return Ok(());
780 }
781 self.store
782 .save_sync_state(SyncState {
783 last_sync_date: proposed,
784 })
785 .await
786 .map_err(store_error_to_backend)
787 }
788}
789
790fn bucket_key_for_update(update: &proto::Update) -> Option<SyncBucketKey> {
791 use proto::update::Update;
792 match update.update.as_ref()? {
793 Update::NewMessage(value) => value
794 .message
795 .as_ref()?
796 .peer_id
797 .as_ref()
798 .and_then(chat_bucket),
799 Update::EditMessage(value) => value
800 .message
801 .as_ref()?
802 .peer_id
803 .as_ref()
804 .and_then(chat_bucket),
805 Update::DeleteMessages(value) => value.peer_id.as_ref().and_then(chat_bucket),
806 Update::ClearChatHistory(value) => match value.target.as_ref()? {
807 proto::update_clear_chat_history::Target::PeerId(peer) => chat_bucket(peer),
808 proto::update_clear_chat_history::Target::SpaceId(space_id) => {
809 Some(SyncBucketKey::Space {
810 space_id: InlineId::new(*space_id),
811 })
812 }
813 },
814 Update::MessageAttachment(value) => value.peer_id.as_ref().and_then(chat_bucket),
815 Update::UpdateReaction(value) => value
816 .reaction
817 .as_ref()
818 .map(|reaction| chat_key(reaction.chat_id)),
819 Update::DeleteReaction(value) => Some(chat_key(value.chat_id)),
820 Update::DeleteChat(value) => value.peer_id.as_ref().and_then(chat_bucket),
821 Update::MarkAsUnread(value) => value.peer_id.as_ref().and_then(chat_bucket),
822 Update::SpaceMemberAdd(value) => value
823 .member
824 .as_ref()
825 .map(|member| space_key(member.space_id)),
826 Update::SpaceMemberDelete(value) => Some(space_key(value.space_id)),
827 Update::SpaceMemberUpdate(value) => value
828 .member
829 .as_ref()
830 .map(|member| space_key(member.space_id)),
831 Update::JoinSpace(_)
832 | Update::UpdateUserStatus(_)
833 | Update::UpdateUserSettings(_)
834 | Update::UpdatedUser(_)
835 | Update::DialogArchived(_)
836 | Update::DialogNotificationSettings(_)
837 | Update::DialogFollowMode(_)
838 | Update::UpdateReadMaxId(_)
839 | Update::ChatOpen(_) => Some(SyncBucketKey::User),
840 Update::NewChat(value) => value.chat.as_ref()?.peer_id.as_ref().and_then(chat_bucket),
841 Update::ChatMoved(value) => value.chat.as_ref()?.peer_id.as_ref().and_then(chat_bucket),
842 Update::ParticipantAdd(value) => Some(chat_key(value.chat_id)),
843 Update::ParticipantDelete(value) => Some(chat_key(value.chat_id)),
844 Update::ParticipantGroupAdd(value) => Some(chat_key(value.chat_id)),
845 Update::ParticipantGroupDelete(value) => Some(chat_key(value.chat_id)),
846 Update::ChatVisibility(value) => Some(chat_key(value.chat_id)),
847 Update::ChatInfo(value) => Some(chat_key(value.chat_id)),
848 Update::PinnedMessages(value) => value.peer_id.as_ref().and_then(chat_bucket),
849 Update::ChatSkipPts(value) => Some(chat_key(value.chat_id)),
850 Update::SpaceSettings(value) => Some(space_key(value.space_id)),
851 _ => None,
852 }
853}
854
855fn chat_hint_bucket_key(hint: &proto::UpdateChatHasNewUpdates) -> Option<SyncBucketKey> {
856 hint.peer_id
857 .as_ref()
858 .and_then(chat_bucket)
859 .or_else(|| (hint.chat_id != 0).then(|| chat_key(hint.chat_id)))
860}
861
862fn chat_bucket(peer: &proto::Peer) -> Option<SyncBucketKey> {
863 let peer = match peer.r#type.as_ref()? {
864 proto::peer::Type::User(user) => SyncBucketPeer::User {
865 user_id: InlineId::new(user.user_id),
866 },
867 proto::peer::Type::Chat(chat) => SyncBucketPeer::Chat {
868 chat_id: InlineId::new(chat.chat_id),
869 },
870 };
871 Some(SyncBucketKey::Chat { peer })
872}
873
874fn chat_key(chat_id: i64) -> SyncBucketKey {
875 SyncBucketKey::Chat {
876 peer: SyncBucketPeer::Chat {
877 chat_id: InlineId::new(chat_id),
878 },
879 }
880}
881
882fn space_key(space_id: i64) -> SyncBucketKey {
883 SyncBucketKey::Space {
884 space_id: InlineId::new(space_id),
885 }
886}
887
888fn protocol_bucket(key: SyncBucketKey) -> proto::UpdateBucket {
889 let r#type = match key {
890 SyncBucketKey::User => proto::update_bucket::Type::User(proto::UpdateBucketUser {}),
891 SyncBucketKey::Space { space_id } => {
892 proto::update_bucket::Type::Space(proto::UpdateBucketSpace {
893 space_id: space_id.get(),
894 })
895 }
896 SyncBucketKey::Chat { peer } => {
897 let r#type = match peer {
898 SyncBucketPeer::User { user_id } => {
899 proto::input_peer::Type::User(proto::InputPeerUser {
900 user_id: user_id.get(),
901 })
902 }
903 SyncBucketPeer::Chat { chat_id } => {
904 proto::input_peer::Type::Chat(proto::InputPeerChat {
905 chat_id: chat_id.get(),
906 })
907 }
908 };
909 proto::update_bucket::Type::Chat(proto::UpdateBucketChat {
910 peer_id: Some(proto::InputPeer {
911 r#type: Some(r#type),
912 }),
913 })
914 }
915 };
916 proto::UpdateBucket {
917 r#type: Some(r#type),
918 }
919}
920
921fn update_seq(update: &proto::Update) -> i64 {
922 i64::from(update.seq.unwrap_or_default())
923}
924
925#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
926struct PageAccounting {
927 requires_snapshot_repair: bool,
928}
929
930fn validate_response_page(
931 previous_seq: i64,
932 response: &proto::GetUpdatesResult,
933) -> BackendResult<PageAccounting> {
934 use proto::get_updates_result::ResultType;
935 use proto::sync_skipped_sequence::Reason;
936
937 let result_type = ResultType::try_from(response.result_type).map_err(|_| {
938 BackendError::new(
939 ClientErrorCategory::ProtocolMismatch,
940 "bucket sync response used an unknown result type",
941 )
942 })?;
943 if !matches!(result_type, ResultType::Slice | ResultType::Empty) {
944 return Err(BackendError::new(
945 ClientErrorCategory::ProtocolMismatch,
946 "bucket sync response used an invalid page result type",
947 ));
948 }
949 if response.seq < previous_seq {
950 return Err(BackendError::new(
951 ClientErrorCategory::ProtocolMismatch,
952 "bucket sync page cursor moved backwards",
953 ));
954 }
955
956 let mut accounted = BTreeMap::<i64, &'static str>::new();
957 for update in &response.updates {
958 let seq = update_seq(update);
959 validate_page_sequence(previous_seq, response.seq, seq)?;
960 if accounted.insert(seq, "update").is_some() {
961 return Err(BackendError::new(
962 ClientErrorCategory::ProtocolMismatch,
963 "bucket sync page contained a duplicate sequence",
964 ));
965 }
966 }
967
968 let mut requires_snapshot_repair = false;
969 for skipped in &response.skipped_sequences {
970 validate_page_sequence(previous_seq, response.seq, skipped.seq)?;
971 let reason = Reason::try_from(skipped.reason).map_err(|_| {
972 BackendError::new(
973 ClientErrorCategory::ProtocolMismatch,
974 "bucket sync page used an unknown skipped-sequence reason",
975 )
976 })?;
977 match reason {
978 Reason::IrrelevantToBucket => {}
979 Reason::SnapshotRepairRequired => requires_snapshot_repair = true,
980 Reason::Unspecified => {
981 return Err(BackendError::new(
982 ClientErrorCategory::ProtocolMismatch,
983 "bucket sync page used an unspecified skipped-sequence reason",
984 ));
985 }
986 }
987 if accounted.insert(skipped.seq, "skip").is_some() {
988 return Err(BackendError::new(
989 ClientErrorCategory::ProtocolMismatch,
990 "bucket sync page accounted for one sequence more than once",
991 ));
992 }
993 }
994
995 let delta = response.seq.saturating_sub(previous_seq);
996 let accounted_count = i64::try_from(accounted.len()).map_err(|_| {
997 BackendError::new(
998 ClientErrorCategory::ProtocolMismatch,
999 "bucket sync page sequence count overflowed",
1000 )
1001 })?;
1002 if accounted_count != delta {
1003 return Err(BackendError::new(
1004 ClientErrorCategory::ProtocolMismatch,
1005 "bucket sync page did not account for every advanced sequence",
1006 ));
1007 }
1008
1009 Ok(PageAccounting {
1010 requires_snapshot_repair,
1011 })
1012}
1013
1014fn validate_page_sequence(previous_seq: i64, response_seq: i64, seq: i64) -> BackendResult<()> {
1015 if seq <= previous_seq || seq > response_seq {
1016 return Err(BackendError::new(
1017 ClientErrorCategory::ProtocolMismatch,
1018 "bucket sync page contained a sequence outside its cursor envelope",
1019 ));
1020 }
1021 Ok(())
1022}
1023
1024fn insert_unique_update(
1025 updates: &mut BTreeMap<i64, proto::Update>,
1026 seq: i64,
1027 update: proto::Update,
1028 source: &'static str,
1029) -> BackendResult<()> {
1030 if let Some(existing) = updates.get(&seq) {
1031 if existing == &update {
1032 return Ok(());
1033 }
1034 return Err(BackendError::new(
1035 ClientErrorCategory::ProtocolMismatch,
1036 format!("conflicting Inline updates shared sequence {seq} in {source}"),
1037 ));
1038 }
1039 updates.insert(seq, update);
1040 Ok(())
1041}
1042
1043fn validate_journal_updates(updates: &[proto::Update]) -> BackendResult<()> {
1044 for update in updates {
1045 if update_seq(update) <= 0 {
1046 return Err(BackendError::new(
1047 ClientErrorCategory::ProtocolMismatch,
1048 "lossless sync journal contained an unsequenced update",
1049 ));
1050 }
1051 match update.update.as_ref() {
1052 Some(proto::update::Update::ChatHasNewUpdates(_))
1053 | Some(proto::update::Update::SpaceHasNewUpdates(_)) => {
1054 return Err(BackendError::new(
1055 ClientErrorCategory::ProtocolMismatch,
1056 "lossless sync journal contained a realtime hint",
1057 ));
1058 }
1059 Some(_) => {}
1060 None => {
1061 return Err(BackendError::new(
1062 ClientErrorCategory::ProtocolMismatch,
1063 "lossless sync journal contained an unknown or missing update payload",
1064 ));
1065 }
1066 }
1067 }
1068 Ok(())
1069}
1070
1071fn is_permanent_sync_error(error: &BackendError) -> bool {
1072 matches!(
1073 error.category,
1074 ClientErrorCategory::ProtocolMismatch | ClientErrorCategory::Unsupported
1075 )
1076}
1077
1078fn max_update_date(updates: &[proto::Update]) -> i64 {
1079 updates
1080 .iter()
1081 .filter_map(|update| update.date)
1082 .max()
1083 .unwrap_or_default()
1084}
1085
1086fn merge_sidecars(target: &mut proto::UpdateSidecars, page: proto::UpdateSidecars) {
1087 target.users.extend(page.users);
1088 target.chats.extend(page.chats);
1089 target.dialogs.extend(page.dialogs);
1090 target.spaces.extend(page.spaces);
1091 target.user_groups.extend(page.user_groups);
1092}
1093
1094fn has_sidecars(sidecars: &proto::UpdateSidecars) -> bool {
1095 !sidecars.users.is_empty()
1096 || !sidecars.chats.is_empty()
1097 || !sidecars.dialogs.is_empty()
1098 || !sidecars.spaces.is_empty()
1099 || !sidecars.user_groups.is_empty()
1100}
1101
1102fn bucket_sort_key(key: SyncBucketKey) -> (u8, i64) {
1103 match key {
1104 SyncBucketKey::User => (0, 0),
1105 SyncBucketKey::Space { space_id } => (1, space_id.get()),
1106 SyncBucketKey::Chat {
1107 peer: SyncBucketPeer::User { user_id },
1108 } => (2, user_id.get()),
1109 SyncBucketKey::Chat {
1110 peer: SyncBucketPeer::Chat { chat_id },
1111 } => (3, chat_id.get()),
1112 }
1113}
1114
1115fn bucket_kind(key: SyncBucketKey) -> &'static str {
1116 match key {
1117 SyncBucketKey::User => "user",
1118 SyncBucketKey::Space { .. } => "space",
1119 SyncBucketKey::Chat { .. } => "chat",
1120 }
1121}
1122
1123fn store_error_to_backend(error: StoreError) -> BackendError {
1124 BackendError::new(error.category, error.message)
1125}
1126
1127fn validate_core_sync_schema(server_revision: u32) -> BackendResult<()> {
1128 if server_revision == crate::CORE_SYNC_SCHEMA_REVISION {
1129 return Ok(());
1130 }
1131 Err(BackendError::new(
1132 ClientErrorCategory::ProtocolMismatch,
1133 format!(
1134 "incompatible Inline sync schema: client={} server={server_revision}",
1135 crate::CORE_SYNC_SCHEMA_REVISION
1136 ),
1137 ))
1138}
1139
1140fn now_seconds() -> i64 {
1141 SystemTime::now()
1142 .duration_since(UNIX_EPOCH)
1143 .unwrap_or_default()
1144 .as_secs() as i64
1145}
1146
1147const PENDING_SYNC_PAYLOAD_MAGIC: &[u8; 4] = b"ISB1";
1148const MAX_PENDING_SYNC_ITEMS: usize = 100_000;
1149const MAX_PENDING_SYNC_ITEM_BYTES: usize = 64 * 1024 * 1024;
1150
1151fn encode_pending_payload(
1152 updates: &[proto::Update],
1153 sidecars: Option<&proto::UpdateSidecars>,
1154) -> BackendResult<Vec<u8>> {
1155 if updates.len() > MAX_PENDING_SYNC_ITEMS {
1156 return Err(BackendError::new(
1157 ClientErrorCategory::Internal,
1158 "sync batch is too large to journal",
1159 ));
1160 }
1161 let mut payload = Vec::new();
1162 payload.extend_from_slice(PENDING_SYNC_PAYLOAD_MAGIC);
1163 append_u32(&mut payload, updates.len())?;
1164 for update in updates {
1165 let encoded = update.encode_to_vec();
1166 append_bytes(&mut payload, &encoded)?;
1167 }
1168 match sidecars {
1169 Some(sidecars) => {
1170 payload.push(1);
1171 append_bytes(&mut payload, &sidecars.encode_to_vec())?;
1172 }
1173 None => payload.push(0),
1174 }
1175 Ok(payload)
1176}
1177
1178fn decode_pending_payload(
1179 payload: &[u8],
1180) -> BackendResult<(Vec<proto::Update>, Option<proto::UpdateSidecars>)> {
1181 if !payload.starts_with(PENDING_SYNC_PAYLOAD_MAGIC) {
1182 return Err(invalid_pending_payload("unsupported journal version"));
1183 }
1184 let mut offset = PENDING_SYNC_PAYLOAD_MAGIC.len();
1185 let count = read_u32(payload, &mut offset)?;
1186 if count > MAX_PENDING_SYNC_ITEMS {
1187 return Err(invalid_pending_payload("update count exceeds safety limit"));
1188 }
1189 let mut updates = Vec::with_capacity(count);
1190 for _ in 0..count {
1191 let bytes = read_bytes(payload, &mut offset)?;
1192 updates.push(
1193 proto::Update::decode(bytes)
1194 .map_err(|_| invalid_pending_payload("update protobuf is invalid"))?,
1195 );
1196 }
1197 let sidecars = match payload.get(offset).copied() {
1198 Some(0) => {
1199 offset += 1;
1200 None
1201 }
1202 Some(1) => {
1203 offset += 1;
1204 let bytes = read_bytes(payload, &mut offset)?;
1205 Some(
1206 proto::UpdateSidecars::decode(bytes)
1207 .map_err(|_| invalid_pending_payload("sidecars protobuf is invalid"))?,
1208 )
1209 }
1210 _ => return Err(invalid_pending_payload("sidecars flag is invalid")),
1211 };
1212 if offset != payload.len() {
1213 return Err(invalid_pending_payload("journal has trailing bytes"));
1214 }
1215 Ok((updates, sidecars))
1216}
1217
1218fn append_u32(payload: &mut Vec<u8>, value: usize) -> BackendResult<()> {
1219 let value = u32::try_from(value).map_err(|_| {
1220 BackendError::new(
1221 ClientErrorCategory::Internal,
1222 "sync journal length exceeds u32",
1223 )
1224 })?;
1225 payload.extend_from_slice(&value.to_le_bytes());
1226 Ok(())
1227}
1228
1229fn append_bytes(payload: &mut Vec<u8>, bytes: &[u8]) -> BackendResult<()> {
1230 if bytes.len() > MAX_PENDING_SYNC_ITEM_BYTES {
1231 return Err(BackendError::new(
1232 ClientErrorCategory::Internal,
1233 "sync journal item exceeds safety limit",
1234 ));
1235 }
1236 append_u32(payload, bytes.len())?;
1237 payload.extend_from_slice(bytes);
1238 Ok(())
1239}
1240
1241fn read_u32(payload: &[u8], offset: &mut usize) -> BackendResult<usize> {
1242 let end = offset.saturating_add(4);
1243 let bytes = payload
1244 .get(*offset..end)
1245 .ok_or_else(|| invalid_pending_payload("journal is truncated"))?;
1246 *offset = end;
1247 Ok(u32::from_le_bytes(
1248 bytes
1249 .try_into()
1250 .map_err(|_| invalid_pending_payload("length is invalid"))?,
1251 ) as usize)
1252}
1253
1254fn read_bytes<'a>(payload: &'a [u8], offset: &mut usize) -> BackendResult<&'a [u8]> {
1255 let length = read_u32(payload, offset)?;
1256 if length > MAX_PENDING_SYNC_ITEM_BYTES {
1257 return Err(invalid_pending_payload("journal item exceeds safety limit"));
1258 }
1259 let end = offset.saturating_add(length);
1260 let bytes = payload
1261 .get(*offset..end)
1262 .ok_or_else(|| invalid_pending_payload("journal item is truncated"))?;
1263 *offset = end;
1264 Ok(bytes)
1265}
1266
1267fn invalid_pending_payload(message: &'static str) -> BackendError {
1268 BackendError::new(
1269 ClientErrorCategory::ProtocolMismatch,
1270 format!("invalid pending sync journal: {message}"),
1271 )
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use std::{
1277 collections::VecDeque,
1278 sync::{
1279 Arc,
1280 atomic::{AtomicBool, AtomicUsize, Ordering},
1281 },
1282 };
1283
1284 use super::*;
1285 use crate::InMemoryStore;
1286
1287 #[derive(Clone, Debug)]
1288 struct FakeHost {
1289 state: proto::GetUpdatesStateResult,
1290 responses: Arc<Mutex<VecDeque<BackendResult<proto::GetUpdatesResult>>>>,
1291 requests: Arc<Mutex<Vec<proto::GetUpdatesInput>>>,
1292 applied: Arc<Mutex<Vec<Vec<proto::Update>>>>,
1293 repaired_buckets: Arc<Mutex<Vec<SyncBucketKey>>>,
1294 fail_apply: Arc<AtomicBool>,
1295 fetch_delay_ms: u64,
1296 fetches_in_flight: Arc<AtomicUsize>,
1297 max_fetches_in_flight: Arc<AtomicUsize>,
1298 }
1299
1300 impl FakeHost {
1301 fn new(responses: Vec<proto::GetUpdatesResult>) -> Self {
1302 Self {
1303 state: proto::GetUpdatesStateResult {
1304 date: 100,
1305 updates_found: Some(true),
1306 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1307 },
1308 responses: Arc::new(Mutex::new(
1309 responses.into_iter().map(Ok).collect::<VecDeque<_>>(),
1310 )),
1311 requests: Arc::new(Mutex::new(Vec::new())),
1312 applied: Arc::new(Mutex::new(Vec::new())),
1313 repaired_buckets: Arc::new(Mutex::new(Vec::new())),
1314 fail_apply: Arc::new(AtomicBool::new(false)),
1315 fetch_delay_ms: 0,
1316 fetches_in_flight: Arc::new(AtomicUsize::new(0)),
1317 max_fetches_in_flight: Arc::new(AtomicUsize::new(0)),
1318 }
1319 }
1320
1321 fn failing_apply(mut self) -> Self {
1322 self.fail_apply = Arc::new(AtomicBool::new(true));
1323 self
1324 }
1325
1326 fn with_fetch_delay(mut self, delay_ms: u64) -> Self {
1327 self.fetch_delay_ms = delay_ms;
1328 self
1329 }
1330 }
1331
1332 impl SyncHost for FakeHost {
1333 fn get_updates_state(
1334 &self,
1335 _date: i64,
1336 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesStateResult>> {
1337 let state = self.state.clone();
1338 Box::pin(async move { Ok(state) })
1339 }
1340
1341 fn get_updates(
1342 &self,
1343 input: proto::GetUpdatesInput,
1344 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesResult>> {
1345 let host = self.clone();
1346 Box::pin(async move {
1347 let in_flight = host.fetches_in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1348 host.max_fetches_in_flight
1349 .fetch_max(in_flight, Ordering::SeqCst);
1350 if host.fetch_delay_ms > 0 {
1351 tokio::time::sleep(std::time::Duration::from_millis(host.fetch_delay_ms)).await;
1352 }
1353 host.requests.lock().await.push(input);
1354 let response = host.responses.lock().await.pop_front().unwrap_or_else(|| {
1355 Err(BackendError::new(
1356 ClientErrorCategory::Internal,
1357 "missing fake getUpdates response",
1358 ))
1359 });
1360 host.fetches_in_flight.fetch_sub(1, Ordering::SeqCst);
1361 response
1362 })
1363 }
1364
1365 fn apply_sync_batch(
1366 &self,
1367 updates: Vec<proto::Update>,
1368 _sidecars: Option<proto::UpdateSidecars>,
1369 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1370 let host = self.clone();
1371 Box::pin(async move {
1372 if host.fail_apply.load(Ordering::Relaxed) {
1373 return Err(BackendError::new(
1374 ClientErrorCategory::Internal,
1375 "fake apply failure",
1376 ));
1377 }
1378 host.applied.lock().await.push(updates.clone());
1379 Ok(updates
1380 .iter()
1381 .filter_map(|update| match update.update.as_ref() {
1382 Some(proto::update::Update::NewMessage(update)) => {
1383 let message = update.message.as_ref()?;
1384 Some(ClientEvent::MessageUpserted {
1385 chat_id: InlineId::new(message.chat_id),
1386 message_id: InlineId::new(message.id),
1387 })
1388 }
1389 _ => None,
1390 })
1391 .collect())
1392 })
1393 }
1394
1395 fn repair_bucket(
1396 &self,
1397 key: SyncBucketKey,
1398 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1399 let host = self.clone();
1400 Box::pin(async move {
1401 host.repaired_buckets.lock().await.push(key);
1402 Ok(Vec::new())
1403 })
1404 }
1405 }
1406
1407 #[tokio::test]
1408 async fn hint_only_recovery_pages_to_target_and_persists_cursor() {
1409 let store = Arc::new(InMemoryStore::new());
1410 let key = chat_key(7);
1411 store
1412 .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1413 .await
1414 .unwrap();
1415 let host = FakeHost::new(vec![
1416 updates_result(vec![message_update(2, 20, 7, 102)], 2, 20, false),
1417 updates_result(vec![message_update(3, 30, 7, 103)], 3, 30, true),
1418 ]);
1419 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1420
1421 let events = sync
1422 .process_realtime(&host, vec![chat_hint(7, 3)])
1423 .await
1424 .unwrap();
1425
1426 assert_eq!(events.len(), 2);
1427 assert_eq!(
1428 store.sync_bucket_state(key).await.unwrap(),
1429 SyncBucketState { seq: 3, date: 30 }
1430 );
1431 let requests = host.requests.lock().await;
1432 assert_eq!(requests.len(), 2);
1433 assert_eq!(requests[0].start_seq, 1);
1434 assert_eq!(requests[0].seq_end, 3);
1435 assert_eq!(requests[1].start_seq, 2);
1436 assert_eq!(requests[1].seq_end, 3);
1437 }
1438
1439 #[tokio::test]
1440 async fn warm_catchup_commits_progress_across_more_than_128_pages() {
1441 let store = Arc::new(InMemoryStore::new());
1442 let key = chat_key(7);
1443 store
1444 .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1445 .await
1446 .unwrap();
1447 let responses = (2..=131)
1448 .map(|seq| {
1449 updates_result(
1450 vec![message_update(seq, i64::from(seq), 7, i64::from(seq) + 100)],
1451 i64::from(seq),
1452 i64::from(seq),
1453 seq == 131,
1454 )
1455 })
1456 .collect::<Vec<_>>();
1457 let host = FakeHost::new(responses);
1458 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1459
1460 let events = sync
1461 .process_realtime(&host, vec![chat_hint(7, 131)])
1462 .await
1463 .unwrap();
1464
1465 assert_eq!(events.len(), 130);
1466 assert_eq!(host.requests.lock().await.len(), 130);
1467 assert_eq!(host.applied.lock().await.len(), 130);
1468 assert_eq!(store.sync_bucket_state(key).await.unwrap().seq, 131);
1469 assert!(store.pending_sync_batches().await.unwrap().is_empty());
1470 }
1471
1472 #[tokio::test]
1473 async fn gap_recovery_merges_buffered_realtime_update_before_commit() {
1474 let store = Arc::new(InMemoryStore::new());
1475 let key = chat_key(7);
1476 store
1477 .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1478 .await
1479 .unwrap();
1480 let host = FakeHost::new(vec![
1481 updates_result(vec![message_update(2, 20, 7, 102)], 2, 20, false),
1482 updates_result(vec![message_update(3, 30, 7, 103)], 3, 30, true),
1483 ]);
1484 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1485
1486 sync.process_realtime(&host, vec![message_update(3, 30, 7, 103)])
1487 .await
1488 .unwrap();
1489
1490 let applied = host.applied.lock().await;
1491 assert_eq!(applied.len(), 2);
1492 assert_eq!(
1493 applied
1494 .iter()
1495 .flat_map(|batch| batch.iter().map(update_seq))
1496 .collect::<Vec<_>>(),
1497 vec![2, 3]
1498 );
1499 assert_eq!(
1500 store.sync_bucket_state(key).await.unwrap(),
1501 SyncBucketState { seq: 3, date: 30 }
1502 );
1503 }
1504
1505 #[tokio::test]
1506 async fn apply_failure_does_not_advance_bucket_cursor() {
1507 let store = Arc::new(InMemoryStore::new());
1508 let key = chat_key(7);
1509 store
1510 .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1511 .await
1512 .unwrap();
1513 let host = FakeHost::new(vec![updates_result(
1514 vec![message_update(2, 20, 7, 101)],
1515 2,
1516 20,
1517 true,
1518 )])
1519 .failing_apply();
1520 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1521
1522 let error = sync
1523 .process_realtime(&host, vec![chat_hint(7, 2)])
1524 .await
1525 .unwrap_err();
1526
1527 assert_eq!(error.category, ClientErrorCategory::Internal);
1528 assert_eq!(
1529 store.sync_bucket_state(key).await.unwrap(),
1530 SyncBucketState { seq: 1, date: 10 }
1531 );
1532 assert_eq!(store.pending_sync_batches().await.unwrap().len(), 1);
1533
1534 host.fail_apply.store(false, Ordering::Relaxed);
1535 let recovered = sync.process_realtime(&host, Vec::new()).await.unwrap();
1536 assert_eq!(recovered.len(), 1);
1537 assert_eq!(
1538 store.sync_bucket_state(key).await.unwrap(),
1539 SyncBucketState { seq: 2, date: 20 }
1540 );
1541 assert!(store.pending_sync_batches().await.unwrap().is_empty());
1542 }
1543
1544 #[test]
1545 fn pending_sync_payload_round_trips_updates_and_sidecars() {
1546 let updates = vec![message_update(2, 20, 7, 102)];
1547 let sidecars = proto::UpdateSidecars {
1548 users: vec![proto::User {
1549 id: 42,
1550 ..Default::default()
1551 }],
1552 ..Default::default()
1553 };
1554 let payload = encode_pending_payload(&updates, Some(&sidecars)).unwrap();
1555
1556 let (decoded_updates, decoded_sidecars) = decode_pending_payload(&payload).unwrap();
1557 assert_eq!(decoded_updates, updates);
1558 assert_eq!(decoded_sidecars, Some(sidecars));
1559 }
1560
1561 #[tokio::test]
1562 async fn incompatible_pending_journal_is_discarded_and_refetched_without_advancing_first() {
1563 let store = Arc::new(InMemoryStore::new());
1564 let key = chat_key(7);
1565 store
1566 .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1567 .await
1568 .unwrap();
1569 let invalid = proto::Update {
1570 seq: Some(2),
1571 date: Some(20),
1572 update: None,
1573 };
1574 store
1575 .save_pending_sync_batch(PendingSyncBatch {
1576 key,
1577 committed_state: SyncBucketState { seq: 2, date: 20 },
1578 payload: encode_pending_payload(&[invalid], None).unwrap(),
1579 })
1580 .await
1581 .unwrap();
1582 let host = FakeHost::new(vec![updates_result(
1583 vec![message_update(2, 20, 7, 102)],
1584 2,
1585 20,
1586 true,
1587 )]);
1588 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1589
1590 let events = sync.process_realtime(&host, Vec::new()).await.unwrap();
1591
1592 assert_eq!(events.len(), 1);
1593 assert_eq!(host.requests.lock().await.len(), 1);
1594 assert_eq!(
1595 store.sync_bucket_state(key).await.unwrap(),
1596 SyncBucketState { seq: 2, date: 20 }
1597 );
1598 assert!(store.pending_sync_batches().await.unwrap().is_empty());
1599 }
1600
1601 #[test]
1602 fn page_envelope_rejects_missing_duplicate_and_out_of_range_sequences() {
1603 let missing = updates_result(vec![message_update(2, 20, 7, 102)], 3, 30, true);
1604 assert!(validate_response_page(1, &missing).is_err());
1605
1606 let duplicate = updates_result(
1607 vec![message_update(2, 20, 7, 102), message_update(2, 20, 7, 102)],
1608 2,
1609 20,
1610 true,
1611 );
1612 assert!(validate_response_page(1, &duplicate).is_err());
1613
1614 let out_of_range = updates_result(vec![message_update(4, 40, 7, 104)], 3, 40, true);
1615 assert!(validate_response_page(1, &out_of_range).is_err());
1616 }
1617
1618 #[test]
1619 fn page_envelope_accepts_explicit_irrelevant_sequence_accounting() {
1620 let mut response = updates_result(vec![message_update(3, 30, 7, 103)], 3, 30, true);
1621 response.skipped_sequences = vec![proto::SyncSkippedSequence {
1622 seq: 2,
1623 reason: proto::sync_skipped_sequence::Reason::IrrelevantToBucket as i32,
1624 }];
1625
1626 assert_eq!(
1627 validate_response_page(1, &response).unwrap(),
1628 PageAccounting::default()
1629 );
1630 }
1631
1632 #[tokio::test]
1633 async fn empty_result_behind_hint_is_retried_without_advancing() {
1634 let store = Arc::new(InMemoryStore::new());
1635 let key = chat_key(7);
1636 store
1637 .save_sync_bucket_state(key, SyncBucketState { seq: 5, date: 50 })
1638 .await
1639 .unwrap();
1640 let empty = || proto::GetUpdatesResult {
1641 updates: Vec::new(),
1642 seq: 5,
1643 date: 80,
1644 r#final: Some(true),
1645 result_type: proto::get_updates_result::ResultType::Empty as i32,
1646 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1647 ..Default::default()
1648 };
1649 let host = FakeHost::new(vec![empty(), empty(), empty()]);
1650 let sync = SyncManager::new(
1651 store.clone(),
1652 SyncConfig {
1653 inconsistent_empty_retry_attempts: 2,
1654 inconsistent_empty_retry_delay_ms: 0,
1655 ..SyncConfig::default()
1656 },
1657 );
1658
1659 let error = sync
1660 .process_realtime(&host, vec![chat_hint(7, 8)])
1661 .await
1662 .unwrap_err();
1663
1664 assert_eq!(error.category, ClientErrorCategory::ProtocolMismatch);
1665 assert_eq!(
1666 store.sync_bucket_state(key).await.unwrap(),
1667 SyncBucketState { seq: 5, date: 50 }
1668 );
1669 }
1670
1671 #[tokio::test]
1672 async fn cold_chat_too_long_repairs_snapshot_and_advances_to_hint() {
1673 let store = Arc::new(InMemoryStore::new());
1674 let key = chat_key(7);
1675 let host = FakeHost::new(vec![
1676 proto::GetUpdatesResult {
1677 updates: Vec::new(),
1678 seq: 400,
1679 date: 80,
1680 r#final: Some(false),
1681 result_type: proto::get_updates_result::ResultType::TooLong as i32,
1682 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1683 ..Default::default()
1684 },
1685 skipped_result(400, 500, 90),
1686 ]);
1687 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1688
1689 sync.process_realtime(&host, vec![chat_hint(7, 500)])
1690 .await
1691 .unwrap();
1692
1693 assert_eq!(host.repaired_buckets.lock().await.as_slice(), &[key]);
1694 assert_eq!(
1695 store.sync_bucket_state(key).await.unwrap(),
1696 SyncBucketState { seq: 500, date: 90 }
1697 );
1698 assert_eq!(host.requests.lock().await.len(), 2);
1699 }
1700
1701 #[tokio::test]
1702 async fn cold_pointer_commits_lossless_events_without_an_empty_journal() {
1703 let store = Arc::new(InMemoryStore::new());
1704 let key = chat_key(7);
1705 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1706 let event = ClientEvent::MessageDeleted {
1707 chat_id: InlineId::new(7),
1708 message_id: InlineId::new(101),
1709 };
1710
1711 let deliveries = sync
1712 .commit_cold_pointer(key, 500, 80, vec![event.clone()])
1713 .await
1714 .unwrap();
1715
1716 assert_eq!(deliveries.len(), 1);
1717 assert_eq!(deliveries[0].event, event);
1718 assert!(deliveries[0].delivery_id.is_some());
1719 assert!(store.pending_sync_batches().await.unwrap().is_empty());
1720 assert_eq!(
1721 store.sync_bucket_state(key).await.unwrap(),
1722 SyncBucketState { seq: 500, date: 80 }
1723 );
1724 assert_eq!(store.pending_client_events().await.unwrap(), deliveries);
1725 }
1726
1727 #[tokio::test]
1728 async fn cold_user_and_space_too_long_require_snapshots_before_advancing() {
1729 for key in [
1730 SyncBucketKey::User,
1731 SyncBucketKey::Space {
1732 space_id: InlineId::new(9),
1733 },
1734 ] {
1735 let store = Arc::new(InMemoryStore::new());
1736 let host = FakeHost::new(vec![
1737 proto::GetUpdatesResult {
1738 updates: Vec::new(),
1739 seq: 400,
1740 date: 80,
1741 r#final: Some(false),
1742 result_type: proto::get_updates_result::ResultType::TooLong as i32,
1743 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1744 ..Default::default()
1745 },
1746 skipped_result(400, 500, 90),
1747 ]);
1748 let sync = SyncManager::new(store.clone(), SyncConfig::default());
1749
1750 sync.fetch_bucket(
1751 &host,
1752 key,
1753 SyncBucketState::default(),
1754 BTreeMap::new(),
1755 Some(500),
1756 )
1757 .await
1758 .unwrap();
1759
1760 assert_eq!(host.repaired_buckets.lock().await.as_slice(), &[key]);
1761 assert_eq!(
1762 store.sync_bucket_state(key).await.unwrap(),
1763 SyncBucketState { seq: 500, date: 90 }
1764 );
1765 }
1766 }
1767
1768 #[tokio::test]
1769 async fn independent_bucket_fetches_use_configured_concurrency() {
1770 let store = Arc::new(InMemoryStore::new());
1771 let empty = |date| proto::GetUpdatesResult {
1772 updates: Vec::new(),
1773 seq: 1,
1774 date,
1775 r#final: Some(true),
1776 result_type: proto::get_updates_result::ResultType::Empty as i32,
1777 skipped_sequences: vec![proto::SyncSkippedSequence {
1778 seq: 1,
1779 reason: proto::sync_skipped_sequence::Reason::IrrelevantToBucket as i32,
1780 }],
1781 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1782 ..Default::default()
1783 };
1784 let host = FakeHost::new(vec![empty(100), empty(200)]).with_fetch_delay(25);
1785 let sync = SyncManager::new(
1786 store.clone(),
1787 SyncConfig {
1788 max_concurrent_bucket_fetches: 2,
1789 ..SyncConfig::default()
1790 },
1791 );
1792
1793 sync.process_realtime(&host, vec![chat_hint(7, 1), chat_hint(8, 1)])
1794 .await
1795 .unwrap();
1796
1797 assert_eq!(host.max_fetches_in_flight.load(Ordering::SeqCst), 2);
1798 assert_eq!(store.sync_bucket_state(chat_key(7)).await.unwrap().seq, 1);
1799 assert_eq!(store.sync_bucket_state(chat_key(8)).await.unwrap().seq, 1);
1800 assert_eq!(store.sync_state().await.unwrap().last_sync_date, 185);
1801 }
1802
1803 fn updates_result(
1804 updates: Vec<proto::Update>,
1805 seq: i64,
1806 date: i64,
1807 final_page: bool,
1808 ) -> proto::GetUpdatesResult {
1809 proto::GetUpdatesResult {
1810 updates,
1811 seq,
1812 date,
1813 r#final: Some(final_page),
1814 result_type: proto::get_updates_result::ResultType::Slice as i32,
1815 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1816 ..Default::default()
1817 }
1818 }
1819
1820 fn skipped_result(start_seq: i64, end_seq: i64, date: i64) -> proto::GetUpdatesResult {
1821 proto::GetUpdatesResult {
1822 updates: Vec::new(),
1823 seq: end_seq,
1824 date,
1825 r#final: Some(true),
1826 result_type: proto::get_updates_result::ResultType::Empty as i32,
1827 skipped_sequences: ((start_seq + 1)..=end_seq)
1828 .map(|seq| proto::SyncSkippedSequence {
1829 seq,
1830 reason: proto::sync_skipped_sequence::Reason::IrrelevantToBucket as i32,
1831 })
1832 .collect(),
1833 core_sync_schema_revision: crate::CORE_SYNC_SCHEMA_REVISION,
1834 ..Default::default()
1835 }
1836 }
1837
1838 fn chat_hint(chat_id: i64, seq: i32) -> proto::Update {
1839 proto::Update {
1840 seq: None,
1841 date: None,
1842 update: Some(proto::update::Update::ChatHasNewUpdates(
1843 proto::UpdateChatHasNewUpdates {
1844 chat_id,
1845 update_seq: seq,
1846 peer_id: Some(chat_peer(chat_id)),
1847 },
1848 )),
1849 }
1850 }
1851
1852 fn message_update(seq: i32, date: i64, chat_id: i64, message_id: i64) -> proto::Update {
1853 proto::Update {
1854 seq: Some(seq),
1855 date: Some(date),
1856 update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
1857 message: Some(proto::Message {
1858 id: message_id,
1859 chat_id,
1860 peer_id: Some(chat_peer(chat_id)),
1861 ..Default::default()
1862 }),
1863 })),
1864 }
1865 }
1866
1867 fn chat_peer(chat_id: i64) -> proto::Peer {
1868 proto::Peer {
1869 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id })),
1870 }
1871 }
1872}