1use super::*;
2use crate::sync::encrypted_tail::attachments::{
3 self as images, Operation as ImageOperation, Reply as ImageReply, Ticket,
4};
5pub use aven_protocol::wire::images::PATH as IMAGES_PATH;
6use std::{
7 path::Path,
8 time::{Duration, Instant},
9};
10
11const PUSH_LIMIT: usize = 2048;
14
15struct ImageRoundLimits {
16 objects: usize,
17 elapsed: Duration,
18 metadata_pull_skips: usize,
19}
20
21const IMAGE_ROUND_LIMITS: ImageRoundLimits = ImageRoundLimits {
27 objects: 16,
28 elapsed: Duration::from_secs(2),
29 metadata_pull_skips: 3,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum ImageTransfer {
35 Complete,
36 Pending,
37 Failed,
38 Unavailable,
39}
40
41impl std::fmt::Display for ImageTransfer {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.write_str(match self {
44 Self::Complete => "complete",
45 Self::Pending => "pending",
46 Self::Failed => "failed",
47 Self::Unavailable => "unavailable",
48 })
49 }
50}
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Round {
53 pub metadata_caught_up: bool,
56 pub images: ImageTransfer,
57 pub sent_changes: usize,
59 pub received_changes: usize,
61 pub publishing_blocked: bool,
64}
65
66pub struct DrainSnapshot {
67 tail: TailSnapshot,
68 pull: PullFreshness,
69 batch_count: usize,
70 withdrawal: Option<anyhow::Error>,
72}
73
74#[derive(Default)]
75struct PullFreshness {
76 complete: bool,
77 skipped_rounds: usize,
78}
79impl DrainSnapshot {
80 pub fn publishing_blocked_error(&mut self) -> anyhow::Error {
82 match self.withdrawal.take() {
83 Some(error) => error.context(PublishingBlocked),
84 None => PublishingBlocked.into(),
85 }
86 }
87}
88
89async fn validated_tail_inputs(
90 store: &ProtectedLocalKeyStore,
91 db: &Database,
92 locator: &str,
93) -> Result<TailSnapshot> {
94 let inputs = store.tail_inputs(db, locator).await?;
95 db.validate_encrypted_attachment_integrity(&inputs.authority)
96 .await?;
97 Ok(inputs)
98}
99
100struct RoundProgress {
101 pushes: usize,
102 batch_collisions: usize,
103 preflight: Option<tail::Preflight>,
104 push_complete: bool,
105 publishing_blocked: bool,
106 page_complete: Option<bool>,
107 pulled: bool,
108 image_state: Option<ImageTransfer>,
109 selected: bool,
110 download: Option<images::Download>,
111 image_started: Instant,
112 image_transfers: usize,
113}
114
115impl Default for RoundProgress {
116 fn default() -> Self {
117 Self {
118 pushes: 0,
119 batch_collisions: 0,
120 preflight: None,
121 push_complete: false,
122 publishing_blocked: false,
123 page_complete: None,
124 pulled: false,
125 image_state: None,
126 selected: false,
127 download: None,
128 image_started: Instant::now(),
129 image_transfers: 0,
130 }
131 }
132}
133
134impl RoundProgress {
135 fn image_budget_available(&self) -> bool {
136 self.image_transfers == 0
137 || (self.image_transfers < IMAGE_ROUND_LIMITS.objects
138 && self.image_started.elapsed() < IMAGE_ROUND_LIMITS.elapsed)
139 }
140
141 fn transferred_image(&mut self) {
142 self.image_transfers += 1;
143 }
144}
145impl Client {
146 pub async fn image_exchange(
147 &self,
148 context: &Context,
149 bearer: &Secret,
150 operation: ImageOperation,
151 ) -> Result<ImageReply> {
152 let request_limit = if matches!(operation, ImageOperation::Put { .. }) {
153 images::HTTP_LIMIT
154 } else {
155 tail::CONTROL_LIMIT
156 };
157 let response_limit = if matches!(operation, ImageOperation::Read { .. }) {
158 images::HTTP_LIMIT
159 } else {
160 tail::CONTROL_LIMIT
161 };
162 self.exchange_to(
163 IMAGES_PATH,
164 context,
165 bearer,
166 operation,
167 request_limit,
168 response_limit,
169 )
170 .await
171 }
172 pub async fn start_drain(
173 &self,
174 store: &ProtectedLocalKeyStore,
175 db: &Database,
176 ) -> Result<DrainSnapshot> {
177 let enrollment = self.enrollment()?;
178 enrollment.finish_pending_removal(store, db).await?;
179 let withdrawal = Box::pin(enrollment.withdraw_expired_disclosure(store, db))
181 .await
182 .err();
183 Ok(DrainSnapshot {
184 tail: validated_tail_inputs(store, db, &self.locator).await?,
185 pull: PullFreshness::default(),
186 withdrawal,
187 batch_count: 0,
188 })
189 }
190 #[cfg(any(test, feature = "test-support"))]
194 pub async fn round(
195 &self,
196 store: &ProtectedLocalKeyStore,
197 db: &Database,
198 blob_dir: &Path,
199 lifecycle_policy: crate::attachments::LifecyclePolicy,
200 ) -> Result<Round> {
201 let mut drain = Box::pin(self.start_drain(store, db)).await?;
202 Box::pin(self.round_in_drain(store, db, blob_dir, lifecycle_policy, &mut drain)).await
203 }
204 pub async fn round_in_drain(
205 &self,
206 store: &ProtectedLocalKeyStore,
207 db: &Database,
208 blob_dir: &Path,
209 lifecycle_policy: crate::attachments::LifecyclePolicy,
210 drain: &mut DrainSnapshot,
211 ) -> Result<Round> {
212 if !drain.tail.is_current(db).await? {
213 drain.tail = validated_tail_inputs(store, db, &self.locator).await?;
214 }
215 let mut progress = RoundProgress::default();
216 retry_stale!(
217 self.round_once(
218 &drain.tail,
219 &mut drain.pull,
220 db,
221 blob_dir,
222 lifecycle_policy,
223 &mut progress,
224 &mut drain.batch_count
225 )
226 .await,
227 async {
228 self.enrollment()?
229 .refresh_and_finish_pending_removal(store, db)
230 .await?;
231 drain.tail = validated_tail_inputs(store, db, &self.locator).await?;
232 drain.pull = PullFreshness::default();
233 progress.preflight = None;
234 progress.page_complete = None;
235 progress.pulled = false;
236 anyhow::Ok(())
237 }
238 .await,
239 )
240 }
241 #[allow(clippy::too_many_arguments)]
242 async fn round_once(
243 &self,
244 inputs: &TailSnapshot,
245 pull: &mut PullFreshness,
246 db: &Database,
247 blob_dir: &Path,
248 lifecycle_policy: crate::attachments::LifecyclePolicy,
249 progress: &mut RoundProgress,
250 batch_count: &mut usize,
251 ) -> Result<Round> {
252 let a = &inputs.authority;
253 while !progress.push_complete && progress.pushes < PUSH_LIMIT {
254 let (step, preflight) = match self
255 .push_in_run(
256 inputs,
257 db,
258 blob_dir,
259 progress.preflight.clone(),
260 batch_count,
261 PUSH_LIMIT - progress.pushes,
262 )
263 .await
264 {
265 Err(error)
266 if super::super::errors::has_code(&error, "encrypted-tail-batch-known")
267 && progress.batch_collisions < 3 =>
268 {
269 progress.batch_collisions += 1;
270 continue;
271 }
272 Err(error) if error.is::<PublishingBlocked>() => {
273 progress.publishing_blocked = true;
274 break;
275 }
276 result => result?,
277 };
278 progress.preflight = preflight;
279 match step {
280 PushStep::Appended => progress.pushes += 1,
281 PushStep::BatchAppended(count) => progress.pushes += count,
282 PushStep::Image(Some(state)) => {
283 progress.image_state = Some(state);
284 progress.push_complete = true;
285 }
286 PushStep::Image(None) => {
287 progress.transferred_image();
288 if !progress.image_budget_available() {
289 progress.push_complete = true;
290 }
291 }
292 PushStep::Empty => progress.push_complete = true,
293 }
294 }
295 progress.push_complete = true;
298 let state_before_pull = db.encrypted_round_state(a).await?;
299 let cursor_before_pull = state_before_pull.cursor;
300 let image_work_waiting = state_before_pull.upload_pending
301 || state_before_pull
302 .downloads
303 .is_some_and(|downloads| downloads.pending);
304 let may_skip_pull = pull.complete
305 && progress.pushes == 0
306 && image_work_waiting
307 && pull.skipped_rounds < IMAGE_ROUND_LIMITS.metadata_pull_skips;
308 if progress.page_complete.is_none() && !may_skip_pull {
309 progress.page_complete = Some(self.pull(a, &inputs.bearer, db).await?);
310 progress.pulled = true;
311 pull.complete = progress.page_complete == Some(true);
312 pull.skipped_rounds = 0;
313 } else if progress.page_complete.is_none() {
314 progress.page_complete = Some(true);
315 pull.skipped_rounds += 1;
316 }
317 let state = db.encrypted_round_state(a).await?;
318 let mut downloaded = None;
319 if progress.image_state.is_none() && state.downloads.is_some() {
320 while progress.image_budget_available() {
321 if !progress.selected {
322 progress.download = db.prepare_encrypted_image_download(a).await?;
323 progress.selected = true;
324 }
325 if progress.download.is_none() {
326 break;
327 }
328 match self
329 .download_image(
330 a,
331 &inputs.bearer,
332 db,
333 blob_dir,
334 progress.download.as_ref(),
335 lifecycle_policy,
336 )
337 .await
338 {
339 Ok(true) => {
340 progress.transferred_image();
341 progress.selected = false;
342 progress.download = None;
343 }
344 Ok(false) => {
345 downloaded = Some(ImageTransfer::Unavailable);
346 break;
347 }
348 Err(error)
349 if is_stale(&error) || super::super::errors::is_hosting_refusal(&error) =>
350 {
351 return Err(error);
352 }
353 Err(_) => {
354 downloaded = Some(ImageTransfer::Failed);
355 break;
356 }
357 }
358 }
359 }
360 let final_state = db.encrypted_round_state(a).await?;
361 let images =
362 downloaded.unwrap_or_else(|| settled(&final_state, progress.publishing_blocked));
363 Ok(Round {
364 metadata_caught_up: progress.pulled
365 && progress.page_complete == Some(true)
366 && (final_state.idle || progress.publishing_blocked),
367 images: progress.image_state.unwrap_or(images),
369 sent_changes: progress.pushes,
370 received_changes: final_state.cursor.saturating_sub(cursor_before_pull) as usize,
371 publishing_blocked: progress.publishing_blocked,
372 })
373 }
374 pub async fn upload_prepared_image(
375 &self,
376 inputs: &TailSnapshot,
377 upload: images::Upload,
378 ) -> Result<Ticket> {
379 let (a, bearer) = (&inputs.authority, &inputs.bearer);
380 inputs.require_publishing_ready()?;
381 let ImageReply::Status(status) = self
382 .image_exchange(
383 &a.context,
384 bearer,
385 ImageOperation::Declare {
386 workspace: upload.workspace.clone(),
387 descriptor: upload.descriptor.clone(),
388 },
389 )
390 .await?
391 else {
392 anyhow::bail!("error encrypted-image-reply")
393 };
394 let mut indices = std::collections::HashSet::new();
395 ensure!(
396 status.missing.len() <= upload.records.len()
397 && (!status.complete || status.missing.is_empty())
398 && status.expires_at.is_some(),
399 "error encrypted-image-status"
400 );
401 ensure!(
402 status
403 .missing
404 .iter()
405 .all(|i| *i < upload.records.len() && indices.insert(*i)),
406 "error encrypted-image-status"
407 );
408 let ticket = Ticket {
409 reservation: status
410 .reservation
411 .ok_or_else(|| anyhow::anyhow!("error encrypted-image-ticket"))?,
412 };
413 for index in status.missing {
414 inputs.require_publishing_ready()?;
415 let record = upload
416 .records
417 .get(index)
418 .ok_or_else(|| anyhow::anyhow!("error encrypted-image-index"))?
419 .clone();
420 ensure!(
421 matches!(
422 self.image_exchange(
423 &a.context,
424 bearer,
425 ImageOperation::Put {
426 workspace: upload.workspace.clone(),
427 object: upload.object,
428 descriptor_commitment: upload.commitment,
429 reservation: ticket.reservation,
430 index,
431 record
432 }
433 )
434 .await?,
435 ImageReply::Done
436 ),
437 "error encrypted-image-reply"
438 );
439 }
440 crate::sync::crash::Crash::Tail.at("image-put");
441 inputs.require_publishing_ready()?;
442 ensure!(
443 matches!(
444 self.image_exchange(
445 &a.context,
446 bearer,
447 ImageOperation::Complete {
448 workspace: upload.workspace,
449 object: upload.object,
450 descriptor_commitment: upload.commitment,
451 reservation: ticket.reservation
452 }
453 )
454 .await?,
455 ImageReply::Done
456 ),
457 "error encrypted-image-reply"
458 );
459 Ok(ticket)
460 }
461 pub async fn repair_attachment(
463 &self,
464 store: &ProtectedLocalKeyStore,
465 db: &Database,
466 blob_dir: &Path,
467 workspace: &str,
468 reference: &str,
469 ) -> Result<()> {
470 let enrollment = self.enrollment()?;
471 enrollment.refresh(store, db).await?;
472 retry_stale!(
473 self.repair_attachment_once(store, db, blob_dir, workspace, reference)
474 .await,
475 enrollment.refresh(store, db).await,
476 )
477 }
478 async fn repair_attachment_once(
479 &self,
480 store: &ProtectedLocalKeyStore,
481 db: &Database,
482 blob_dir: &Path,
483 workspace: &str,
484 reference: &str,
485 ) -> Result<()> {
486 let inputs = validated_tail_inputs(store, db, &self.locator).await?;
487 let upload = db
488 .repair_encrypted_image(&inputs.authority, blob_dir, workspace, reference)
489 .await?;
490 let object = upload.object;
491 let commitment = upload.commitment;
492 let ticket = self.upload_prepared_image(&inputs, upload).await?;
493 ensure!(
494 matches!(
495 self.image_exchange(
496 &inputs.authority.context,
497 &inputs.bearer,
498 ImageOperation::Release {
499 workspace: workspace.into(),
500 object,
501 descriptor_commitment: commitment,
502 reservation: ticket.reservation
503 }
504 )
505 .await?,
506 ImageReply::Done
507 ),
508 "error encrypted-image-release"
509 );
510 Ok(())
511 }
512 async fn download_image(
514 &self,
515 a: &tail::Authority,
516 bearer: &Secret,
517 db: &Database,
518 blob_dir: &Path,
519 selected: Option<&images::Download>,
520 lifecycle_policy: crate::attachments::LifecyclePolicy,
521 ) -> Result<bool> {
522 if let Some(download) = selected
523 && !db
524 .complete_encrypted_image_from_local(a, blob_dir, download, lifecycle_policy)
525 .await?
526 {
527 let mut records = Vec::new();
528 let mut total = 0;
529 for index in 0..download.chunk_count {
530 match self
531 .image_exchange(
532 &a.context,
533 bearer,
534 ImageOperation::Read {
535 workspace: download.workspace.clone(),
536 object: download.object,
537 descriptor_commitment: download.descriptor_commitment,
538 index,
539 },
540 )
541 .await?
542 {
543 ImageReply::Chunk(record) => {
544 total += record.len();
545 ensure!(
546 total <= images::TRANSFER_BYTES,
547 "error encrypted-image-limit"
548 );
549 records.push(record);
550 }
551 ImageReply::Unavailable => return Ok(false),
552 _ => anyhow::bail!("error encrypted-image-reply"),
553 }
554 }
555 db.install_encrypted_image(a, blob_dir, download, &records, lifecycle_policy)
556 .await?;
557 }
558 Ok(true)
559 }
560}
561fn settled(state: &tail::RoundState, publishing_blocked: bool) -> ImageTransfer {
564 match state.downloads {
565 Some(d) if d.pending => ImageTransfer::Pending,
566 Some(d) if d.unavailable => ImageTransfer::Unavailable,
567 Some(_) if !state.upload_pending || publishing_blocked => ImageTransfer::Complete,
568 _ => ImageTransfer::Pending,
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn exhausted_round_time_still_allows_the_first_image_transfer() {
578 let mut progress = RoundProgress {
579 image_started: Instant::now() - IMAGE_ROUND_LIMITS.elapsed,
580 ..RoundProgress::default()
581 };
582 assert!(progress.image_budget_available());
583 progress.transferred_image();
584 assert!(!progress.image_budget_available());
585 }
586}