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