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};
5pub use aven_protocol::wire::images::PATH as IMAGES_PATH;
6use std::{
7    path::Path,
8    time::{Duration, Instant},
9};
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)
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            // A failed push outranks later download outcomes in this round.
368            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    /// Repairs one known reference without changing its descriptor or metadata.
462    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    /// False means the server no longer holds the selected object's bytes.
513    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}
561/// Image availability after this round's transfer step. Uploads withheld
562/// while publishing is blocked do not keep the drain going.
563fn 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}