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        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        // Cold repair has already rebuilt its durable snapshot. Commit the
613        // resulting events and cursor together; an intermediate empty journal
614        // could otherwise recover the cursor without reproducing these events.
615        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}