Skip to main content

aven_core/sync/
session.rs

1use std::collections::HashSet;
2use std::io::Write;
3use std::path::PathBuf;
4use std::time::Instant;
5
6use anyhow::{Context, Result, bail};
7use chrono::{DateTime, Utc};
8use flate2::write::GzEncoder;
9use getrandom::fill as fill_random;
10
11use super::blob::{
12    MissingLocalBlob, attachment_blob_contract, contract_by_hash, unique_blob_contracts,
13};
14use super::persistence::{ApplySyncPage, ClientSyncPage};
15use super::planner::{
16    PendingChange, TransferBudget, TransferObject, plan_change_prefix, plan_transfers,
17};
18use super::wire::{
19    BlobUploadContract, MAX_BLOB_TRANSFER_BYTES, MAX_BLOB_TRANSFER_OBJECTS, MAX_PULL_BATCH,
20    MAX_PUSH_BATCH, MissingBlobsRequest, MissingBlobsResponse, SYNC_PROTOCOL_VERSION, SyncResponse,
21    sync_server_url_is_valid, validate_blob_hashes, validate_sync_response_for_request,
22};
23use crate::attachments::lifecycle::{ByteCount, LifecyclePolicy};
24use crate::db::Database;
25use crate::ids::{new_id, now};
26
27const GZIP_THRESHOLD: usize = 256;
28const MAX_HTTP_ERROR_DETAIL_BYTES: usize = 1024;
29const MAX_SYNC_ATTEMPTS: u32 = 4;
30const BASE_RETRY_DELAY_MS: u64 = 250;
31const MAX_RETRY_DELAY_MS: u64 = 4_000;
32const MAX_RETRY_AFTER_MS: u64 = 30_000;
33const METADATA_ATTEMPT_TIMEOUT_MS: u64 = 30_000;
34const METADATA_INACTIVITY_TIMEOUT_MS: u64 = 10_000;
35const BLOB_INACTIVITY_TIMEOUT_MS: u64 = 30_000;
36const BLOB_ATTEMPT_BASE_TIMEOUT_MS: u64 = 60_000;
37const BLOB_FLOOR_BYTES_PER_SECOND: u64 = 64 * 1024;
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct SyncRequestContext {
41    session_id: String,
42    request: usize,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct SyncHttpHeader {
47    pub name: String,
48    pub value: String,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct SyncRequestTimeout {
53    pub attempt_ms: u64,
54    pub inactivity_ms: u64,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum SyncRetryDecision {
59    RetryAfter { delay_ms: u64 },
60    Stop,
61}
62
63#[derive(Clone)]
64pub struct PreparedSyncRequest {
65    pub method: String,
66    pub url: String,
67    pub headers: Vec<SyncHttpHeader>,
68    pub body: Vec<u8>,
69    pub timeout: SyncRequestTimeout,
70    pub context: SyncRequestContext,
71}
72
73pub struct SyncHttpResponse {
74    pub status: u16,
75    pub headers: Vec<SyncHttpHeader>,
76    pub body: Vec<u8>,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum PairingConnectionValidationResponse {
81    Accepted,
82    RejectedCredentials,
83    MalformedResponse,
84    IncompatibleServer,
85    ServerFailure,
86    Timeout,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SyncPageOutcome {
91    pub page: usize,
92    pub pushed: usize,
93    pub pulled: usize,
94    pub blob_uploaded: usize,
95    pub blob_uploaded_bytes: u64,
96    pub blob_downloaded: usize,
97    pub blob_downloaded_bytes: u64,
98    pub cursor: i64,
99    pub complete: bool,
100    pub has_more: bool,
101    pub local_more: bool,
102    pub request_bytes: usize,
103    pub request_wire_bytes: usize,
104    pub response_decoded_bytes: usize,
105    pub response_compression: String,
106    pub apply_ms: u128,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SyncSessionSummary {
111    pub pushed: i64,
112    pub pulled: usize,
113    pub blob_uploaded: usize,
114    pub blob_uploaded_bytes: u64,
115    pub blob_downloaded: usize,
116    pub blob_downloaded_bytes: u64,
117    pub blob_upload_remaining: usize,
118    pub blob_upload_remaining_bytes: u64,
119    pub blob_download_remaining: usize,
120    pub blob_download_remaining_bytes: u64,
121    pub cursor: i64,
122    pub complete: bool,
123    pub pages: usize,
124    pub request_bytes: usize,
125    pub request_wire_bytes: usize,
126    pub response_decoded_bytes: usize,
127    pub response_compression: String,
128    pub apply_ms: u128,
129}
130
131struct ActivePage {
132    page: ClientSyncPage,
133    contracts: Vec<BlobUploadContract>,
134    probed: bool,
135    missing: HashSet<String>,
136    uploads: Vec<BlobUploadContract>,
137    upload_index: usize,
138    confirmed: bool,
139    metadata_sent: bool,
140    downloads: Vec<MissingLocalBlob>,
141    download_index: usize,
142    has_more: bool,
143    page_pushed: usize,
144    page_pulled: usize,
145    page_uploaded: usize,
146    page_uploaded_bytes: u64,
147    page_downloaded: usize,
148    page_downloaded_bytes: u64,
149    transfer_budget_blocked: bool,
150}
151
152impl ActivePage {
153    fn new(mut page: ClientSyncPage) -> Result<Self> {
154        let mut hashes = HashSet::new();
155        let mut count = page.request.changes.len();
156        for (index, change) in page.request.changes.iter().enumerate() {
157            if let Some(contract) = attachment_blob_contract(change)?
158                && hashes.insert(contract.sha256)
159                && hashes.len() > MAX_BLOB_TRANSFER_OBJECTS
160            {
161                count = index;
162                break;
163            }
164        }
165        page.request.changes.truncate(count);
166        page.pending = count;
167        let contracts = unique_blob_contracts(&page.request.changes)?;
168        Ok(Self {
169            page,
170            contracts,
171            probed: false,
172            missing: HashSet::new(),
173            uploads: Vec::new(),
174            upload_index: 0,
175            confirmed: false,
176            metadata_sent: false,
177            downloads: Vec::new(),
178            download_index: 0,
179            has_more: false,
180            page_pushed: 0,
181            page_pulled: 0,
182            page_uploaded: 0,
183            page_uploaded_bytes: 0,
184            page_downloaded: 0,
185            page_downloaded_bytes: 0,
186            transfer_budget_blocked: false,
187        })
188    }
189}
190
191#[derive(Clone)]
192enum RequestKind {
193    Missing,
194    Upload {
195        lease_id: String,
196        contract: BlobUploadContract,
197    },
198    Confirm,
199    Metadata {
200        request_bytes: usize,
201    },
202    Download {
203        blob: MissingLocalBlob,
204    },
205}
206
207struct OutstandingRequest {
208    prepared: PreparedSyncRequest,
209    kind: RequestKind,
210    attempts: u32,
211}
212
213pub struct SyncSession {
214    database: Database,
215    server: String,
216    auth_token: Option<String>,
217    blob_dir: PathBuf,
218    lifecycle_policy: LifecyclePolicy,
219    page_budget: Option<usize>,
220    attempted_at: String,
221    session_id: String,
222    request_number: usize,
223    active: Option<ActivePage>,
224    outstanding: Option<OutstandingRequest>,
225    known_server_blobs: HashSet<String>,
226    transfer_budget: TransferBudget,
227    summary: SyncSessionSummary,
228    last_has_more: bool,
229    last_local_more: bool,
230    stopped: bool,
231}
232
233impl SyncSession {
234    pub async fn start(
235        database: Database,
236        server: String,
237        auth_token: Option<String>,
238        page_budget: Option<usize>,
239    ) -> Result<Self> {
240        let blob_dir = crate::attachments::default_blob_dir(database.path());
241        Self::start_with_attachment_storage(
242            database,
243            server,
244            auth_token,
245            page_budget,
246            blob_dir,
247            LifecyclePolicy::default(),
248        )
249        .await
250    }
251
252    pub async fn start_with_attachment_storage(
253        database: Database,
254        server: String,
255        auth_token: Option<String>,
256        page_budget: Option<usize>,
257        blob_dir: PathBuf,
258        lifecycle_policy: LifecyclePolicy,
259    ) -> Result<Self> {
260        let attempted_at = now();
261        database.begin_sync_attempt(attempted_at.clone()).await?;
262        if !sync_server_url_is_valid(&server) {
263            let message = "invalid sync server URL";
264            database.record_sync_error(message.to_string()).await?;
265            bail!("{message}");
266        }
267        Ok(Self {
268            database,
269            server: server.trim_end_matches('/').to_string(),
270            auth_token,
271            blob_dir,
272            lifecycle_policy,
273            page_budget,
274            attempted_at,
275            session_id: new_id(),
276            request_number: 0,
277            active: None,
278            outstanding: None,
279            known_server_blobs: HashSet::new(),
280            transfer_budget: TransferBudget {
281                objects: MAX_BLOB_TRANSFER_OBJECTS,
282                bytes: MAX_BLOB_TRANSFER_BYTES,
283                completed_objects: 0,
284            },
285            summary: SyncSessionSummary {
286                pushed: 0,
287                pulled: 0,
288                blob_uploaded: 0,
289                blob_uploaded_bytes: 0,
290                blob_downloaded: 0,
291                blob_downloaded_bytes: 0,
292                blob_upload_remaining: 0,
293                blob_upload_remaining_bytes: 0,
294                blob_download_remaining: 0,
295                blob_download_remaining_bytes: 0,
296                cursor: 0,
297                complete: false,
298                pages: 0,
299                request_bytes: 0,
300                request_wire_bytes: 0,
301                response_decoded_bytes: 0,
302                response_compression: "none".to_string(),
303                apply_ms: 0,
304            },
305            last_has_more: false,
306            last_local_more: false,
307            stopped: false,
308        })
309    }
310
311    pub async fn prepare_request(&mut self) -> Result<Option<PreparedSyncRequest>> {
312        if self.stopped {
313            return Ok(None);
314        }
315        if let Some(outstanding) = &self.outstanding {
316            return Ok(Some(outstanding.prepared.clone()));
317        }
318        if self.active.is_none() {
319            let page = self
320                .database
321                .prepare_client_sync_page(self.server.clone(), MAX_PUSH_BATCH, MAX_PULL_BATCH)
322                .await?;
323            self.active = Some(ActivePage::new(page)?);
324        }
325
326        let active = self.active.as_ref().expect("active sync page");
327        if !active.contracts.is_empty() && !active.probed {
328            return self.prepare_json_request(
329                "POST",
330                "/sync/blobs/missing",
331                &MissingBlobsRequest {
332                    blobs: active.contracts.clone(),
333                },
334                RequestKind::Missing,
335            );
336        }
337        if active.upload_index < active.uploads.len() {
338            let contract = active.uploads[active.upload_index].clone();
339            let upload = self
340                .database
341                .prepare_blob_upload(&self.blob_dir, &contract)
342                .await?;
343            let mut headers = vec![header("content-type", &contract.media_type)];
344            headers.push(header("x-aven-workspace-id", &contract.workspace_id));
345            headers.push(header("x-aven-byte-size", &contract.byte_size.to_string()));
346            headers.push(header("x-aven-width", &contract.width.to_string()));
347            headers.push(header("x-aven-height", &contract.height.to_string()));
348            return self.prepare_raw_request(
349                "PUT",
350                &format!("/sync/blobs/{}", contract.sha256),
351                headers,
352                upload.bytes,
353                RequestKind::Upload {
354                    lease_id: upload.lease_id,
355                    contract,
356                },
357            );
358        }
359        if !active.contracts.is_empty() && !active.confirmed {
360            return self.prepare_json_request(
361                "POST",
362                "/sync/blobs/missing",
363                &MissingBlobsRequest {
364                    blobs: active.contracts.clone(),
365                },
366                RequestKind::Confirm,
367            );
368        }
369        if !active.metadata_sent {
370            let body = serde_json::to_vec(&active.page.request).context("encode sync request")?;
371            let request_bytes = body.len();
372            let mut headers = vec![header("content-type", "application/json")];
373            let body = if request_bytes > GZIP_THRESHOLD {
374                headers.push(header("content-encoding", "gzip"));
375                gzip_encode(&body)?
376            } else {
377                body
378            };
379            return self.prepare_raw_request(
380                "POST",
381                "/sync",
382                headers,
383                body,
384                RequestKind::Metadata { request_bytes },
385            );
386        }
387        if active.download_index < active.downloads.len() {
388            let blob = active.downloads[active.download_index].clone();
389            return self.prepare_raw_request(
390                "GET",
391                &format!("/sync/blobs/{}", blob.sha256),
392                vec![header("accept-encoding", "identity")],
393                Vec::new(),
394                RequestKind::Download { blob },
395            );
396        }
397
398        self.finish_page().await?;
399        if self.stopped {
400            Ok(None)
401        } else {
402            Box::pin(self.prepare_request()).await
403        }
404    }
405
406    fn prepare_json_request<T: serde::Serialize>(
407        &mut self,
408        method: &str,
409        path: &str,
410        value: &T,
411        kind: RequestKind,
412    ) -> Result<Option<PreparedSyncRequest>> {
413        self.prepare_raw_request(
414            method,
415            path,
416            vec![header("content-type", "application/json")],
417            serde_json::to_vec(value)?,
418            kind,
419        )
420    }
421
422    fn prepare_raw_request(
423        &mut self,
424        method: &str,
425        path: &str,
426        mut headers: Vec<SyncHttpHeader>,
427        body: Vec<u8>,
428        kind: RequestKind,
429    ) -> Result<Option<PreparedSyncRequest>> {
430        if let Some(token) = &self.auth_token {
431            headers.push(header("authorization", &format!("Bearer {token}")));
432        }
433        self.request_number += 1;
434        let timeout = request_timeout(&kind);
435        let prepared = PreparedSyncRequest {
436            method: method.to_string(),
437            url: format!("{}{}", self.server, path),
438            headers,
439            body,
440            timeout,
441            context: SyncRequestContext {
442                session_id: self.session_id.clone(),
443                request: self.request_number,
444            },
445        };
446        self.outstanding = Some(OutstandingRequest {
447            prepared: prepared.clone(),
448            kind,
449            attempts: 1,
450        });
451        Ok(Some(prepared))
452    }
453
454    pub fn register_transport_failure(
455        &mut self,
456        context: &SyncRequestContext,
457    ) -> Result<SyncRetryDecision> {
458        self.register_retry(context, None)
459    }
460
461    pub fn register_http_failure(
462        &mut self,
463        context: &SyncRequestContext,
464        status: u16,
465        headers: &[SyncHttpHeader],
466    ) -> Result<SyncRetryDecision> {
467        {
468            let outstanding = self
469                .outstanding
470                .as_ref()
471                .context("no outstanding sync request")?;
472            validate_context(&outstanding.prepared.context, context)?;
473        }
474        if !retryable_http_status(status) {
475            return Ok(SyncRetryDecision::Stop);
476        }
477        let retry_after = retry_after_ms(headers, Utc::now().timestamp());
478        self.register_retry(context, retry_after)
479    }
480
481    fn register_retry(
482        &mut self,
483        context: &SyncRequestContext,
484        retry_after_ms: Option<u64>,
485    ) -> Result<SyncRetryDecision> {
486        let outstanding = self
487            .outstanding
488            .as_mut()
489            .context("no outstanding sync request")?;
490        validate_context(&outstanding.prepared.context, context)?;
491        if outstanding.attempts >= MAX_SYNC_ATTEMPTS {
492            return Ok(SyncRetryDecision::Stop);
493        }
494        let delay_ms = retry_after_ms.unwrap_or_else(|| retry_delay_ms(outstanding.attempts));
495        outstanding.attempts += 1;
496        Ok(SyncRetryDecision::RetryAfter { delay_ms })
497    }
498
499    pub async fn accept_response(
500        &mut self,
501        context: &SyncRequestContext,
502        response: SyncHttpResponse,
503    ) -> Result<SyncPageOutcome> {
504        {
505            let outstanding = self
506                .outstanding
507                .as_ref()
508                .context("no outstanding sync request")?;
509            validate_context(&outstanding.prepared.context, context)?;
510        }
511        if !(200..300).contains(&response.status) {
512            bail!("{}", sync_http_error(&response));
513        }
514        let outstanding = self
515            .outstanding
516            .as_ref()
517            .expect("validated outstanding request");
518        let prepared_body_len = outstanding.prepared.body.len();
519        let request_kind = outstanding.kind.clone();
520        let response_bytes = response.body.len();
521        match request_kind {
522            RequestKind::Missing => {
523                let decoded: MissingBlobsResponse = serde_json::from_slice(&response.body)
524                    .context("decode missing blobs response")?;
525                validate_blob_hashes(&decoded.missing)?;
526                let active = self.active.as_mut().expect("active sync page");
527                let requested = active
528                    .contracts
529                    .iter()
530                    .map(|c| c.sha256.as_str())
531                    .collect::<HashSet<_>>();
532                if decoded
533                    .missing
534                    .iter()
535                    .any(|hash| !requested.contains(hash.as_str()))
536                {
537                    bail!("error invalid-blob-missing-response");
538                }
539                let missing = decoded.missing.into_iter().collect::<HashSet<_>>();
540                self.known_server_blobs.extend(
541                    active
542                        .contracts
543                        .iter()
544                        .filter(|contract| !missing.contains(&contract.sha256))
545                        .map(|contract| contract.sha256.clone()),
546                );
547                active.probed = true;
548                active.missing = missing;
549                let pending = active
550                    .page
551                    .request
552                    .changes
553                    .iter()
554                    .map(|change| {
555                        let blob = match attachment_blob_contract(change)? {
556                            Some(contract) if active.missing.contains(&contract.sha256) => {
557                                Some(TransferObject {
558                                    sha256: contract.sha256,
559                                    byte_size: u64::try_from(contract.byte_size)
560                                        .context("attachment bytes exceed u64")?,
561                                })
562                            }
563                            _ => None,
564                        };
565                        Ok(PendingChange { missing_blob: blob })
566                    })
567                    .collect::<Result<Vec<_>>>()?;
568                let plan = plan_change_prefix(&pending, self.transfer_budget);
569                active.transfer_budget_blocked = plan.change_count < pending.len();
570                active.page.request.changes.truncate(plan.change_count);
571                active.page.pending = plan.change_count;
572                active.contracts = unique_blob_contracts(&active.page.request.changes)?;
573                active.uploads = plan
574                    .transfers
575                    .iter()
576                    .map(|object| contract_by_hash(&active.contracts, &object.sha256))
577                    .collect::<Result<Vec<_>>>()?;
578            }
579            RequestKind::Upload { lease_id, contract } => {
580                self.database.finish_blob_upload(&lease_id).await?;
581                let bytes =
582                    u64::try_from(contract.byte_size).context("attachment bytes exceed u64")?;
583                self.known_server_blobs.insert(contract.sha256.clone());
584                self.transfer_budget.consume(&TransferObject {
585                    sha256: contract.sha256,
586                    byte_size: bytes,
587                });
588                let active = self.active.as_mut().expect("active sync page");
589                active.upload_index += 1;
590                active.page_uploaded += 1;
591                active.page_uploaded_bytes += bytes;
592                self.summary.blob_uploaded += 1;
593                self.summary.blob_uploaded_bytes += bytes;
594            }
595            RequestKind::Confirm => {
596                let decoded: MissingBlobsResponse = serde_json::from_slice(&response.body)
597                    .context("decode missing blobs response")?;
598                validate_blob_hashes(&decoded.missing)?;
599                if !decoded.missing.is_empty() {
600                    bail!("error attachment-blob-admission-missing");
601                }
602                self.active.as_mut().expect("active sync page").confirmed = true;
603            }
604            RequestKind::Metadata { request_bytes } => {
605                let decoded: SyncResponse =
606                    serde_json::from_slice(&response.body).context("decode sync response")?;
607                let active = self.active.as_mut().expect("active sync page");
608                let request = active.page.request.clone();
609                let pending = active.page.pending;
610                let has_more = decoded.has_more;
611                let cursor = decoded.cursor;
612                let apply_started = Instant::now();
613                let pulled = self
614                    .database
615                    .apply_client_sync_page(ApplySyncPage {
616                        request,
617                        response: decoded,
618                        attempted_at: self.attempted_at.clone(),
619                        previous_pushed: self.summary.pushed,
620                        previous_pulled: self.summary.pulled,
621                    })
622                    .await?;
623                let apply_ms = apply_started.elapsed().as_millis();
624                active.metadata_sent = true;
625                active.has_more = has_more;
626                active.page_pushed = pending;
627                active.page_pulled = pulled;
628                self.summary.pushed += pending as i64;
629                self.summary.pulled += pulled;
630                self.summary.cursor = cursor;
631                self.summary.request_bytes += request_bytes;
632                self.summary.request_wire_bytes += prepared_body_len;
633                self.summary.response_decoded_bytes += response_bytes;
634                self.summary.response_compression =
635                    header_value(&response.headers, "content-encoding")
636                        .unwrap_or("none")
637                        .to_string();
638                self.summary.apply_ms += apply_ms;
639                let missing_page = self
640                    .database
641                    .missing_local_blob_page(MAX_BLOB_TRANSFER_OBJECTS)
642                    .await?;
643                let has_more_missing = missing_page.has_more;
644                let missing = missing_page.blobs;
645                let objects = missing
646                    .iter()
647                    .map(|blob| {
648                        Ok(TransferObject {
649                            sha256: blob.sha256.clone(),
650                            byte_size: u64::try_from(blob.byte_size)
651                                .context("attachment bytes exceed u64")?,
652                        })
653                    })
654                    .collect::<Result<Vec<_>>>()?;
655                let plan = plan_transfers(&objects, self.transfer_budget);
656                active.transfer_budget_blocked |= has_more_missing || plan.len() < objects.len();
657                let planned = plan
658                    .iter()
659                    .map(|o| o.sha256.as_str())
660                    .collect::<HashSet<_>>();
661                active.downloads = missing
662                    .into_iter()
663                    .filter(|b| planned.contains(b.sha256.as_str()))
664                    .collect();
665            }
666            RequestKind::Download { blob } => {
667                let expected =
668                    usize::try_from(blob.byte_size).context("attachment bytes exceed usize")?;
669                let content_length_valid = header_value(&response.headers, "content-length")
670                    .map(|length| length.parse::<usize>().ok() == Some(expected))
671                    .unwrap_or(true);
672                if !content_length_valid || response.body.len() != expected {
673                    bail!("error attachment-blob-remote-invalid");
674                }
675                self.database
676                    .store_downloaded_blob(
677                        &self.blob_dir,
678                        self.lifecycle_policy,
679                        &blob,
680                        response.body,
681                    )
682                    .await?;
683                let bytes = u64::try_from(blob.byte_size).context("attachment bytes exceed u64")?;
684                self.transfer_budget.consume(&TransferObject {
685                    sha256: blob.sha256,
686                    byte_size: bytes,
687                });
688                let active = self.active.as_mut().expect("active sync page");
689                active.download_index += 1;
690                active.page_downloaded += 1;
691                active.page_downloaded_bytes += bytes;
692                self.summary.blob_downloaded += 1;
693                self.summary.blob_downloaded_bytes += bytes;
694            }
695        }
696        self.outstanding = None;
697        let page_finished = self.active.as_ref().is_some_and(|active| {
698            active.metadata_sent && active.download_index >= active.downloads.len()
699        });
700        if page_finished {
701            let mut outcome = self.outcome();
702            self.finish_page().await?;
703            outcome.page = self.summary.pages;
704            outcome.complete = self.summary.complete;
705            outcome.has_more = self.last_has_more;
706            outcome.local_more = self.last_local_more;
707            return Ok(outcome);
708        }
709        Ok(self.outcome())
710    }
711
712    async fn finish_page(&mut self) -> Result<()> {
713        let active = self.active.take().expect("active sync page");
714        self.summary.pages += 1;
715        self.last_has_more = active.has_more;
716        let local_more = self.database.pending_sync_changes_exist().await?;
717        self.last_local_more = local_more;
718        let complete = !local_more && !active.has_more && !active.transfer_budget_blocked;
719        self.summary.complete = complete;
720        let budget_exhausted = self
721            .page_budget
722            .is_some_and(|budget| self.summary.pages >= budget);
723        let transfer_stalled = active.transfer_budget_blocked
724            || self.transfer_budget.objects == 0
725            || self.transfer_budget.bytes == 0;
726        let should_stop = complete || budget_exhausted || transfer_stalled;
727        if should_stop {
728            let download_counts = if complete {
729                ByteCount::default()
730            } else {
731                self.database.missing_local_blob_counts().await?
732            };
733            self.summary.blob_download_remaining = usize::try_from(download_counts.count)?;
734            self.summary.blob_download_remaining_bytes = download_counts.bytes;
735            let upload_counts = self
736                .database
737                .pending_blob_counts(&self.known_server_blobs)
738                .await?;
739            self.summary.blob_upload_remaining = usize::try_from(upload_counts.count)?;
740            self.summary.blob_upload_remaining_bytes = upload_counts.bytes;
741            self.stop();
742        }
743        Ok(())
744    }
745
746    fn stop(&mut self) {
747        self.stopped = true;
748    }
749
750    fn outcome(&self) -> SyncPageOutcome {
751        let active = self.active.as_ref();
752        SyncPageOutcome {
753            page: self.summary.pages + usize::from(active.is_some_and(|page| page.metadata_sent)),
754            pushed: active.map_or(0, |page| page.page_pushed),
755            pulled: active.map_or(0, |page| page.page_pulled),
756            blob_uploaded: active.map_or(0, |page| page.page_uploaded),
757            blob_uploaded_bytes: active.map_or(0, |page| page.page_uploaded_bytes),
758            blob_downloaded: active.map_or(0, |page| page.page_downloaded),
759            blob_downloaded_bytes: active.map_or(0, |page| page.page_downloaded_bytes),
760            cursor: self.summary.cursor,
761            complete: self.summary.complete,
762            has_more: active.map_or(self.last_has_more, |page| page.has_more),
763            local_more: self.last_local_more,
764            request_bytes: self.summary.request_bytes,
765            request_wire_bytes: self.summary.request_wire_bytes,
766            response_decoded_bytes: self.summary.response_decoded_bytes,
767            response_compression: self.summary.response_compression.clone(),
768            apply_ms: self.summary.apply_ms,
769        }
770    }
771
772    pub async fn fail_request(
773        &mut self,
774        context: &SyncRequestContext,
775        error: impl Into<String>,
776    ) -> Result<()> {
777        let outstanding = self
778            .outstanding
779            .as_ref()
780            .context("no outstanding sync request")?;
781        validate_context(&outstanding.prepared.context, context)?;
782        self.fail(error).await
783    }
784
785    pub async fn fail(&mut self, error: impl Into<String>) -> Result<()> {
786        if let Some(OutstandingRequest {
787            kind: RequestKind::Upload { lease_id, .. },
788            ..
789        }) = &self.outstanding
790        {
791            self.database.finish_blob_upload(lease_id).await?;
792        }
793        self.database.record_sync_error(error.into()).await?;
794        self.outstanding = None;
795        self.stop();
796        Ok(())
797    }
798
799    pub fn summary(&self) -> SyncSessionSummary {
800        self.summary.clone()
801    }
802}
803
804pub fn classify_pairing_connection_validation_response(
805    response: &SyncHttpResponse,
806) -> PairingConnectionValidationResponse {
807    match response.status {
808        200..=299 => {
809            let Ok(decoded) = serde_json::from_slice::<SyncResponse>(&response.body) else {
810                return PairingConnectionValidationResponse::MalformedResponse;
811            };
812            if decoded.protocol_version != SYNC_PROTOCOL_VERSION {
813                return PairingConnectionValidationResponse::IncompatibleServer;
814            }
815            if validate_sync_response_for_request(0, 1, &[], &decoded).is_err() {
816                return PairingConnectionValidationResponse::MalformedResponse;
817            }
818            PairingConnectionValidationResponse::Accepted
819        }
820        401 => PairingConnectionValidationResponse::RejectedCredentials,
821        408 | 504 => PairingConnectionValidationResponse::Timeout,
822        300..=399 | 404 | 405 | 426 => PairingConnectionValidationResponse::IncompatibleServer,
823        400 if sync_protocol_versions(sync_http_error_detail(response)).is_some() => {
824            PairingConnectionValidationResponse::IncompatibleServer
825        }
826        _ => PairingConnectionValidationResponse::ServerFailure,
827    }
828}
829
830fn request_timeout(kind: &RequestKind) -> SyncRequestTimeout {
831    match kind {
832        RequestKind::Missing | RequestKind::Confirm | RequestKind::Metadata { .. } => {
833            SyncRequestTimeout {
834                attempt_ms: METADATA_ATTEMPT_TIMEOUT_MS,
835                inactivity_ms: METADATA_INACTIVITY_TIMEOUT_MS,
836            }
837        }
838        RequestKind::Upload { contract, .. } => blob_request_timeout(contract.byte_size),
839        RequestKind::Download { blob } => blob_request_timeout(blob.byte_size),
840    }
841}
842
843fn blob_request_timeout(byte_size: i64) -> SyncRequestTimeout {
844    let bytes = u64::try_from(byte_size).unwrap_or(0);
845    let transfer_ms = bytes
846        .saturating_mul(1_000)
847        .div_ceil(BLOB_FLOOR_BYTES_PER_SECOND);
848    SyncRequestTimeout {
849        attempt_ms: BLOB_ATTEMPT_BASE_TIMEOUT_MS.saturating_add(transfer_ms),
850        inactivity_ms: BLOB_INACTIVITY_TIMEOUT_MS,
851    }
852}
853
854fn retryable_http_status(status: u16) -> bool {
855    matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
856}
857
858fn retry_delay_ms(failed_attempts: u32) -> u64 {
859    let mut entropy = [0_u8; 8];
860    if fill_random(&mut entropy).is_err() {
861        entropy = u64::from(failed_attempts).to_le_bytes();
862    }
863    retry_delay_with_entropy(failed_attempts, u64::from_le_bytes(entropy))
864}
865
866fn retry_delay_with_entropy(failed_attempts: u32, entropy: u64) -> u64 {
867    let exponent = failed_attempts.saturating_sub(1);
868    let cap = (BASE_RETRY_DELAY_MS * (1_u64 << exponent)).min(MAX_RETRY_DELAY_MS);
869    entropy % (cap + 1)
870}
871
872fn retry_after_ms(headers: &[SyncHttpHeader], now_unix_seconds: i64) -> Option<u64> {
873    let mut values = headers
874        .iter()
875        .filter(|header| header.name.eq_ignore_ascii_case("retry-after"));
876    let value = values.next()?.value.trim();
877    if values.next().is_some() {
878        return None;
879    }
880    let milliseconds = if let Ok(seconds) = value.parse::<u64>() {
881        seconds.saturating_mul(1_000)
882    } else {
883        let timestamp = parse_http_date(value)?;
884        u64::try_from(timestamp.saturating_sub(now_unix_seconds))
885            .unwrap_or(0)
886            .saturating_mul(1_000)
887    };
888    Some(milliseconds.min(MAX_RETRY_AFTER_MS))
889}
890
891fn parse_http_date(value: &str) -> Option<i64> {
892    DateTime::parse_from_rfc2822(value)
893        .ok()
894        .map(|value| value.timestamp())
895}
896
897fn header(name: &str, value: &str) -> SyncHttpHeader {
898    SyncHttpHeader {
899        name: name.to_string(),
900        value: value.to_string(),
901    }
902}
903
904fn validate_context(expected: &SyncRequestContext, actual: &SyncRequestContext) -> Result<()> {
905    if expected != actual {
906        bail!("sync request context does not match the outstanding page");
907    }
908    Ok(())
909}
910
911fn header_value<'a>(headers: &'a [SyncHttpHeader], name: &str) -> Option<&'a str> {
912    headers
913        .iter()
914        .find(|header| header.name.eq_ignore_ascii_case(name))
915        .map(|header| header.value.as_str())
916}
917
918fn sync_http_error_detail(response: &SyncHttpResponse) -> Option<&str> {
919    header_value(&response.headers, "content-type")
920        .filter(|content_type| {
921            content_type
922                .split(';')
923                .next()
924                .is_some_and(|value| value.trim().eq_ignore_ascii_case("text/plain"))
925        })
926        .and_then(|_| std::str::from_utf8(&response.body).ok())
927        .map(str::trim)
928        .filter(|detail| {
929            detail.starts_with("error ")
930                && detail.len() <= MAX_HTTP_ERROR_DETAIL_BYTES
931                && detail.is_ascii()
932                && !detail.chars().any(char::is_control)
933        })
934}
935
936fn sync_http_error(response: &SyncHttpResponse) -> String {
937    let detail = match sync_http_error_detail(response) {
938        Some(detail) => actionable_sync_http_error_detail(detail).unwrap_or_else(|| detail.to_string()),
939        None => match response.status {
940            400 => {
941                "server rejected the sync request; check server logs and verify client/server version compatibility"
942            }
943            401 => "authentication failed; check sync.auth_token",
944            403 => "server denied the sync request; check server access settings",
945            404 => "sync endpoint not found; check sync.server_url",
946            429 => "server rate limit exceeded; retry later",
947            507 => "error attachment-quota-exceeded",
948            500..=599 => "sync server failed; check server logs",
949            _ => "server returned no readable error details",
950        }
951        .to_string(),
952    };
953    format!(
954        "sync request failed with HTTP status {}: {detail}",
955        response.status
956    )
957}
958
959fn sync_protocol_versions(detail: Option<&str>) -> Option<(u32, u32)> {
960    let fields = detail?.strip_prefix("error sync-protocol-unsupported ")?;
961    let mut fields = fields.split_whitespace();
962    let client = fields
963        .next()?
964        .strip_prefix("client=")?
965        .parse::<u32>()
966        .ok()?;
967    let server = fields
968        .next()?
969        .strip_prefix("server=")?
970        .parse::<u32>()
971        .ok()?;
972    if fields.next().is_some() || client == server {
973        return None;
974    }
975    Some((client, server))
976}
977
978fn actionable_sync_http_error_detail(detail: &str) -> Option<String> {
979    let (client, server) = sync_protocol_versions(Some(detail))?;
980    let action = if client > server {
981        "upgrade the sync server"
982    } else {
983        "upgrade the sync client"
984    };
985    Some(format!(
986        "client and server use incompatible sync protocol versions (client {client}, server {server}); {action}"
987    ))
988}
989
990fn gzip_encode(body: &[u8]) -> Result<Vec<u8>> {
991    let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
992    encoder
993        .write_all(body)
994        .context("gzip encode sync request body")?;
995    encoder.finish().context("finish sync request compression")
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    fn validation_response(status: u16, body: serde_json::Value) -> SyncHttpResponse {
1003        SyncHttpResponse {
1004            status,
1005            headers: vec![header("content-type", "application/json")],
1006            body: serde_json::to_vec(&body).unwrap(),
1007        }
1008    }
1009
1010    #[test]
1011    fn pairing_validation_classifies_protocol_responses() {
1012        let accepted = validation_response(
1013            200,
1014            serde_json::json!({
1015                "protocol_version": SYNC_PROTOCOL_VERSION,
1016                "cursor": 0,
1017                "has_more": false,
1018                "push_acks": [],
1019                "changes": [],
1020            }),
1021        );
1022        let incompatible = validation_response(
1023            200,
1024            serde_json::json!({
1025                "protocol_version": SYNC_PROTOCOL_VERSION + 1,
1026                "cursor": 0,
1027                "has_more": false,
1028                "push_acks": [],
1029                "changes": [],
1030            }),
1031        );
1032        let malformed = validation_response(
1033            200,
1034            serde_json::json!({
1035                "protocol_version": SYNC_PROTOCOL_VERSION,
1036                "cursor": 1,
1037                "has_more": false,
1038                "push_acks": [],
1039                "changes": [],
1040            }),
1041        );
1042
1043        assert_eq!(
1044            classify_pairing_connection_validation_response(&accepted),
1045            PairingConnectionValidationResponse::Accepted
1046        );
1047        assert_eq!(
1048            classify_pairing_connection_validation_response(&incompatible),
1049            PairingConnectionValidationResponse::IncompatibleServer
1050        );
1051        assert_eq!(
1052            classify_pairing_connection_validation_response(&malformed),
1053            PairingConnectionValidationResponse::MalformedResponse
1054        );
1055    }
1056
1057    #[test]
1058    fn pairing_validation_classifies_http_failures_without_details() {
1059        let response = |status| SyncHttpResponse {
1060            status,
1061            headers: Vec::new(),
1062            body: Vec::new(),
1063        };
1064
1065        assert_eq!(
1066            classify_pairing_connection_validation_response(&response(401)),
1067            PairingConnectionValidationResponse::RejectedCredentials
1068        );
1069        assert_eq!(
1070            classify_pairing_connection_validation_response(&response(404)),
1071            PairingConnectionValidationResponse::IncompatibleServer
1072        );
1073        assert_eq!(
1074            classify_pairing_connection_validation_response(&response(408)),
1075            PairingConnectionValidationResponse::Timeout
1076        );
1077        assert_eq!(
1078            classify_pairing_connection_validation_response(&response(403)),
1079            PairingConnectionValidationResponse::ServerFailure
1080        );
1081        assert_eq!(
1082            classify_pairing_connection_validation_response(&response(503)),
1083            PairingConnectionValidationResponse::ServerFailure
1084        );
1085
1086        let unsupported = SyncHttpResponse {
1087            status: 400,
1088            headers: vec![header("content-type", "text/plain; charset=utf-8")],
1089            body: b"error sync-protocol-unsupported client=15 server=16".to_vec(),
1090        };
1091        assert_eq!(
1092            classify_pairing_connection_validation_response(&unsupported),
1093            PairingConnectionValidationResponse::IncompatibleServer
1094        );
1095        assert_eq!(
1096            classify_pairing_connection_validation_response(&response(400)),
1097            PairingConnectionValidationResponse::ServerFailure
1098        );
1099        assert_eq!(
1100            classify_pairing_connection_validation_response(&response(302)),
1101            PairingConnectionValidationResponse::IncompatibleServer
1102        );
1103    }
1104
1105    #[test]
1106    fn retry_after_accepts_http_forms_and_caps_delays() {
1107        let now = 1_445_412_480;
1108        let headers = [header("retry-after", "Wed, 21 Oct 2015 07:28:02 GMT")];
1109        assert_eq!(retry_after_ms(&headers, now), Some(2_000));
1110        assert_eq!(
1111            retry_after_ms(&[header("retry-after", "86400")], now),
1112            Some(MAX_RETRY_AFTER_MS)
1113        );
1114        assert_eq!(
1115            retry_after_ms(
1116                &[header("retry-after", "1"), header("retry-after", "2")],
1117                now
1118            ),
1119            None
1120        );
1121        assert_eq!(
1122            retry_after_ms(&[header("retry-after", "invalid")], now),
1123            None
1124        );
1125    }
1126
1127    #[test]
1128    fn backoff_uses_full_bounded_jitter() {
1129        assert_eq!(retry_delay_with_entropy(1, 0), 0);
1130        assert_eq!(retry_delay_with_entropy(1, 250), 250);
1131        assert_eq!(retry_delay_with_entropy(2, 500), 500);
1132        assert_eq!(retry_delay_with_entropy(3, 1_000), 1_000);
1133    }
1134
1135    #[test]
1136    fn blob_timeout_scales_with_transfer_size() {
1137        let small = blob_request_timeout(1);
1138        let large = blob_request_timeout(64 * 1024 * 1024);
1139        assert_eq!(small.inactivity_ms, BLOB_INACTIVITY_TIMEOUT_MS);
1140        assert_eq!(large.inactivity_ms, BLOB_INACTIVITY_TIMEOUT_MS);
1141        assert!(large.attempt_ms > METADATA_ATTEMPT_TIMEOUT_MS);
1142        assert!(large.attempt_ms > small.attempt_ms);
1143    }
1144
1145    #[test]
1146    fn retryable_statuses_exclude_deterministic_failures() {
1147        for status in [408, 425, 429, 500, 502, 503, 504] {
1148            assert!(retryable_http_status(status));
1149        }
1150        for status in [400, 401, 403, 404, 501, 505, 507] {
1151            assert!(!retryable_http_status(status));
1152        }
1153    }
1154}