Skip to main content

aven_core/sync/client/tail/
images.rs

1use super::*;
2use crate::sync::encrypted_tail::attachments::{
3    self as images, Operation as ImageOperation, Reply as ImageReply, Ticket,
4};
5use std::{
6    path::Path,
7    time::{Duration, Instant},
8};
9pub const IMAGES_PATH: &str = "/e2ee/images/v1";
10
11/// Bounds serial append work in one round while allowing a large offline backlog
12/// to clear well within the drain's round budget.
13const PUSH_LIMIT: usize = 2048;
14
15struct ImageRoundLimits {
16    objects: usize,
17    elapsed: Duration,
18    metadata_pull_skips: usize,
19}
20
21/// A round transfers one image at a time until either bound is reached, and
22/// always attempts at least one transfer so slow pushes, pulls or stale-context
23/// refreshes earlier in the round cannot starve image progress. A complete
24/// metadata observation may be reused only for this many intervening
25/// image-only rounds before another pull is required.
26const 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    /// The server has no further page and no local change waits to upload.
54    /// While publishing is blocked, withheld local changes do not count.
55    pub metadata_caught_up: bool,
56    pub images: ImageTransfer,
57    /// Metadata records appended by this device during the round.
58    pub sent_changes: usize,
59    /// Metadata records applied from the server during the round.
60    pub received_changes: usize,
61    /// New encrypted content waits for a withdrawal rotation; this round
62    /// uploaded nothing but still pulled and downloaded.
63    pub publishing_blocked: bool,
64}
65
66pub struct DrainSnapshot {
67    tail: TailSnapshot,
68    pull: PullFreshness,
69    batch_count: usize,
70    /// Why this drain's withdrawal rotation failed, if it did.
71    withdrawal: Option<anyhow::Error>,
72}
73
74#[derive(Default)]
75struct PullFreshness {
76    complete: bool,
77    skipped_rounds: usize,
78}
79impl DrainSnapshot {
80    /// The error reported for a drain that ended with publishing blocked.
81    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        // A failed withdrawal keeps publishing blocked without stopping pulls.
180        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    /// Pushes a bounded ordered prefix, applies at most one metadata page and
191    /// transfers a bounded number of images serially. The caller owns the local
192    /// blob root; committed metadata is independent of image transfer success.
193    #[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        // Reaching the cap completes only this round's push phase. The next
296        // bounded round resumes from the next ordered frozen group.
297        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) if is_stale(&error) => return Err(error),
349                    Err(_) => {
350                        downloaded = Some(ImageTransfer::Failed);
351                        break;
352                    }
353                }
354            }
355        }
356        let final_state = db.encrypted_round_state(a).await?;
357        let images =
358            downloaded.unwrap_or_else(|| settled(&final_state, progress.publishing_blocked));
359        Ok(Round {
360            metadata_caught_up: progress.pulled
361                && progress.page_complete == Some(true)
362                && (final_state.idle || progress.publishing_blocked),
363            // A failed push outranks later download outcomes in this round.
364            images: progress.image_state.unwrap_or(images),
365            sent_changes: progress.pushes,
366            received_changes: final_state.cursor.saturating_sub(cursor_before_pull) as usize,
367            publishing_blocked: progress.publishing_blocked,
368        })
369    }
370    pub async fn upload_prepared_image(
371        &self,
372        inputs: &TailSnapshot,
373        upload: images::Upload,
374    ) -> Result<Ticket> {
375        let (a, bearer) = (&inputs.authority, &inputs.bearer);
376        inputs.require_publishing_ready()?;
377        let ImageReply::Status(status) = self
378            .image_exchange(
379                &a.context,
380                bearer,
381                ImageOperation::Declare {
382                    workspace: upload.workspace.clone(),
383                    descriptor: upload.descriptor.clone(),
384                },
385            )
386            .await?
387        else {
388            anyhow::bail!("error encrypted-image-reply")
389        };
390        let mut indices = std::collections::HashSet::new();
391        ensure!(
392            status.missing.len() <= upload.records.len()
393                && (!status.complete || status.missing.is_empty())
394                && status.expires_at.is_some(),
395            "error encrypted-image-status"
396        );
397        ensure!(
398            status
399                .missing
400                .iter()
401                .all(|i| *i < upload.records.len() && indices.insert(*i)),
402            "error encrypted-image-status"
403        );
404        let ticket = Ticket {
405            reservation: status
406                .reservation
407                .ok_or_else(|| anyhow::anyhow!("error encrypted-image-ticket"))?,
408        };
409        for index in status.missing {
410            inputs.require_publishing_ready()?;
411            let record = upload
412                .records
413                .get(index)
414                .ok_or_else(|| anyhow::anyhow!("error encrypted-image-index"))?
415                .clone();
416            ensure!(
417                matches!(
418                    self.image_exchange(
419                        &a.context,
420                        bearer,
421                        ImageOperation::Put {
422                            workspace: upload.workspace.clone(),
423                            object: upload.object,
424                            descriptor_commitment: upload.commitment,
425                            reservation: ticket.reservation,
426                            index,
427                            record
428                        }
429                    )
430                    .await?,
431                    ImageReply::Done
432                ),
433                "error encrypted-image-reply"
434            );
435        }
436        crate::sync::crash::Crash::Tail.at("image-put");
437        inputs.require_publishing_ready()?;
438        ensure!(
439            matches!(
440                self.image_exchange(
441                    &a.context,
442                    bearer,
443                    ImageOperation::Complete {
444                        workspace: upload.workspace,
445                        object: upload.object,
446                        descriptor_commitment: upload.commitment,
447                        reservation: ticket.reservation
448                    }
449                )
450                .await?,
451                ImageReply::Done
452            ),
453            "error encrypted-image-reply"
454        );
455        Ok(ticket)
456    }
457    /// Repairs one known reference without changing its descriptor or metadata.
458    pub async fn repair_attachment(
459        &self,
460        store: &ProtectedLocalKeyStore,
461        db: &Database,
462        blob_dir: &Path,
463        workspace: &str,
464        reference: &str,
465    ) -> Result<()> {
466        let enrollment = self.enrollment()?;
467        enrollment.refresh(store, db).await?;
468        retry_stale!(
469            self.repair_attachment_once(store, db, blob_dir, workspace, reference)
470                .await,
471            enrollment.refresh(store, db).await,
472        )
473    }
474    async fn repair_attachment_once(
475        &self,
476        store: &ProtectedLocalKeyStore,
477        db: &Database,
478        blob_dir: &Path,
479        workspace: &str,
480        reference: &str,
481    ) -> Result<()> {
482        let inputs = validated_tail_inputs(store, db, &self.locator).await?;
483        let upload = db
484            .repair_encrypted_image(&inputs.authority, blob_dir, workspace, reference)
485            .await?;
486        let object = upload.object;
487        let commitment = upload.commitment;
488        let ticket = self.upload_prepared_image(&inputs, upload).await?;
489        ensure!(
490            matches!(
491                self.image_exchange(
492                    &inputs.authority.context,
493                    &inputs.bearer,
494                    ImageOperation::Release {
495                        workspace: workspace.into(),
496                        object,
497                        descriptor_commitment: commitment,
498                        reservation: ticket.reservation
499                    }
500                )
501                .await?,
502                ImageReply::Done
503            ),
504            "error encrypted-image-release"
505        );
506        Ok(())
507    }
508    /// False means the server no longer holds the selected object's bytes.
509    async fn download_image(
510        &self,
511        a: &tail::Authority,
512        bearer: &Secret,
513        db: &Database,
514        blob_dir: &Path,
515        selected: Option<&images::Download>,
516        lifecycle_policy: crate::attachments::LifecyclePolicy,
517    ) -> Result<bool> {
518        if let Some(download) = selected
519            && !db
520                .complete_encrypted_image_from_local(a, blob_dir, download, lifecycle_policy)
521                .await?
522        {
523            let mut records = Vec::new();
524            let mut total = 0;
525            for index in 0..download.chunk_count {
526                match self
527                    .image_exchange(
528                        &a.context,
529                        bearer,
530                        ImageOperation::Read {
531                            workspace: download.workspace.clone(),
532                            object: download.object,
533                            descriptor_commitment: download.descriptor_commitment,
534                            index,
535                        },
536                    )
537                    .await?
538                {
539                    ImageReply::Chunk(record) => {
540                        total += record.len();
541                        ensure!(
542                            total <= images::TRANSFER_BYTES,
543                            "error encrypted-image-limit"
544                        );
545                        records.push(record);
546                    }
547                    ImageReply::Unavailable => return Ok(false),
548                    _ => anyhow::bail!("error encrypted-image-reply"),
549                }
550            }
551            db.install_encrypted_image(a, blob_dir, download, &records, lifecycle_policy)
552                .await?;
553        }
554        Ok(true)
555    }
556}
557/// Image availability after this round's transfer step. Uploads withheld
558/// while publishing is blocked do not keep the drain going.
559fn settled(state: &tail::RoundState, publishing_blocked: bool) -> ImageTransfer {
560    match state.downloads {
561        Some(d) if d.pending => ImageTransfer::Pending,
562        Some(d) if d.unavailable => ImageTransfer::Unavailable,
563        Some(_) if !state.upload_pending || publishing_blocked => ImageTransfer::Complete,
564        _ => ImageTransfer::Pending,
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn exhausted_round_time_still_allows_the_first_image_transfer() {
574        let mut progress = RoundProgress {
575            image_started: Instant::now() - IMAGE_ROUND_LIMITS.elapsed,
576            ..RoundProgress::default()
577        };
578        assert!(progress.image_budget_available());
579        progress.transferred_image();
580        assert!(!progress.image_budget_available());
581    }
582}