Skip to main content

inline_client/
sync.rs

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