Skip to main content

mdcast_client/
blobs.rs

1//! The blob half of the protocol: the "what are you missing?" probe, the
2//! multipart upload that answers it, and the pessimistic combination of the
3//! two.
4//!
5//! One `POST /v1/blobs` carries every blob it needs to, one part per blob, the
6//! part *name* being the digest the server will re-hash the bytes against — so
7//! a whole `409` is satisfied in a single request, never one round trip per
8//! asset.
9
10use bytes::Bytes;
11use mdcast_api::Digest;
12use mdcast_api::wire::{BlobCheckRequest, BlobCheckResponse, MissingBlob};
13use reqwest::Body;
14use reqwest::multipart::{Form, Part};
15
16use crate::bundle::AssetBundle;
17use crate::client::{BLOBS, BLOBS_CHECK, Client};
18use crate::error::{Error, Result};
19
20/// The parts one upload will carry: the claimed digest and the bytes behind it.
21type UploadSet = Vec<(Digest, Bytes)>;
22
23impl Client {
24    /// Which of the bundle's assets the server does not hold yet. An empty
25    /// result means a render with this bundle will not stall on a `409`.
26    /// Never fetches a digest-only entry's bytes — only its stored digest.
27    pub async fn check(&self, bundle: &AssetBundle) -> Result<Vec<Digest>> {
28        let digests: Vec<Digest> = bundle
29            .digests()
30            .into_iter()
31            .map(|(_, digest)| digest)
32            .collect();
33        if digests.is_empty() {
34            return Ok(Vec::new());
35        }
36
37        let response = self
38            .post_json(BLOBS_CHECK, &BlobCheckRequest { digests })
39            .await?;
40        let body: BlobCheckResponse = self.json_response(response).await?;
41        Ok(body.missing)
42    }
43
44    /// Upload the named digests from `bundle`. A digest nothing in the bundle
45    /// hashes to is [`Error::UnknownDigest`] — the request is never sent
46    /// half-formed.
47    pub async fn upload(&self, bundle: &AssetBundle, digests: &[Digest]) -> Result<()> {
48        self.upload_set(select_by_digest(digests, bundle).await?)
49            .await
50    }
51
52    /// The pessimistic strategy: ask what is missing, upload exactly that, and
53    /// let the render that follows go straight through. Returns the digests it
54    /// uploaded.
55    pub async fn preflight(&self, bundle: &AssetBundle) -> Result<Vec<Digest>> {
56        let missing = self.check(bundle).await?;
57        self.upload(bundle, &missing).await?;
58        Ok(missing)
59    }
60
61    /// Satisfy a `409`. The digests come from the manifest this client built,
62    /// so they need no re-hashing — only the bytes have to be found again
63    /// (fetched, for a digest-only entry).
64    pub(crate) async fn upload_missing(
65        &self,
66        missing: &[MissingBlob],
67        bundle: &AssetBundle,
68    ) -> Result<()> {
69        self.upload_set(select_missing(missing, bundle).await?)
70            .await
71    }
72
73    async fn upload_set(&self, parts: UploadSet) -> Result<()> {
74        if parts.is_empty() {
75            return Ok(());
76        }
77
78        let mut form = Form::new();
79        for (digest, bytes) in parts {
80            let length = bytes.len() as u64;
81            // `Bytes` clones are refcount bumps, so streaming the part with a
82            // known length hands the buffer over without copying it.
83            form = form.part(
84                digest.to_string(),
85                Part::stream_with_length(Body::from(bytes), length),
86            );
87        }
88
89        let response = self
90            .http
91            .post(self.url(BLOBS))
92            .bearer_auth(&self.token)
93            .multipart(form)
94            .send()
95            .await?;
96        self.expect_success(response).await
97    }
98}
99
100/// The upload set for a `409`: exactly the blobs the server named, in the
101/// order it named them — never the whole bundle. Resolving a digest-only
102/// entry's bytes here is exactly the point: the fetch only ever runs for a
103/// blob the server actually asked for.
104async fn select_missing(missing: &[MissingBlob], bundle: &AssetBundle) -> Result<UploadSet> {
105    let mut parts = Vec::with_capacity(missing.len());
106    for blob in missing {
107        let bytes = bundle.resolve(&blob.key, &blob.digest).await?;
108        parts.push((blob.digest.clone(), bytes));
109    }
110    Ok(parts)
111}
112
113/// The upload set for an explicit [`Client::upload`]: the bundle entries whose
114/// digest is one of `digests`, deduplicated — two keys can share content.
115/// Matching by digest is cheap (never fetches); resolving the chosen entry's
116/// bytes is the only part that can run a digest-only entry's `fetch`.
117async fn select_by_digest(digests: &[Digest], bundle: &AssetBundle) -> Result<UploadSet> {
118    let catalogue = bundle.digests();
119    let mut parts: UploadSet = Vec::with_capacity(digests.len());
120
121    for digest in digests {
122        if parts.iter().any(|(taken, _)| taken == digest) {
123            continue;
124        }
125        let key = catalogue
126            .iter()
127            .find(|(_, candidate)| candidate == digest)
128            .map(|(key, _)| *key)
129            .ok_or_else(|| Error::UnknownDigest {
130                digest: digest.clone(),
131            })?;
132        let bytes = bundle.resolve(key, digest).await?;
133        parts.push((digest.clone(), bytes));
134    }
135    Ok(parts)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::bundle::sha256;
142
143    fn bundle() -> AssetBundle {
144        AssetBundle::new()
145            .with("img/kept.png", &b"kept"[..])
146            .with("img/gone.png", &b"gone"[..])
147            .with("img/also-gone.png", &b"also gone"[..])
148    }
149
150    fn missing(keys: &[&str]) -> Vec<MissingBlob> {
151        keys.iter()
152            .map(|key| MissingBlob {
153                key: key.to_string(),
154                digest: sha256(&bundle().get(key).expect("a bundled key")[..]),
155            })
156            .collect()
157    }
158
159    #[tokio::test]
160    async fn a_409_uploads_only_the_blobs_it_named() {
161        let parts = select_missing(&missing(&["img/gone.png", "img/also-gone.png"]), &bundle())
162            .await
163            .expect("both are in the bundle");
164
165        assert_eq!(parts.len(), 2, "never the whole bundle");
166        assert_eq!(parts[0].0, sha256(b"gone"));
167        assert_eq!(&parts[0].1[..], b"gone");
168        assert_eq!(parts[1].0, sha256(b"also gone"));
169        assert!(
170            !parts.iter().any(|(d, _)| *d == sha256(b"kept")),
171            "a blob the server already holds must not be re-uploaded"
172        );
173    }
174
175    #[tokio::test]
176    async fn a_409_naming_a_key_the_bundle_lacks_fails_before_the_upload() {
177        let phantom = vec![MissingBlob {
178            key: "img/never-supplied.png".to_string(),
179            digest: sha256(b"whatever"),
180        }];
181
182        match select_missing(&phantom, &bundle()).await {
183            Err(Error::MissingAsset { key }) => assert_eq!(key, "img/never-supplied.png"),
184            other => panic!("expected MissingAsset, got {other:?}"),
185        }
186    }
187
188    #[tokio::test]
189    async fn an_empty_missing_list_selects_nothing() {
190        assert!(select_missing(&[], &bundle()).await.unwrap().is_empty());
191        assert!(select_by_digest(&[], &bundle()).await.unwrap().is_empty());
192    }
193
194    #[tokio::test]
195    async fn selecting_by_digest_finds_the_bytes_that_hash_to_it() {
196        let parts = select_by_digest(&[sha256(b"kept"), sha256(b"gone")], &bundle())
197            .await
198            .unwrap();
199
200        assert_eq!(parts.len(), 2);
201        assert_eq!(&parts[0].1[..], b"kept");
202        assert_eq!(&parts[1].1[..], b"gone");
203    }
204
205    #[tokio::test]
206    async fn selecting_by_digest_deduplicates_shared_content() {
207        let bundle = AssetBundle::new()
208            .with("a.png", &b"same"[..])
209            .with("b.png", &b"same"[..]);
210        let digest = sha256(b"same");
211
212        let parts = select_by_digest(&[digest.clone(), digest], &bundle)
213            .await
214            .unwrap();
215
216        assert_eq!(parts.len(), 1, "one blob is one part, however many keys");
217    }
218
219    #[tokio::test]
220    async fn selecting_an_unknown_digest_is_an_error() {
221        let stranger = sha256(b"nothing in the bundle");
222
223        match select_by_digest(std::slice::from_ref(&stranger), &bundle()).await {
224            Err(Error::UnknownDigest { digest }) => assert_eq!(digest, stranger),
225            other => panic!("expected UnknownDigest, got {other:?}"),
226        }
227    }
228
229    #[tokio::test]
230    async fn a_409_naming_a_digest_only_entry_fetches_its_bytes() {
231        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
232        let counted = std::sync::Arc::clone(&calls);
233        let bundle = AssetBundle::new().with_digest("img/lazy.png", sha256(b"lazy"), move || {
234            let counted = std::sync::Arc::clone(&counted);
235            async move {
236                counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
237                Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(b"lazy"))
238            }
239        });
240        let named = vec![MissingBlob {
241            key: "img/lazy.png".to_string(),
242            digest: sha256(b"lazy"),
243        }];
244
245        let parts = select_missing(&named, &bundle).await.unwrap();
246
247        assert_eq!(parts.len(), 1);
248        assert_eq!(&parts[0].1[..], b"lazy");
249        assert_eq!(
250            calls.load(std::sync::atomic::Ordering::SeqCst),
251            1,
252            "the fetch runs exactly when the server names this digest"
253        );
254    }
255
256    #[tokio::test]
257    async fn selecting_by_digest_never_fetches_an_entry_that_isnt_chosen() {
258        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
259        let counted = std::sync::Arc::clone(&calls);
260        let bundle = AssetBundle::new()
261            .with("img/kept.png", &b"kept"[..])
262            .with_digest("img/lazy.png", sha256(b"lazy"), move || {
263                let counted = std::sync::Arc::clone(&counted);
264                async move {
265                    counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
266                    Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(b"lazy"))
267                }
268            });
269
270        let parts = select_by_digest(&[sha256(b"kept")], &bundle).await.unwrap();
271
272        assert_eq!(parts.len(), 1);
273        assert_eq!(
274            calls.load(std::sync::atomic::Ordering::SeqCst),
275            0,
276            "a digest not asked for must never be fetched"
277        );
278    }
279}