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;