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