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