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