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::{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, ClientStore, InlineId,
16    PendingSyncBatch, StoreError, SyncBucketKey, SyncBucketPeer, SyncBucketState, SyncState,
17};
18
19/// Runtime policy for Inline update discovery and bucket catch-up.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct SyncConfig {
22    /// Safety window subtracted from the newest applied update date.
23    pub last_sync_safety_gap_seconds: i64,
24    /// Maximum number of bucket fetch RPCs allowed concurrently.
25    pub max_concurrent_bucket_fetches: usize,
26    /// Lookback used when global sync state is missing or stale.
27    pub initial_lookback_seconds: i64,
28    /// Age after which the global discovery cursor is reseeded.
29    pub stale_state_max_age_seconds: i64,
30    /// Maximum number of updates requested per catch-up slice.
31    pub max_total_updates: i32,
32    /// Maximum updates requested in one response page.
33    pub page_limit: i32,
34    /// Smaller total limit used by a bucket with no durable cursor.
35    pub cold_start_total_limit: i32,
36}
37
38impl Default for SyncConfig {
39    fn default() -> Self {
40        Self {
41            last_sync_safety_gap_seconds: 15,
42            max_concurrent_bucket_fetches: 4,
43            initial_lookback_seconds: 5 * 24 * 60 * 60,
44            stale_state_max_age_seconds: 14 * 24 * 60 * 60,
45            max_total_updates: 1_000,
46            page_limit: 200,
47            cold_start_total_limit: 50,
48        }
49    }
50}
51
52pub(crate) trait SyncHost: Clone + Send + Sync + 'static {
53    fn get_updates_state(
54        &self,
55        date: i64,
56    ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesStateResult>>;
57
58    fn get_updates(
59        &self,
60        input: proto::GetUpdatesInput,
61    ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesResult>>;
62
63    fn apply_sync_batch(
64        &self,
65        updates: Vec<proto::Update>,
66        sidecars: Option<proto::UpdateSidecars>,
67    ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>>;
68
69    /// Rebuilds authoritative current state for a bucket before a cold cursor
70    /// can be advanced past history that is no longer available.
71    fn repair_bucket(
72        &self,
73        key: SyncBucketKey,
74    ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>>;
75}
76
77#[derive(Debug)]
78pub(crate) struct SyncManager {
79    store: Arc<dyn ClientStore>,
80    config: SyncConfig,
81    bucket_locks: Mutex<HashMap<SyncBucketKey, Arc<Mutex<()>>>>,
82    fetch_limiter: Arc<Semaphore>,
83    sync_state_lock: Mutex<()>,
84}
85
86impl SyncManager {
87    pub(crate) fn new(store: Arc<dyn ClientStore>, config: SyncConfig) -> Self {
88        let max_fetches = config.max_concurrent_bucket_fetches.max(1);
89        Self {
90            store,
91            config,
92            bucket_locks: Mutex::new(HashMap::new()),
93            fetch_limiter: Arc::new(Semaphore::new(max_fetches)),
94            sync_state_lock: Mutex::new(()),
95        }
96    }
97
98    pub(crate) async fn discover<H: SyncHost>(&self, host: &H) -> BackendResult<Vec<ClientEvent>> {
99        let mut events = self.recover_pending_batches(host).await?;
100        let state = self.prepared_sync_state().await?;
101        log::debug!(
102            "starting Inline update discovery from date={}",
103            state.last_sync_date
104        );
105        let result = host.get_updates_state(state.last_sync_date).await?;
106        if result.updates_found == Some(false) {
107            self.update_last_sync_date(result.date).await?;
108        }
109        events.extend(
110            self.process_bucket(host, SyncBucketKey::User, Vec::new(), None, true)
111                .await?,
112        );
113        log::debug!(
114            "finished Inline update discovery date={} events={}",
115            result.date,
116            events.len()
117        );
118        Ok(events)
119    }
120
121    pub(crate) async fn process_realtime<H: SyncHost>(
122        &self,
123        host: &H,
124        updates: Vec<proto::Update>,
125    ) -> BackendResult<Vec<ClientEvent>> {
126        let mut events = self.recover_pending_batches(host).await?;
127        let received_count = updates.len();
128        let mut direct = Vec::new();
129        let mut buckets = HashMap::<SyncBucketKey, Vec<proto::Update>>::new();
130        let mut targets = HashMap::<SyncBucketKey, i64>::new();
131
132        for update in updates {
133            match update.update.as_ref() {
134                Some(proto::update::Update::ChatHasNewUpdates(hint)) => {
135                    if let Some(key) = chat_hint_bucket_key(hint) {
136                        targets
137                            .entry(key)
138                            .and_modify(|seq| *seq = (*seq).max(i64::from(hint.update_seq)))
139                            .or_insert(i64::from(hint.update_seq));
140                    }
141                }
142                Some(proto::update::Update::SpaceHasNewUpdates(hint)) => {
143                    let key = SyncBucketKey::Space {
144                        space_id: InlineId::new(hint.space_id),
145                    };
146                    targets
147                        .entry(key)
148                        .and_modify(|seq| *seq = (*seq).max(i64::from(hint.update_seq)))
149                        .or_insert(i64::from(hint.update_seq));
150                }
151                _ => {
152                    let seq = update_seq(&update);
153                    if seq > 0
154                        && let Some(key) = bucket_key_for_update(&update)
155                    {
156                        buckets.entry(key).or_default().push(update);
157                    } else {
158                        direct.push(update);
159                    }
160                }
161            }
162        }
163
164        if !direct.is_empty() {
165            let max_date = max_update_date(&direct);
166            let applied = host.apply_sync_batch(direct, None).await?;
167            self.update_last_sync_date(max_date).await?;
168            events.extend(applied);
169        }
170
171        for key in targets.keys() {
172            buckets.entry(*key).or_default();
173        }
174
175        let mut ordered = buckets
176            .into_iter()
177            .map(|(key, updates)| {
178                let target = targets.get(&key).copied();
179                (key, updates, target)
180            })
181            .collect::<Vec<_>>();
182        let bucket_count = ordered.len();
183        ordered.sort_by_key(|(key, _, _)| bucket_sort_key(*key));
184
185        if let Some(user_index) = ordered
186            .iter()
187            .position(|(key, _, _)| *key == SyncBucketKey::User)
188        {
189            let (key, updates, target) = ordered.remove(user_index);
190            events.extend(
191                self.process_bucket(host, key, updates, target, false)
192                    .await?,
193            );
194        }
195
196        let concurrency = self.config.max_concurrent_bucket_fetches.max(1);
197        let mut results = stream::iter(ordered)
198            .map(|(key, updates, target)| async move {
199                (
200                    bucket_sort_key(key),
201                    self.process_bucket(host, key, updates, target, false).await,
202                )
203            })
204            .buffer_unordered(concurrency)
205            .collect::<Vec<_>>()
206            .await;
207        results.sort_by_key(|(sort_key, _)| *sort_key);
208        for (_, result) in results {
209            events.extend(result?);
210        }
211        log::debug!(
212            "processed Inline realtime batch received={received_count} buckets={bucket_count} events={}",
213            events.len()
214        );
215        Ok(events)
216    }
217
218    async fn process_bucket<H: SyncHost>(
219        &self,
220        host: &H,
221        key: SyncBucketKey,
222        realtime_updates: Vec<proto::Update>,
223        target_seq: Option<i64>,
224        force_fetch: bool,
225    ) -> BackendResult<Vec<ClientEvent>> {
226        let bucket_lock = self.bucket_lock(key).await;
227        let _bucket_guard = bucket_lock.lock().await;
228        let mut state = self
229            .store
230            .sync_bucket_state(key)
231            .await
232            .map_err(store_error_to_backend)?;
233        let mut buffered = BTreeMap::new();
234        for update in realtime_updates {
235            let seq = update_seq(&update);
236            if seq > state.seq {
237                buffered.insert(seq, update);
238            }
239        }
240
241        let mut events = self
242            .drain_contiguous(host, key, &mut state, &mut buffered)
243            .await?;
244        let target_seq = target_seq
245            .filter(|target| *target > state.seq)
246            .or_else(|| buffered.last_key_value().map(|(seq, _)| *seq));
247        if !force_fetch && target_seq.is_none() && buffered.is_empty() && state.seq > 0 {
248            return Ok(events);
249        }
250
251        events.extend(
252            self.fetch_bucket(host, key, state, buffered, target_seq)
253                .await?,
254        );
255        Ok(events)
256    }
257
258    async fn drain_contiguous<H: SyncHost>(
259        &self,
260        host: &H,
261        key: SyncBucketKey,
262        state: &mut SyncBucketState,
263        buffered: &mut BTreeMap<i64, proto::Update>,
264    ) -> BackendResult<Vec<ClientEvent>> {
265        let mut updates = Vec::new();
266        let mut next_seq = state.seq + 1;
267        while let Some(update) = buffered.remove(&next_seq) {
268            updates.push(update);
269            next_seq += 1;
270        }
271        if updates.is_empty() {
272            return Ok(Vec::new());
273        }
274
275        let committed = SyncBucketState {
276            seq: next_seq - 1,
277            date: state.date.max(max_update_date(&updates)),
278        };
279        let events = self
280            .commit_sync_batch(host, key, committed, updates, None)
281            .await?;
282        self.update_last_sync_date(committed.date).await?;
283        *state = committed;
284        Ok(events)
285    }
286
287    async fn fetch_bucket<H: SyncHost>(
288        &self,
289        host: &H,
290        key: SyncBucketKey,
291        initial: SyncBucketState,
292        buffered: BTreeMap<i64, proto::Update>,
293        target_seq: Option<i64>,
294    ) -> BackendResult<Vec<ClientEvent>> {
295        let _permit = self
296            .fetch_limiter
297            .clone()
298            .acquire_owned()
299            .await
300            .map_err(|_| BackendError::new(ClientErrorCategory::Internal, "sync fetch stopped"))?;
301        let cold_start = initial.seq == 0 || initial.date == 0;
302        let mut current_seq = initial.seq;
303        let mut final_date = initial.date;
304        let hard_end = target_seq.or_else(|| buffered.last_key_value().map(|(seq, _)| *seq));
305        let mut slice_end = None;
306        let mut fetched = BTreeMap::<i64, proto::Update>::new();
307        let mut unsequenced = Vec::new();
308        let mut sidecars = proto::UpdateSidecars::default();
309        let mut page_count = 0_u32;
310
311        log::debug!(
312            "starting Inline bucket fetch kind={} start_seq={} target_seq={:?} cold_start={cold_start}",
313            bucket_kind(key),
314            current_seq,
315            hard_end
316        );
317
318        loop {
319            page_count += 1;
320            if page_count > 128 {
321                return Err(BackendError::new(
322                    ClientErrorCategory::ProtocolMismatch,
323                    "bucket sync exceeded the page safety limit",
324                ));
325            }
326            let request_end = slice_end.or(hard_end).filter(|end| *end > current_seq);
327            let response = host
328                .get_updates(proto::GetUpdatesInput {
329                    bucket: Some(protocol_bucket(key)),
330                    start_seq: current_seq,
331                    total_limit: if cold_start && slice_end.is_none() {
332                        self.config.cold_start_total_limit
333                    } else {
334                        self.config.max_total_updates
335                    },
336                    seq_end: request_end.unwrap_or_default(),
337                    limit: self.config.page_limit,
338                })
339                .await?;
340            let result_type = proto::get_updates_result::ResultType::try_from(response.result_type)
341                .unwrap_or(proto::get_updates_result::ResultType::Unspecified);
342
343            if result_type == proto::get_updates_result::ResultType::TooLong {
344                if response.seq <= current_seq {
345                    return Err(BackendError::new(
346                        ClientErrorCategory::ProtocolMismatch,
347                        "bucket sync received a non-advancing TOO_LONG pointer",
348                    ));
349                }
350                if cold_start {
351                    let target_seq = hard_end.unwrap_or(response.seq);
352                    let target_date = final_date.max(response.date);
353                    log::warn!(
354                        "repairing cold Inline bucket after TOO_LONG kind={} target_seq={target_seq}",
355                        bucket_kind(key)
356                    );
357                    return self
358                        .repair_cold_bucket(host, key, target_seq, target_date)
359                        .await;
360                }
361                let max_slice = current_seq + i64::from(self.config.max_total_updates);
362                slice_end = Some(response.seq.min(max_slice));
363                continue;
364            }
365
366            if response.seq < current_seq {
367                return Err(BackendError::new(
368                    ClientErrorCategory::ProtocolMismatch,
369                    "bucket sync server cursor moved backwards",
370                ));
371            }
372            let previous_seq = current_seq;
373            current_seq = response.seq;
374            final_date = final_date.max(response.date);
375            if let Some(page_sidecars) = response.sidecars {
376                merge_sidecars(&mut sidecars, page_sidecars);
377            }
378            for update in response.updates {
379                let seq = update_seq(&update);
380                if seq > initial.seq {
381                    fetched.insert(seq, update);
382                } else if seq == 0 {
383                    unsequenced.push(update);
384                }
385            }
386
387            let empty = result_type == proto::get_updates_result::ResultType::Empty;
388            if empty {
389                current_seq = current_seq.max(hard_end.unwrap_or_default());
390            }
391            let final_page = response.r#final.unwrap_or(false) || empty;
392            if current_seq == previous_seq && !final_page {
393                return Err(BackendError::new(
394                    ClientErrorCategory::ProtocolMismatch,
395                    "bucket sync received a non-progress response",
396                ));
397            }
398            if let Some(end) = slice_end
399                && current_seq >= end
400            {
401                slice_end = None;
402                if hard_end.is_some_and(|target| current_seq < target) {
403                    continue;
404                }
405            }
406            if hard_end.is_some_and(|target| current_seq >= target) || final_page {
407                break;
408            }
409        }
410
411        for (seq, update) in buffered {
412            if seq > initial.seq && seq <= current_seq {
413                fetched.entry(seq).or_insert(update);
414            }
415        }
416        let mut updates = unsequenced;
417        updates.extend(fetched.into_values());
418        let max_delivered_seq = updates.iter().map(update_seq).max().unwrap_or(initial.seq);
419        if current_seq > max_delivered_seq {
420            log::warn!(
421                "trusting Inline bucket pointer ahead of delivered updates kind={} delivered_seq={max_delivered_seq} pointer_seq={current_seq}",
422                bucket_kind(key)
423            );
424        }
425        let committed = SyncBucketState {
426            seq: current_seq,
427            date: final_date.max(max_update_date(&updates)),
428        };
429        if cold_start {
430            let events = self
431                .repair_cold_bucket(host, key, committed.seq, committed.date)
432                .await?;
433            log::debug!(
434                "finished cold Inline bucket snapshot kind={} pages={page_count} end_seq={} events={}",
435                bucket_kind(key),
436                committed.seq,
437                events.len()
438            );
439            return Ok(events);
440        }
441        let has_sidecars = !sidecars.users.is_empty()
442            || !sidecars.chats.is_empty()
443            || !sidecars.dialogs.is_empty()
444            || !sidecars.spaces.is_empty();
445        let update_count = updates.len();
446        let events = self
447            .commit_sync_batch(
448                host,
449                key,
450                committed,
451                updates,
452                has_sidecars.then_some(sidecars),
453            )
454            .await?;
455        self.update_last_sync_date(committed.date).await?;
456        log::debug!(
457            "finished Inline bucket fetch kind={} pages={page_count} end_seq={} updates={} events={}",
458            bucket_kind(key),
459            committed.seq,
460            update_count,
461            events.len()
462        );
463        Ok(events)
464    }
465
466    async fn repair_cold_bucket<H: SyncHost>(
467        &self,
468        host: &H,
469        key: SyncBucketKey,
470        target_seq: i64,
471        target_date: i64,
472    ) -> BackendResult<Vec<ClientEvent>> {
473        let mut events = host.repair_bucket(key).await?;
474        events.extend(
475            self.commit_cold_pointer(host, key, target_seq, target_date)
476                .await?,
477        );
478        Ok(events)
479    }
480
481    async fn commit_cold_pointer<H: SyncHost>(
482        &self,
483        host: &H,
484        key: SyncBucketKey,
485        target_seq: i64,
486        target_date: i64,
487    ) -> BackendResult<Vec<ClientEvent>> {
488        let committed = SyncBucketState {
489            seq: target_seq,
490            date: target_date,
491        };
492        let events = self
493            .commit_sync_batch(host, key, committed, Vec::new(), None)
494            .await?;
495        self.update_last_sync_date(committed.date).await?;
496        Ok(events)
497    }
498
499    async fn commit_sync_batch<H: SyncHost>(
500        &self,
501        host: &H,
502        key: SyncBucketKey,
503        committed_state: SyncBucketState,
504        updates: Vec<proto::Update>,
505        sidecars: Option<proto::UpdateSidecars>,
506    ) -> BackendResult<Vec<ClientEvent>> {
507        let payload = encode_pending_payload(&updates, sidecars.as_ref())?;
508        self.store
509            .save_pending_sync_batch(PendingSyncBatch {
510                key,
511                committed_state,
512                payload,
513            })
514            .await
515            .map_err(store_error_to_backend)?;
516        let events = host.apply_sync_batch(updates, sidecars).await?;
517        self.store
518            .commit_pending_sync_batch(key, committed_state)
519            .await
520            .map_err(store_error_to_backend)?;
521        Ok(events)
522    }
523
524    async fn recover_pending_batches<H: SyncHost>(
525        &self,
526        host: &H,
527    ) -> BackendResult<Vec<ClientEvent>> {
528        let batches = self
529            .store
530            .pending_sync_batches()
531            .await
532            .map_err(store_error_to_backend)?;
533        let mut events = Vec::new();
534        for batch in batches {
535            let (updates, sidecars) = decode_pending_payload(&batch.payload)?;
536            events.extend(host.apply_sync_batch(updates, sidecars).await?);
537            self.store
538                .commit_pending_sync_batch(batch.key, batch.committed_state)
539                .await
540                .map_err(store_error_to_backend)?;
541            self.update_last_sync_date(batch.committed_state.date)
542                .await?;
543        }
544        Ok(events)
545    }
546
547    async fn bucket_lock(&self, key: SyncBucketKey) -> Arc<Mutex<()>> {
548        let mut locks = self.bucket_locks.lock().await;
549        locks
550            .entry(key)
551            .or_insert_with(|| Arc::new(Mutex::new(())))
552            .clone()
553    }
554
555    async fn prepared_sync_state(&self) -> BackendResult<SyncState> {
556        let mut state = self
557            .store
558            .sync_state()
559            .await
560            .map_err(store_error_to_backend)?;
561        let now = now_seconds();
562        if state.last_sync_date == 0
563            || now.saturating_sub(state.last_sync_date) > self.config.stale_state_max_age_seconds
564        {
565            state.last_sync_date = now.saturating_sub(self.config.initial_lookback_seconds);
566            self.store
567                .save_sync_state(state)
568                .await
569                .map_err(store_error_to_backend)?;
570        }
571        Ok(state)
572    }
573
574    async fn update_last_sync_date(&self, max_applied_date: i64) -> BackendResult<()> {
575        if max_applied_date <= 0 {
576            return Ok(());
577        }
578        let _guard = self.sync_state_lock.lock().await;
579        let current = self
580            .store
581            .sync_state()
582            .await
583            .map_err(store_error_to_backend)?;
584        let proposed = max_applied_date.saturating_sub(self.config.last_sync_safety_gap_seconds);
585        if proposed <= current.last_sync_date {
586            return Ok(());
587        }
588        self.store
589            .save_sync_state(SyncState {
590                last_sync_date: proposed,
591            })
592            .await
593            .map_err(store_error_to_backend)
594    }
595}
596
597fn bucket_key_for_update(update: &proto::Update) -> Option<SyncBucketKey> {
598    use proto::update::Update;
599    match update.update.as_ref()? {
600        Update::NewMessage(value) => value
601            .message
602            .as_ref()?
603            .peer_id
604            .as_ref()
605            .and_then(chat_bucket),
606        Update::EditMessage(value) => value
607            .message
608            .as_ref()?
609            .peer_id
610            .as_ref()
611            .and_then(chat_bucket),
612        Update::DeleteMessages(value) => value.peer_id.as_ref().and_then(chat_bucket),
613        Update::ClearChatHistory(value) => match value.target.as_ref()? {
614            proto::update_clear_chat_history::Target::PeerId(peer) => chat_bucket(peer),
615            proto::update_clear_chat_history::Target::SpaceId(space_id) => {
616                Some(SyncBucketKey::Space {
617                    space_id: InlineId::new(*space_id),
618                })
619            }
620        },
621        Update::MessageAttachment(value) => value.peer_id.as_ref().and_then(chat_bucket),
622        Update::UpdateReaction(value) => value
623            .reaction
624            .as_ref()
625            .map(|reaction| chat_key(reaction.chat_id)),
626        Update::DeleteReaction(value) => Some(chat_key(value.chat_id)),
627        Update::DeleteChat(value) => value.peer_id.as_ref().and_then(chat_bucket),
628        Update::MarkAsUnread(value) => value.peer_id.as_ref().and_then(chat_bucket),
629        Update::SpaceMemberAdd(value) => value
630            .member
631            .as_ref()
632            .map(|member| space_key(member.space_id)),
633        Update::SpaceMemberDelete(value) => Some(space_key(value.space_id)),
634        Update::SpaceMemberUpdate(value) => value
635            .member
636            .as_ref()
637            .map(|member| space_key(member.space_id)),
638        Update::JoinSpace(_)
639        | Update::UpdateUserStatus(_)
640        | Update::UpdateUserSettings(_)
641        | Update::UpdatedUser(_)
642        | Update::DialogArchived(_)
643        | Update::DialogNotificationSettings(_)
644        | Update::DialogFollowMode(_)
645        | Update::UpdateReadMaxId(_)
646        | Update::ChatOpen(_) => Some(SyncBucketKey::User),
647        Update::NewChat(value) => value.chat.as_ref()?.peer_id.as_ref().and_then(chat_bucket),
648        Update::ChatMoved(value) => value.chat.as_ref()?.peer_id.as_ref().and_then(chat_bucket),
649        Update::ParticipantAdd(value) => Some(chat_key(value.chat_id)),
650        Update::ParticipantDelete(value) => Some(chat_key(value.chat_id)),
651        Update::ChatVisibility(value) => Some(chat_key(value.chat_id)),
652        Update::ChatInfo(value) => Some(chat_key(value.chat_id)),
653        Update::PinnedMessages(value) => value.peer_id.as_ref().and_then(chat_bucket),
654        Update::ChatSkipPts(value) => Some(chat_key(value.chat_id)),
655        _ => None,
656    }
657}
658
659fn chat_hint_bucket_key(hint: &proto::UpdateChatHasNewUpdates) -> Option<SyncBucketKey> {
660    hint.peer_id
661        .as_ref()
662        .and_then(chat_bucket)
663        .or_else(|| (hint.chat_id != 0).then(|| chat_key(hint.chat_id)))
664}
665
666fn chat_bucket(peer: &proto::Peer) -> Option<SyncBucketKey> {
667    let peer = match peer.r#type.as_ref()? {
668        proto::peer::Type::User(user) => SyncBucketPeer::User {
669            user_id: InlineId::new(user.user_id),
670        },
671        proto::peer::Type::Chat(chat) => SyncBucketPeer::Chat {
672            chat_id: InlineId::new(chat.chat_id),
673        },
674    };
675    Some(SyncBucketKey::Chat { peer })
676}
677
678fn chat_key(chat_id: i64) -> SyncBucketKey {
679    SyncBucketKey::Chat {
680        peer: SyncBucketPeer::Chat {
681            chat_id: InlineId::new(chat_id),
682        },
683    }
684}
685
686fn space_key(space_id: i64) -> SyncBucketKey {
687    SyncBucketKey::Space {
688        space_id: InlineId::new(space_id),
689    }
690}
691
692fn protocol_bucket(key: SyncBucketKey) -> proto::UpdateBucket {
693    let r#type = match key {
694        SyncBucketKey::User => proto::update_bucket::Type::User(proto::UpdateBucketUser {}),
695        SyncBucketKey::Space { space_id } => {
696            proto::update_bucket::Type::Space(proto::UpdateBucketSpace {
697                space_id: space_id.get(),
698            })
699        }
700        SyncBucketKey::Chat { peer } => {
701            let r#type = match peer {
702                SyncBucketPeer::User { user_id } => {
703                    proto::input_peer::Type::User(proto::InputPeerUser {
704                        user_id: user_id.get(),
705                    })
706                }
707                SyncBucketPeer::Chat { chat_id } => {
708                    proto::input_peer::Type::Chat(proto::InputPeerChat {
709                        chat_id: chat_id.get(),
710                    })
711                }
712            };
713            proto::update_bucket::Type::Chat(proto::UpdateBucketChat {
714                peer_id: Some(proto::InputPeer {
715                    r#type: Some(r#type),
716                }),
717            })
718        }
719    };
720    proto::UpdateBucket {
721        r#type: Some(r#type),
722    }
723}
724
725fn update_seq(update: &proto::Update) -> i64 {
726    i64::from(update.seq.unwrap_or_default())
727}
728
729fn max_update_date(updates: &[proto::Update]) -> i64 {
730    updates
731        .iter()
732        .filter_map(|update| update.date)
733        .max()
734        .unwrap_or_default()
735}
736
737fn merge_sidecars(target: &mut proto::UpdateSidecars, page: proto::UpdateSidecars) {
738    target.users.extend(page.users);
739    target.chats.extend(page.chats);
740    target.dialogs.extend(page.dialogs);
741    target.spaces.extend(page.spaces);
742}
743
744fn bucket_sort_key(key: SyncBucketKey) -> (u8, i64) {
745    match key {
746        SyncBucketKey::User => (0, 0),
747        SyncBucketKey::Space { space_id } => (1, space_id.get()),
748        SyncBucketKey::Chat {
749            peer: SyncBucketPeer::User { user_id },
750        } => (2, user_id.get()),
751        SyncBucketKey::Chat {
752            peer: SyncBucketPeer::Chat { chat_id },
753        } => (3, chat_id.get()),
754    }
755}
756
757fn bucket_kind(key: SyncBucketKey) -> &'static str {
758    match key {
759        SyncBucketKey::User => "user",
760        SyncBucketKey::Space { .. } => "space",
761        SyncBucketKey::Chat { .. } => "chat",
762    }
763}
764
765fn store_error_to_backend(error: StoreError) -> BackendError {
766    BackendError::new(error.category, error.message)
767}
768
769fn now_seconds() -> i64 {
770    SystemTime::now()
771        .duration_since(UNIX_EPOCH)
772        .unwrap_or_default()
773        .as_secs() as i64
774}
775
776const PENDING_SYNC_PAYLOAD_MAGIC: &[u8; 4] = b"ISB1";
777const MAX_PENDING_SYNC_ITEMS: usize = 100_000;
778const MAX_PENDING_SYNC_ITEM_BYTES: usize = 64 * 1024 * 1024;
779
780fn encode_pending_payload(
781    updates: &[proto::Update],
782    sidecars: Option<&proto::UpdateSidecars>,
783) -> BackendResult<Vec<u8>> {
784    if updates.len() > MAX_PENDING_SYNC_ITEMS {
785        return Err(BackendError::new(
786            ClientErrorCategory::Internal,
787            "sync batch is too large to journal",
788        ));
789    }
790    let mut payload = Vec::new();
791    payload.extend_from_slice(PENDING_SYNC_PAYLOAD_MAGIC);
792    append_u32(&mut payload, updates.len())?;
793    for update in updates {
794        let encoded = update.encode_to_vec();
795        append_bytes(&mut payload, &encoded)?;
796    }
797    match sidecars {
798        Some(sidecars) => {
799            payload.push(1);
800            append_bytes(&mut payload, &sidecars.encode_to_vec())?;
801        }
802        None => payload.push(0),
803    }
804    Ok(payload)
805}
806
807fn decode_pending_payload(
808    payload: &[u8],
809) -> BackendResult<(Vec<proto::Update>, Option<proto::UpdateSidecars>)> {
810    if !payload.starts_with(PENDING_SYNC_PAYLOAD_MAGIC) {
811        return Err(invalid_pending_payload("unsupported journal version"));
812    }
813    let mut offset = PENDING_SYNC_PAYLOAD_MAGIC.len();
814    let count = read_u32(payload, &mut offset)?;
815    if count > MAX_PENDING_SYNC_ITEMS {
816        return Err(invalid_pending_payload("update count exceeds safety limit"));
817    }
818    let mut updates = Vec::with_capacity(count);
819    for _ in 0..count {
820        let bytes = read_bytes(payload, &mut offset)?;
821        updates.push(
822            proto::Update::decode(bytes)
823                .map_err(|_| invalid_pending_payload("update protobuf is invalid"))?,
824        );
825    }
826    let sidecars = match payload.get(offset).copied() {
827        Some(0) => {
828            offset += 1;
829            None
830        }
831        Some(1) => {
832            offset += 1;
833            let bytes = read_bytes(payload, &mut offset)?;
834            Some(
835                proto::UpdateSidecars::decode(bytes)
836                    .map_err(|_| invalid_pending_payload("sidecars protobuf is invalid"))?,
837            )
838        }
839        _ => return Err(invalid_pending_payload("sidecars flag is invalid")),
840    };
841    if offset != payload.len() {
842        return Err(invalid_pending_payload("journal has trailing bytes"));
843    }
844    Ok((updates, sidecars))
845}
846
847fn append_u32(payload: &mut Vec<u8>, value: usize) -> BackendResult<()> {
848    let value = u32::try_from(value).map_err(|_| {
849        BackendError::new(
850            ClientErrorCategory::Internal,
851            "sync journal length exceeds u32",
852        )
853    })?;
854    payload.extend_from_slice(&value.to_le_bytes());
855    Ok(())
856}
857
858fn append_bytes(payload: &mut Vec<u8>, bytes: &[u8]) -> BackendResult<()> {
859    if bytes.len() > MAX_PENDING_SYNC_ITEM_BYTES {
860        return Err(BackendError::new(
861            ClientErrorCategory::Internal,
862            "sync journal item exceeds safety limit",
863        ));
864    }
865    append_u32(payload, bytes.len())?;
866    payload.extend_from_slice(bytes);
867    Ok(())
868}
869
870fn read_u32(payload: &[u8], offset: &mut usize) -> BackendResult<usize> {
871    let end = offset.saturating_add(4);
872    let bytes = payload
873        .get(*offset..end)
874        .ok_or_else(|| invalid_pending_payload("journal is truncated"))?;
875    *offset = end;
876    Ok(u32::from_le_bytes(
877        bytes
878            .try_into()
879            .map_err(|_| invalid_pending_payload("length is invalid"))?,
880    ) as usize)
881}
882
883fn read_bytes<'a>(payload: &'a [u8], offset: &mut usize) -> BackendResult<&'a [u8]> {
884    let length = read_u32(payload, offset)?;
885    if length > MAX_PENDING_SYNC_ITEM_BYTES {
886        return Err(invalid_pending_payload("journal item exceeds safety limit"));
887    }
888    let end = offset.saturating_add(length);
889    let bytes = payload
890        .get(*offset..end)
891        .ok_or_else(|| invalid_pending_payload("journal item is truncated"))?;
892    *offset = end;
893    Ok(bytes)
894}
895
896fn invalid_pending_payload(message: &'static str) -> BackendError {
897    BackendError::new(
898        ClientErrorCategory::ProtocolMismatch,
899        format!("invalid pending sync journal: {message}"),
900    )
901}
902
903#[cfg(test)]
904mod tests {
905    use std::{
906        collections::VecDeque,
907        sync::{
908            Arc,
909            atomic::{AtomicBool, AtomicUsize, Ordering},
910        },
911    };
912
913    use super::*;
914    use crate::InMemoryStore;
915
916    #[derive(Clone, Debug)]
917    struct FakeHost {
918        state: proto::GetUpdatesStateResult,
919        responses: Arc<Mutex<VecDeque<BackendResult<proto::GetUpdatesResult>>>>,
920        requests: Arc<Mutex<Vec<proto::GetUpdatesInput>>>,
921        applied: Arc<Mutex<Vec<Vec<proto::Update>>>>,
922        repaired_buckets: Arc<Mutex<Vec<SyncBucketKey>>>,
923        fail_apply: Arc<AtomicBool>,
924        fetch_delay_ms: u64,
925        fetches_in_flight: Arc<AtomicUsize>,
926        max_fetches_in_flight: Arc<AtomicUsize>,
927    }
928
929    impl FakeHost {
930        fn new(responses: Vec<proto::GetUpdatesResult>) -> Self {
931            Self {
932                state: proto::GetUpdatesStateResult {
933                    date: 100,
934                    updates_found: Some(true),
935                },
936                responses: Arc::new(Mutex::new(
937                    responses.into_iter().map(Ok).collect::<VecDeque<_>>(),
938                )),
939                requests: Arc::new(Mutex::new(Vec::new())),
940                applied: Arc::new(Mutex::new(Vec::new())),
941                repaired_buckets: Arc::new(Mutex::new(Vec::new())),
942                fail_apply: Arc::new(AtomicBool::new(false)),
943                fetch_delay_ms: 0,
944                fetches_in_flight: Arc::new(AtomicUsize::new(0)),
945                max_fetches_in_flight: Arc::new(AtomicUsize::new(0)),
946            }
947        }
948
949        fn failing_apply(mut self) -> Self {
950            self.fail_apply = Arc::new(AtomicBool::new(true));
951            self
952        }
953
954        fn with_fetch_delay(mut self, delay_ms: u64) -> Self {
955            self.fetch_delay_ms = delay_ms;
956            self
957        }
958    }
959
960    impl SyncHost for FakeHost {
961        fn get_updates_state(
962            &self,
963            _date: i64,
964        ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesStateResult>> {
965            let state = self.state.clone();
966            Box::pin(async move { Ok(state) })
967        }
968
969        fn get_updates(
970            &self,
971            input: proto::GetUpdatesInput,
972        ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesResult>> {
973            let host = self.clone();
974            Box::pin(async move {
975                let in_flight = host.fetches_in_flight.fetch_add(1, Ordering::SeqCst) + 1;
976                host.max_fetches_in_flight
977                    .fetch_max(in_flight, Ordering::SeqCst);
978                if host.fetch_delay_ms > 0 {
979                    tokio::time::sleep(std::time::Duration::from_millis(host.fetch_delay_ms)).await;
980                }
981                host.requests.lock().await.push(input);
982                let response = host.responses.lock().await.pop_front().unwrap_or_else(|| {
983                    Err(BackendError::new(
984                        ClientErrorCategory::Internal,
985                        "missing fake getUpdates response",
986                    ))
987                });
988                host.fetches_in_flight.fetch_sub(1, Ordering::SeqCst);
989                response
990            })
991        }
992
993        fn apply_sync_batch(
994            &self,
995            updates: Vec<proto::Update>,
996            _sidecars: Option<proto::UpdateSidecars>,
997        ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
998            let host = self.clone();
999            Box::pin(async move {
1000                if host.fail_apply.load(Ordering::Relaxed) {
1001                    return Err(BackendError::new(
1002                        ClientErrorCategory::Internal,
1003                        "fake apply failure",
1004                    ));
1005                }
1006                host.applied.lock().await.push(updates.clone());
1007                Ok(updates
1008                    .iter()
1009                    .filter_map(|update| match update.update.as_ref() {
1010                        Some(proto::update::Update::NewMessage(update)) => {
1011                            let message = update.message.as_ref()?;
1012                            Some(ClientEvent::MessageUpserted {
1013                                chat_id: InlineId::new(message.chat_id),
1014                                message_id: InlineId::new(message.id),
1015                            })
1016                        }
1017                        _ => None,
1018                    })
1019                    .collect())
1020            })
1021        }
1022
1023        fn repair_bucket(
1024            &self,
1025            key: SyncBucketKey,
1026        ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1027            let host = self.clone();
1028            Box::pin(async move {
1029                host.repaired_buckets.lock().await.push(key);
1030                Ok(Vec::new())
1031            })
1032        }
1033    }
1034
1035    #[tokio::test]
1036    async fn hint_only_recovery_pages_to_target_and_persists_cursor() {
1037        let store = Arc::new(InMemoryStore::new());
1038        let key = chat_key(7);
1039        store
1040            .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1041            .await
1042            .unwrap();
1043        let host = FakeHost::new(vec![
1044            updates_result(vec![message_update(2, 20, 7, 102)], 2, 20, false),
1045            updates_result(vec![message_update(3, 30, 7, 103)], 3, 30, true),
1046        ]);
1047        let sync = SyncManager::new(store.clone(), SyncConfig::default());
1048
1049        let events = sync
1050            .process_realtime(&host, vec![chat_hint(7, 3)])
1051            .await
1052            .unwrap();
1053
1054        assert_eq!(events.len(), 2);
1055        assert_eq!(
1056            store.sync_bucket_state(key).await.unwrap(),
1057            SyncBucketState { seq: 3, date: 30 }
1058        );
1059        let requests = host.requests.lock().await;
1060        assert_eq!(requests.len(), 2);
1061        assert_eq!(requests[0].start_seq, 1);
1062        assert_eq!(requests[0].seq_end, 3);
1063        assert_eq!(requests[1].start_seq, 2);
1064        assert_eq!(requests[1].seq_end, 3);
1065    }
1066
1067    #[tokio::test]
1068    async fn gap_recovery_merges_buffered_realtime_update_before_commit() {
1069        let store = Arc::new(InMemoryStore::new());
1070        let key = chat_key(7);
1071        store
1072            .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1073            .await
1074            .unwrap();
1075        let host = FakeHost::new(vec![updates_result(
1076            vec![message_update(2, 20, 7, 102)],
1077            3,
1078            30,
1079            true,
1080        )]);
1081        let sync = SyncManager::new(store.clone(), SyncConfig::default());
1082
1083        sync.process_realtime(&host, vec![message_update(3, 30, 7, 103)])
1084            .await
1085            .unwrap();
1086
1087        let applied = host.applied.lock().await;
1088        assert_eq!(applied.len(), 1);
1089        assert_eq!(
1090            applied[0].iter().map(update_seq).collect::<Vec<_>>(),
1091            vec![2, 3]
1092        );
1093        assert_eq!(
1094            store.sync_bucket_state(key).await.unwrap(),
1095            SyncBucketState { seq: 3, date: 30 }
1096        );
1097    }
1098
1099    #[tokio::test]
1100    async fn apply_failure_does_not_advance_bucket_cursor() {
1101        let store = Arc::new(InMemoryStore::new());
1102        let key = chat_key(7);
1103        store
1104            .save_sync_bucket_state(key, SyncBucketState { seq: 1, date: 10 })
1105            .await
1106            .unwrap();
1107        let host = FakeHost::new(vec![updates_result(
1108            vec![message_update(2, 20, 7, 101)],
1109            2,
1110            20,
1111            true,
1112        )])
1113        .failing_apply();
1114        let sync = SyncManager::new(store.clone(), SyncConfig::default());
1115
1116        let error = sync
1117            .process_realtime(&host, vec![chat_hint(7, 2)])
1118            .await
1119            .unwrap_err();
1120
1121        assert_eq!(error.category, ClientErrorCategory::Internal);
1122        assert_eq!(
1123            store.sync_bucket_state(key).await.unwrap(),
1124            SyncBucketState { seq: 1, date: 10 }
1125        );
1126        assert_eq!(store.pending_sync_batches().await.unwrap().len(), 1);
1127
1128        host.fail_apply.store(false, Ordering::Relaxed);
1129        let recovered = sync.process_realtime(&host, Vec::new()).await.unwrap();
1130        assert_eq!(recovered.len(), 1);
1131        assert_eq!(
1132            store.sync_bucket_state(key).await.unwrap(),
1133            SyncBucketState { seq: 2, date: 20 }
1134        );
1135        assert!(store.pending_sync_batches().await.unwrap().is_empty());
1136    }
1137
1138    #[test]
1139    fn pending_sync_payload_round_trips_updates_and_sidecars() {
1140        let updates = vec![message_update(2, 20, 7, 102)];
1141        let sidecars = proto::UpdateSidecars {
1142            users: vec![proto::User {
1143                id: 42,
1144                ..Default::default()
1145            }],
1146            ..Default::default()
1147        };
1148        let payload = encode_pending_payload(&updates, Some(&sidecars)).unwrap();
1149
1150        let (decoded_updates, decoded_sidecars) = decode_pending_payload(&payload).unwrap();
1151        assert_eq!(decoded_updates, updates);
1152        assert_eq!(decoded_sidecars, Some(sidecars));
1153    }
1154
1155    #[tokio::test]
1156    async fn empty_result_trusts_hint_pointer() {
1157        let store = Arc::new(InMemoryStore::new());
1158        let key = chat_key(7);
1159        store
1160            .save_sync_bucket_state(key, SyncBucketState { seq: 5, date: 50 })
1161            .await
1162            .unwrap();
1163        let host = FakeHost::new(vec![proto::GetUpdatesResult {
1164            updates: Vec::new(),
1165            seq: 6,
1166            date: 80,
1167            r#final: None,
1168            result_type: proto::get_updates_result::ResultType::Empty as i32,
1169            sidecars: None,
1170        }]);
1171        let sync = SyncManager::new(store.clone(), SyncConfig::default());
1172
1173        sync.process_realtime(&host, vec![chat_hint(7, 8)])
1174            .await
1175            .unwrap();
1176
1177        assert_eq!(
1178            store.sync_bucket_state(key).await.unwrap(),
1179            SyncBucketState { seq: 8, date: 80 }
1180        );
1181    }
1182
1183    #[tokio::test]
1184    async fn cold_chat_too_long_repairs_snapshot_and_advances_to_hint() {
1185        let store = Arc::new(InMemoryStore::new());
1186        let key = chat_key(7);
1187        let host = FakeHost::new(vec![proto::GetUpdatesResult {
1188            updates: Vec::new(),
1189            seq: 400,
1190            date: 80,
1191            r#final: Some(false),
1192            result_type: proto::get_updates_result::ResultType::TooLong as i32,
1193            sidecars: None,
1194        }]);
1195        let sync = SyncManager::new(store.clone(), SyncConfig::default());
1196
1197        sync.process_realtime(&host, vec![chat_hint(7, 500)])
1198            .await
1199            .unwrap();
1200
1201        assert_eq!(host.repaired_buckets.lock().await.as_slice(), &[key]);
1202        assert_eq!(
1203            store.sync_bucket_state(key).await.unwrap(),
1204            SyncBucketState { seq: 500, date: 80 }
1205        );
1206        assert_eq!(host.requests.lock().await.len(), 1);
1207    }
1208
1209    #[tokio::test]
1210    async fn cold_user_and_space_too_long_require_snapshots_before_advancing() {
1211        for key in [
1212            SyncBucketKey::User,
1213            SyncBucketKey::Space {
1214                space_id: InlineId::new(9),
1215            },
1216        ] {
1217            let store = Arc::new(InMemoryStore::new());
1218            let host = FakeHost::new(vec![proto::GetUpdatesResult {
1219                updates: Vec::new(),
1220                seq: 400,
1221                date: 80,
1222                r#final: Some(false),
1223                result_type: proto::get_updates_result::ResultType::TooLong as i32,
1224                sidecars: None,
1225            }]);
1226            let sync = SyncManager::new(store.clone(), SyncConfig::default());
1227
1228            sync.fetch_bucket(
1229                &host,
1230                key,
1231                SyncBucketState::default(),
1232                BTreeMap::new(),
1233                Some(500),
1234            )
1235            .await
1236            .unwrap();
1237
1238            assert_eq!(host.repaired_buckets.lock().await.as_slice(), &[key]);
1239            assert_eq!(
1240                store.sync_bucket_state(key).await.unwrap(),
1241                SyncBucketState { seq: 500, date: 80 }
1242            );
1243        }
1244    }
1245
1246    #[tokio::test]
1247    async fn independent_bucket_fetches_use_configured_concurrency() {
1248        let store = Arc::new(InMemoryStore::new());
1249        let empty = |date| proto::GetUpdatesResult {
1250            updates: Vec::new(),
1251            seq: 1,
1252            date,
1253            r#final: Some(true),
1254            result_type: proto::get_updates_result::ResultType::Empty as i32,
1255            sidecars: None,
1256        };
1257        let host = FakeHost::new(vec![empty(100), empty(200)]).with_fetch_delay(25);
1258        let sync = SyncManager::new(
1259            store.clone(),
1260            SyncConfig {
1261                max_concurrent_bucket_fetches: 2,
1262                ..SyncConfig::default()
1263            },
1264        );
1265
1266        sync.process_realtime(&host, vec![chat_hint(7, 1), chat_hint(8, 1)])
1267            .await
1268            .unwrap();
1269
1270        assert_eq!(host.max_fetches_in_flight.load(Ordering::SeqCst), 2);
1271        assert_eq!(store.sync_bucket_state(chat_key(7)).await.unwrap().seq, 1);
1272        assert_eq!(store.sync_bucket_state(chat_key(8)).await.unwrap().seq, 1);
1273        assert_eq!(store.sync_state().await.unwrap().last_sync_date, 185);
1274    }
1275
1276    fn updates_result(
1277        updates: Vec<proto::Update>,
1278        seq: i64,
1279        date: i64,
1280        final_page: bool,
1281    ) -> proto::GetUpdatesResult {
1282        proto::GetUpdatesResult {
1283            updates,
1284            seq,
1285            date,
1286            r#final: Some(final_page),
1287            result_type: proto::get_updates_result::ResultType::Slice as i32,
1288            sidecars: None,
1289        }
1290    }
1291
1292    fn chat_hint(chat_id: i64, seq: i32) -> proto::Update {
1293        proto::Update {
1294            seq: None,
1295            date: None,
1296            update: Some(proto::update::Update::ChatHasNewUpdates(
1297                proto::UpdateChatHasNewUpdates {
1298                    chat_id,
1299                    update_seq: seq,
1300                    peer_id: Some(chat_peer(chat_id)),
1301                },
1302            )),
1303        }
1304    }
1305
1306    fn message_update(seq: i32, date: i64, chat_id: i64, message_id: i64) -> proto::Update {
1307        proto::Update {
1308            seq: Some(seq),
1309            date: Some(date),
1310            update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
1311                message: Some(proto::Message {
1312                    id: message_id,
1313                    chat_id,
1314                    peer_id: Some(chat_peer(chat_id)),
1315                    ..Default::default()
1316                }),
1317            })),
1318        }
1319    }
1320
1321    fn chat_peer(chat_id: i64) -> proto::Peer {
1322        proto::Peer {
1323            r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id })),
1324        }
1325    }
1326}