1use std::collections::BTreeMap;
3
4use ssp2::model::{Frame, MediaType, Message, MsgKind, SubStatus};
5use ssp2::{decode_message, encode_message};
6
7use crate::client::RequestMeta;
8use crate::{
9 BlobUploadGrant, ProgressObserver, ProgressPhase, SegmentRequest, SyncOutcome, Transport,
10 TransportError,
11};
12
13pub(crate) fn now_ms(fixed: Option<i64>) -> i64 {
14 fixed.unwrap_or_else(crate::wall_clock_ms)
15}
16
17pub(crate) struct PendingUpload {
18 pub id: String,
19 pub bytes: Result<Vec<u8>, TransportError>,
20 pub media_type: Option<String>,
21}
22
23pub(crate) fn upload_one(
24 transport: &mut dyn Transport,
25 id: &str,
26 bytes: &[u8],
27 media_type: Option<&str>,
28 fixed_now: Option<i64>,
29) -> Result<(), TransportError> {
30 match transport.blob_upload_grant(id, bytes.len() as u64, media_type)? {
31 BlobUploadGrant::Present => return Ok(()),
32 BlobUploadGrant::Url {
33 url,
34 url_expires_at_ms,
35 } => {
36 if url_expires_at_ms.is_none_or(|expiry| expiry > now_ms(fixed_now))
37 && transport.blob_put_url(&url, bytes, media_type).is_ok()
38 {
39 return Ok(());
40 }
41 }
42 BlobUploadGrant::None => {}
43 }
44 transport.blob_upload(id, bytes, media_type)
45}
46
47pub struct PreparedSyncRound {
50 pub(crate) id: uuid::Uuid,
51 pub(crate) started_at_ms: i64,
52 pub(crate) meta: RequestMeta,
53 pub(crate) message: Message,
54 pub(crate) realtime: bool,
55 pub(crate) uploads: std::collections::VecDeque<PendingUpload>,
56 pub(crate) scopes: BTreeMap<String, String>,
57 #[cfg(feature = "bench-internals")]
58 pub(crate) benchmark_phases: crate::bench::Recorder,
59 pub(crate) progress: ProgressObserver,
60 pub(crate) fixed_now: Option<i64>,
61 pub(crate) round_deadline_at: Option<std::time::Instant>,
66}
67
68pub struct CompletedSyncRound {
70 pub(crate) prepared: PreparedSyncRound,
71 pub(crate) exchange: ExchangeResult,
72}
73
74pub(crate) enum ExchangeResult {
75 Upload {
76 id: String,
77 result: Result<(), TransportError>,
78 },
79 Reply {
80 response: Result<Message, TransportError>,
81 transport_failed: bool,
82 downloads: DownloadResults,
83 },
84}
85
86pub enum AppliedSyncRound {
89 Continue(PreparedSyncRound),
90 Complete {
91 outcome: SyncOutcome,
92 controls: Vec<String>,
93 more: bool,
95 bootstrap_advanced: bool,
97 },
98}
99
100impl PreparedSyncRound {
101 fn scope_round_deadline(
106 &mut self,
107 transport: &mut dyn Transport,
108 ) -> Result<(), TransportError> {
109 if self.round_deadline_at.is_none() {
110 if let Some(budget) = transport.round_deadline() {
111 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
115 {
116 let _ = budget;
117 return Err(TransportError::new(
118 "sync.invalid_request",
119 "transport round deadline is unsupported on wasm32-unknown-unknown",
120 ));
121 }
122 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
123 match std::time::Instant::now().checked_add(budget) {
124 Some(deadline) => self.round_deadline_at = Some(deadline),
125 None => {
127 return Err(TransportError::new(
128 "sync.invalid_request",
129 "transport round deadline is out of range",
130 ))
131 }
132 }
133 }
134 }
135 transport.set_round_deadline(self.round_deadline_at);
136 Ok(())
137 }
138
139 pub fn exchange(mut self, transport: &mut dyn Transport) -> CompletedSyncRound {
142 if let Some(upload) = self.uploads.pop_front() {
143 let result = match upload.bytes {
147 Ok(bytes) => self.scope_round_deadline(transport).and_then(|()| {
148 upload_one(
149 transport,
150 &upload.id,
151 &bytes,
152 upload.media_type.as_deref(),
153 self.fixed_now,
154 )
155 }),
156 Err(error) => Err(error),
157 };
158 transport.set_round_deadline(None);
159 return CompletedSyncRound {
160 prepared: self,
161 exchange: ExchangeResult::Upload {
162 id: upload.id,
163 result,
164 },
165 };
166 }
167 let mut transport_failed = false;
168 let response = (|| {
169 #[cfg(feature = "bench-internals")]
170 let encode_phase = self
171 .benchmark_phases
172 .start(crate::bench::Phase::RequestEncode);
173 let request = encode_message(&self.message);
174 #[cfg(feature = "bench-internals")]
175 drop(encode_phase);
176 self.scope_round_deadline(transport)?;
180 let round = if self.realtime {
181 transport.realtime_sync(&request)
182 } else {
183 transport.sync(&request)
184 };
185 transport_failed = round.is_err();
186 let bytes = round?;
187 #[cfg(feature = "bench-internals")]
188 let _decode_phase = self
189 .benchmark_phases
190 .start(crate::bench::Phase::ResponseDecode);
191 let response = decode_message(&bytes)
192 .map_err(|e| TransportError::new(e.code.as_str(), e.detail))?;
193 if response.msg_kind != MsgKind::Response {
194 return Err(TransportError::new(
195 "sync.invalid_request",
196 "expected a response message",
197 ));
198 }
199 Ok(response)
200 })();
201 let mut downloads = DownloadResults::default();
202 if let Ok(response) = &response {
203 if matches!(response.frames.first(), Some(Frame::RespHeader { required_schema_version: None, reset_required: Some(false), log_epoch: Some(epoch), .. }) if self.message.frames.first().is_some_and(|f| matches!(f, Frame::ReqHeader { log_epoch: Some(request_epoch), .. } if request_epoch == epoch)))
205 {
206 let mut scope: Option<(&str, &str)> = None;
207 for frame in &response.frames {
208 match frame {
209 Frame::SubStart {
210 id,
211 status: SubStatus::Active,
212 ..
213 } => {
214 scope = self
215 .scopes
216 .get(id)
217 .map(|scopes| (id.as_str(), scopes.as_str()));
218 }
219 Frame::SubStart { .. } | Frame::SubEnd { .. } => scope = None,
220 Frame::Error { .. } => break,
221 Frame::SegmentRef {
222 segment_id,
223 table,
224 media_type,
225 url,
226 url_expires_at_ms,
227 byte_length,
228 row_count,
229 ..
230 } => {
231 let Some((id, scopes)) = scope else { continue };
232 let advertised = match media_type {
233 MediaType::Rows => self.meta.accept & 3 != 0,
234 MediaType::Sqlite => self.meta.accept & 4 != 0,
235 };
236 if !advertised || (url.is_some() && self.meta.accept & 8 == 0) {
237 break;
238 }
239 let started_at = now_ms(self.fixed_now);
240 let key = match url {
241 Some(url) => DownloadKey::Url(url.clone()),
242 None => DownloadKey::Segment(
243 segment_id.clone(),
244 table.clone(),
245 scopes.to_owned(),
246 ),
247 };
248 self.progress.update(|p| {
249 p.phase = ProgressPhase::Download;
250 p.subscription_id = Some(id.to_owned());
251 p.table = Some(table.clone());
252 p.segment_id = Some(segment_id.clone());
253 p.bytes_received = 0;
254 p.bytes_total = u64::try_from(*byte_length).ok();
255 p.rows_processed = 0;
256 p.rows_total = u64::try_from(*row_count).ok();
257 });
258 let mut on_progress =
259 |bytes| self.progress.update(|p| p.bytes_received = bytes);
260 let bytes = if let Some(url) = url {
261 if url_expires_at_ms.is_some_and(|expiry| expiry <= started_at) {
262 Err(TransportError::new(
263 "sync.segment_expired",
264 "signed segment URL expired before fetch",
265 ))
266 } else {
267 transport.fetch_url(url, &mut on_progress)
268 }
269 } else {
270 transport.download_segment(
271 &SegmentRequest {
272 segment_id: segment_id.clone(),
273 table: table.clone(),
274 requested_scopes_json: scopes.to_owned(),
275 },
276 &mut on_progress,
277 )
278 };
279 let failed = bytes.is_err();
280 downloads.entries.push_back((key, started_at, bytes));
281 if failed {
282 break;
283 }
284 }
285 _ => {}
286 }
287 }
288 }
289 }
290 transport.set_round_deadline(None);
291 CompletedSyncRound {
292 prepared: self,
293 exchange: ExchangeResult::Reply {
294 response,
295 downloads,
296 transport_failed,
297 },
298 }
299 }
300}
301
302#[derive(PartialEq)]
303enum DownloadKey {
304 Url(String),
305 Segment(String, String, String),
306}
307
308#[derive(Default)]
309pub(crate) struct DownloadResults {
310 entries: std::collections::VecDeque<(DownloadKey, i64, Result<Vec<u8>, TransportError>)>,
311 pub controls: Vec<String>,
312}
313
314impl DownloadResults {
315 fn take(&mut self, key: DownloadKey) -> Result<Vec<u8>, TransportError> {
316 let position = self
317 .entries
318 .iter()
319 .position(|(candidate, ..)| *candidate == key);
320 position
321 .and_then(|i| self.entries.remove(i))
322 .map(|(_, _, bytes)| bytes)
323 .unwrap_or_else(|| {
324 Err(TransportError::new(
325 "client.round_download_missing",
326 "round apply requested an uncaptured segment",
327 ))
328 })
329 }
330}
331
332impl Transport for DownloadResults {
333 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
334 Err(TransportError::new(
335 "client.round_network_on_owner",
336 "network requests must execute outside the client owner",
337 ))
338 }
339 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
340 self.sync(request)
341 }
342 fn download_segment(
343 &mut self,
344 request: &SegmentRequest,
345 _: &mut dyn FnMut(u64),
346 ) -> Result<Vec<u8>, TransportError> {
347 self.take(DownloadKey::Segment(
348 request.segment_id.clone(),
349 request.table.clone(),
350 request.requested_scopes_json.clone(),
351 ))
352 }
353 fn fetch_url(&mut self, url: &str, _: &mut dyn FnMut(u64)) -> Result<Vec<u8>, TransportError> {
354 self.take(DownloadKey::Url(url.to_owned()))
355 }
356 fn segment_fetch_started_at(&self, url: &str) -> Option<i64> {
357 self.entries
358 .iter()
359 .find_map(|(key, time, _)| (*key == DownloadKey::Url(url.to_owned())).then_some(*time))
360 }
361 fn realtime_connect(&mut self) -> Result<(), TransportError> {
362 Err(TransportError::new(
363 "client.round_network_on_owner",
364 "network connections must execute outside the client owner",
365 ))
366 }
367 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
368 self.controls.push(text.to_owned());
369 Ok(())
370 }
371 fn realtime_close(&mut self) -> Result<(), TransportError> {
372 Ok(())
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::{BlobUploadGrant, SegmentRequest, TransportError};
380
381 struct SpentDeadline;
385
386 impl Transport for SpentDeadline {
387 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
388 unreachable!()
389 }
390 fn realtime_sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
391 unreachable!()
392 }
393 fn download_segment(
394 &mut self,
395 _: &SegmentRequest,
396 _: &mut dyn FnMut(u64),
397 ) -> Result<Vec<u8>, TransportError> {
398 unreachable!()
399 }
400 fn blob_upload_grant(
401 &mut self,
402 _: &str,
403 _: u64,
404 _: Option<&str>,
405 ) -> Result<BlobUploadGrant, TransportError> {
406 Ok(BlobUploadGrant::Url {
407 url: "https://storage.example/signed".into(),
408 url_expires_at_ms: None,
409 })
410 }
411 fn blob_put_url(
412 &mut self,
413 _: &str,
414 _: &[u8],
415 _: Option<&str>,
416 ) -> Result<(), TransportError> {
417 Err(TransportError::new(
418 "transport.timeout",
419 "signed upload timed out",
420 ))
421 }
422 fn blob_upload(
423 &mut self,
424 _: &str,
425 _: &[u8],
426 _: Option<&str>,
427 ) -> Result<(), TransportError> {
428 Err(TransportError::new(
429 "transport.timeout",
430 "direct upload timed out",
431 ))
432 }
433 fn realtime_connect(&mut self) -> Result<(), TransportError> {
434 Ok(())
435 }
436 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
437 Ok(())
438 }
439 fn realtime_close(&mut self) -> Result<(), TransportError> {
440 Ok(())
441 }
442 }
443
444 #[test]
445 fn a_spent_round_deadline_survives_the_signed_upload_fallback() {
446 let mut transport = SpentDeadline;
447 let error = upload_one(&mut transport, "sha256:test", &[0u8; 4], None, None).unwrap_err();
448 assert_eq!(error.code, "transport.timeout");
449 }
450}