/build/source/nativelink-store/src/azure_blob_store.rs
Line | Count | Source |
1 | | // Copyright 2024 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::pin::Pin; |
16 | | use core::time::Duration; |
17 | | use core::{cmp, mem}; |
18 | | use std::borrow::Cow; |
19 | | use std::sync::Arc; |
20 | | use std::time::Instant; |
21 | | |
22 | | use async_trait::async_trait; |
23 | | use azure_core::credentials::TokenCredential; |
24 | | use azure_core::error::ErrorKind; |
25 | | use azure_core::http::headers::{RETRY_AFTER, RETRY_AFTER_MS, X_MS_RETRY_AFTER_MS}; |
26 | | use azure_core::http::{RequestContent, RetryOptions, StatusCode, Transport, Url}; |
27 | | use azure_identity::WorkloadIdentityCredential; |
28 | | use azure_storage_blob::clients::{BlobContainerClient, BlobContainerClientOptions}; |
29 | | use azure_storage_blob::models::{ |
30 | | BlobClientDownloadOptions, BlobClientGetPropertiesResultHeaders, BlockListType, |
31 | | BlockLookupList, HttpRange, StorageErrorCode, |
32 | | }; |
33 | | use futures::future::FusedFuture; |
34 | | use futures::stream::{FuturesUnordered, unfold}; |
35 | | use futures::{FutureExt, StreamExt, TryStreamExt}; |
36 | | use nativelink_config::stores::ExperimentalAzureSpec; |
37 | | use nativelink_error::{Code, Error, ResultExt, make_err}; |
38 | | use nativelink_metric::MetricsComponent; |
39 | | use nativelink_util::adaptive_permits::{AdaptivePermit, AdaptivePermits}; |
40 | | use nativelink_util::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf}; |
41 | | use nativelink_util::health_utils::{HealthRegistryBuilder, HealthStatus, HealthStatusIndicator}; |
42 | | use nativelink_util::instant_wrapper::InstantWrapper; |
43 | | use nativelink_util::metrics::{record_object_store_request_wait, record_object_store_throttle}; |
44 | | use nativelink_util::retry::{Retrier, RetryResult}; |
45 | | use nativelink_util::store_trait::{ |
46 | | RemoveCallback, StoreDriver, StoreKey, StoreOptimizations, UploadSizeInfo, |
47 | | }; |
48 | | use nativelink_util::{background_spawn, fs}; |
49 | | use parking_lot::Mutex; |
50 | | use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc}; |
51 | | use tokio::time::sleep; |
52 | | use tracing::{Level, event}; |
53 | | |
54 | | use crate::cas_utils::is_zero_digest; |
55 | | use crate::common_s3_utils::install_default_rustls_crypto_provider; |
56 | | |
57 | | // Check the below doc for the limits specific to Azure. |
58 | | // https://learn.microsoft.com/en-us/azure/storage/blobs/scalability-targets#scale-targets-for-blob-storage |
59 | | |
60 | | // Maximum number of blocks in a block blob or append blob |
61 | | const MAX_BLOCKS: usize = 50_000; |
62 | | |
63 | | // Maximum size of a block in a block blob (4,000 MiB) |
64 | | const MAX_BLOCK_SIZE: u64 = 4_000 * 1024 * 1024; // 4,000 MiB = 4 GiB |
65 | | |
66 | | // Default block size for uploads (5 MiB) |
67 | | const DEFAULT_BLOCK_SIZE: u64 = 5 * 1024 * 1024; // 5 MiB |
68 | | |
69 | | // Default maximum retry buffer per request |
70 | | const DEFAULT_MAX_RETRY_BUFFER_PER_REQUEST: usize = 5 * 1024 * 1024; // 5 MiB |
71 | | |
72 | | // Default maximum number of concurrent uploads |
73 | | const DEFAULT_MAX_CONCURRENT_UPLOADS: usize = 10; |
74 | | |
75 | | // Default public Azure Blob Storage endpoint suffix. |
76 | | const DEFAULT_BLOB_ENDPOINT_SUFFIX: &str = "blob.core.windows.net"; |
77 | | |
78 | | /// Successful reads in a row before the adaptive read limit grows by one. |
79 | | const READ_LIMIT_GROW_AFTER: usize = 16; |
80 | | |
81 | | /// Shortest time between two warnings from one store that the host ran out |
82 | | /// of local ports. |
83 | | const PORT_HINT_INTERVAL: Duration = Duration::from_mins(1); |
84 | | |
85 | | #[derive(MetricsComponent)] |
86 | | pub struct AzureBlobStore<NowFn> { |
87 | | client: Arc<BlobContainerClient>, |
88 | | now_fn: NowFn, |
89 | | #[metric(help = "The container name for the Azure store")] |
90 | | container: String, |
91 | | #[metric(help = "The blob prefix for the Azure store")] |
92 | | blob_prefix: String, |
93 | | retrier: Retrier, |
94 | | #[metric(help = "The number of seconds to consider an object expired")] |
95 | | consider_expired_after_s: i64, |
96 | | #[metric(help = "The number of bytes to buffer for retrying requests")] |
97 | | max_retry_buffer_per_request: usize, |
98 | | #[metric(help = "The number of concurrent uploads allowed")] |
99 | | max_concurrent_uploads: usize, |
100 | | throttle_retry_delay: Duration, |
101 | | throttle_retry_max_delay: Duration, |
102 | | /// Bounds `GET` and `HEAD` requests in flight; `None` leaves them |
103 | | /// unbounded. See `CommonObjectSpec::max_concurrent_reads`. |
104 | | read_permits: Option<Arc<AdaptivePermits>>, |
105 | | /// Bounds every request in flight, of any kind; `None` leaves them |
106 | | /// unbounded. See `CommonObjectSpec::max_concurrent_requests`. |
107 | | request_permits: Option<Arc<Semaphore>>, |
108 | | #[metric(help = "The most requests this store keeps in flight at once, 0 for unbounded")] |
109 | | max_concurrent_requests: usize, |
110 | | /// When this store last warned that the host ran out of local ports. |
111 | | last_port_hint: Mutex<Option<Instant>>, |
112 | | } |
113 | | |
114 | | impl<NowFn> core::fmt::Debug for AzureBlobStore<NowFn> { |
115 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
116 | 0 | f.debug_struct("AzureBlobStore") |
117 | 0 | .field("container", &self.container) |
118 | 0 | .field("blob_prefix", &self.blob_prefix) |
119 | 0 | .field("consider_expired_after_s", &self.consider_expired_after_s) |
120 | 0 | .finish_non_exhaustive() |
121 | 0 | } |
122 | | } |
123 | | |
124 | | /// A request that cannot connect, or reads nothing for this long (response |
125 | | /// headers included), fails and is retried. Without a bound, a request that |
126 | | /// hangs keeps its permit forever, and `max_concurrent_requests` of them |
127 | | /// wedge the store. The read timer runs only while the body is polled, so a |
128 | | /// download that a slow reader holds back is not cut off. The values are the |
129 | | /// Azure SDK's own defaults, which this store's client replaces. |
130 | | const AZURE_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); |
131 | | const AZURE_READ_TIMEOUT: Duration = Duration::from_mins(1); |
132 | | |
133 | | /// How long a cancelled upload's cleanup waits before it looks at the block |
134 | | /// list, so a block whose request was already in flight has landed and is |
135 | | /// seen rather than raced. |
136 | | const STAGED_BLOCK_DISCARD_DELAY: Duration = Duration::from_secs(5); |
137 | | |
138 | | /// Discards the blocks an upload staged if the upload never commits, from a |
139 | | /// background task in `Drop`: after an error, so the caller gets it without |
140 | | /// waiting for request permits, and when the upload future is dropped (the |
141 | | /// `ByteStream` it fed died and the server swept it), since a dropped future |
142 | | /// runs no code of its own. Only a committed upload disarms it. |
143 | | struct StagedBlocksGuard { |
144 | | client: Arc<BlobContainerClient>, |
145 | | requests: Option<Arc<Semaphore>>, |
146 | | blob_path: String, |
147 | | armed: bool, |
148 | | } |
149 | | |
150 | | impl StagedBlocksGuard { |
151 | 3 | const fn disarm(&mut self) { |
152 | 3 | self.armed = false; |
153 | 3 | } |
154 | | } |
155 | | |
156 | | impl Drop for StagedBlocksGuard { |
157 | 12 | fn drop(&mut self) { |
158 | 12 | if !self.armed { |
159 | 3 | return; |
160 | 9 | } |
161 | 9 | let client = Arc::clone(&self.client); |
162 | 9 | let requests = self.requests.take(); |
163 | 9 | let blob_path = mem::take(&mut self.blob_path); |
164 | 9 | background_spawn!("azure_discard_staged_blocks", async move {8 |
165 | 8 | sleep(STAGED_BLOCK_DISCARD_DELAY).await; |
166 | 7 | discard_staged_blocks(&client, requests.as_ref(), &blob_path).await; |
167 | 7 | }); |
168 | 12 | } |
169 | | } |
170 | | |
171 | | /// Best effort: drop the blocks an upload left staged under this name. |
172 | | /// Azure keeps uncommitted blocks, and bills them, for a week before |
173 | | /// collecting them, which adds up under an upload failure storm. A |
174 | | /// committed blob under the same name (a re-upload of a key that already |
175 | | /// exists) must survive: re-committing its own block list is what discards |
176 | | /// the staged blocks next to it. With nothing committed the name holds only |
177 | | /// staged blocks and deleting it removes them. Failure here is logged, never |
178 | | /// returned: the upload has already failed, and the blocks are collected |
179 | | /// eventually either way. Each request takes a permit from `requests` |
180 | | /// like any other the store sends. |
181 | 7 | async fn discard_staged_blocks( |
182 | 7 | client: &BlobContainerClient, |
183 | 7 | requests: Option<&Arc<Semaphore>>, |
184 | 7 | blob_path: &str, |
185 | 7 | ) { |
186 | 7 | let blob_client = client.blob_client(blob_path); |
187 | 7 | let block_list_result = { |
188 | 7 | let _request_permit = request_permit(requests).await; |
189 | 7 | blob_client |
190 | 7 | .block_blob_client() |
191 | 7 | .get_block_list(BlockListType::All, None) |
192 | 7 | .await |
193 | | }; |
194 | 7 | let block_list = match block_list_result { |
195 | 7 | Ok(response) => match response.into_model() { |
196 | 7 | Ok(block_list) => block_list, |
197 | 0 | Err(err) => { |
198 | 0 | event!( |
199 | 0 | Level::WARN, |
200 | | ?err, |
201 | | blob_path, |
202 | | "Could not read the block list after a failed Azure upload" |
203 | | ); |
204 | 0 | return; |
205 | | } |
206 | | }, |
207 | 0 | Err(err) => { |
208 | 0 | if err.http_status() != Some(StatusCode::NotFound) { |
209 | 0 | event!( |
210 | 0 | Level::WARN, |
211 | | ?err, |
212 | | blob_path, |
213 | | "Could not read the block list after a failed Azure upload" |
214 | | ); |
215 | 0 | } |
216 | 0 | return; |
217 | | } |
218 | | }; |
219 | 7 | let uncommitted = block_list.uncommitted_blocks.unwrap_or_default(); |
220 | 7 | if uncommitted.is_empty() { |
221 | 0 | return; |
222 | 7 | } |
223 | 7 | let committed: Vec<Vec<u8>> = block_list |
224 | 7 | .committed_blocks |
225 | 7 | .unwrap_or_default() |
226 | 7 | .into_iter() |
227 | 7 | .filter_map(|block| block.name) |
228 | 7 | .collect(); |
229 | 7 | let _request_permit = request_permit(requests).await; |
230 | 7 | let result = if committed.is_empty() { |
231 | 4 | blob_client.delete(None).await.map(|_| ()) |
232 | | } else { |
233 | 3 | let keep = BlockLookupList { |
234 | 3 | committed: Some(committed), |
235 | 3 | ..Default::default() |
236 | 3 | }; |
237 | 3 | match RequestContent::try_from(keep) { |
238 | 3 | Ok(blocks) => blob_client |
239 | 3 | .block_blob_client() |
240 | 3 | .commit_block_list(blocks, None) |
241 | 3 | .await |
242 | 3 | .map(|_| ()), |
243 | 0 | Err(err) => Err(err), |
244 | | } |
245 | | }; |
246 | 7 | match result { |
247 | 7 | Ok(()) => event!( |
248 | 7 | Level::INFO, |
249 | | blob_path, |
250 | 7 | staged_blocks = uncommitted.len(), |
251 | | "Discarded the blocks a failed Azure upload left staged" |
252 | | ), |
253 | 0 | Err(err) => event!( |
254 | 0 | Level::WARN, |
255 | | ?err, |
256 | | blob_path, |
257 | 0 | staged_blocks = uncommitted.len(), |
258 | | "Could not discard the blocks a failed Azure upload left staged" |
259 | | ), |
260 | | } |
261 | 7 | } |
262 | | |
263 | | /// A permit from the store's request pool when requests are bounded, |
264 | | /// counted on `store.object.request_waits` when none was free. Every path |
265 | | /// takes it after the read permit and before the process-wide open-file |
266 | | /// permit, so no two requests wait on each other's pools in opposite order. |
267 | 291 | async fn request_permit(requests: Option<&Arc<Semaphore>>) -> Option<OwnedSemaphorePermit> { |
268 | 291 | let requests97 = requests?194 ; |
269 | 97 | if requests.available_permits() == 0 { |
270 | 77 | record_object_store_request_wait("azure"); |
271 | 77 | }20 |
272 | | Some( |
273 | 97 | Arc::clone(requests) |
274 | 97 | .acquire_owned() |
275 | 97 | .await |
276 | 97 | .expect("The request permit pool is never closed"), |
277 | | ) |
278 | 291 | } |
279 | | |
280 | | /// Whether `err` failed because this host had no local port left to |
281 | | /// connect from: `connect()` answers `EADDRNOTAVAIL` when every source port |
282 | | /// toward the destination is held, by a request in flight, an idle |
283 | | /// connection kept for reuse, or a closed one still in `TIME_WAIT`. |
284 | 32 | pub fn is_out_of_local_ports(err: &(dyn core::error::Error + 'static)) -> bool { |
285 | 32 | let mut next = Some(err); |
286 | 68 | while let Some(err43 ) = next { |
287 | 43 | if err |
288 | 43 | .downcast_ref::<std::io::Error>() |
289 | 43 | .is_some_and(|err| err.kind()8 == std::io::ErrorKind::AddrNotAvailable8 ) |
290 | | { |
291 | 7 | return true; |
292 | 36 | } |
293 | 36 | next = err.source(); |
294 | | } |
295 | 25 | false |
296 | 32 | } |
297 | | |
298 | | impl<I, NowFn> AzureBlobStore<NowFn> |
299 | | where |
300 | | I: InstantWrapper, |
301 | | NowFn: Fn() -> I + Send + Sync + Unpin + 'static, |
302 | | { |
303 | 0 | pub fn new(spec: &ExperimentalAzureSpec, now_fn: NowFn) -> Result<Arc<Self>, Error> { |
304 | 0 | let jitter_fn = spec.common.retry.make_jitter_fn(); |
305 | 0 | let client = Self::build_container_client(spec)?; |
306 | 0 | Self::new_with_client_and_jitter(spec, client, jitter_fn, now_fn) |
307 | 0 | } |
308 | | |
309 | | /// Builds the container URL and selects the auth strategy: |
310 | | /// * `sas_url` set -> use it verbatim as the container URL with no credential. |
311 | | /// * otherwise -> `https://{account}.{endpoint}/{container}` authenticated with |
312 | | /// Entra ID via Workload Identity (keyless). |
313 | 0 | fn build_container_client(spec: &ExperimentalAzureSpec) -> Result<BlobContainerClient, Error> { |
314 | 0 | let mut options = BlobContainerClientOptions::default(); |
315 | 0 | options.client_options.retry = RetryOptions::none(); |
316 | | // Hand the SDK an HTTP client with an explicit rustls (ring) config. |
317 | 0 | options.client_options.transport = Some(Self::build_http_transport()?); |
318 | | |
319 | 0 | let (container_url, credential): (Url, Option<Arc<dyn TokenCredential>>) = |
320 | 0 | if let Some(sas_url) = spec.sas_url.as_ref() { |
321 | 0 | let url = Url::parse(sas_url) |
322 | 0 | .map_err(|e| make_err!(Code::InvalidArgument, "Invalid Azure sas_url: {e}"))?; |
323 | 0 | (url, None) |
324 | | } else { |
325 | 0 | let endpoint = spec.endpoint.clone().unwrap_or_else(|| { |
326 | 0 | format!( |
327 | | "https://{}.{DEFAULT_BLOB_ENDPOINT_SUFFIX}", |
328 | | spec.account_name |
329 | | ) |
330 | 0 | }); |
331 | 0 | let mut url = Url::parse(&endpoint) |
332 | 0 | .map_err(|e| make_err!(Code::InvalidArgument, "Invalid Azure endpoint: {e}"))?; |
333 | 0 | url.path_segments_mut() |
334 | 0 | .map_err(|()| { |
335 | 0 | make_err!( |
336 | 0 | Code::InvalidArgument, |
337 | | "Azure endpoint is not a valid base URL: {endpoint}" |
338 | | ) |
339 | 0 | })? |
340 | 0 | .pop_if_empty() |
341 | 0 | .push(&spec.container); |
342 | 0 | let credential: Arc<dyn TokenCredential> = WorkloadIdentityCredential::new(None) |
343 | 0 | .map_err(|e| { |
344 | 0 | make_err!( |
345 | 0 | Code::FailedPrecondition, |
346 | | "Failed to create Azure Workload Identity credential: {e}" |
347 | | ) |
348 | 0 | })?; |
349 | 0 | (url, Some(credential)) |
350 | | }; |
351 | | |
352 | 0 | BlobContainerClient::new(container_url, credential, Some(options)) |
353 | 0 | .map_err(|e| make_err!(Code::Unavailable, "Failed to create Azure client: {e}")) |
354 | 0 | } |
355 | | |
356 | | /// Builds an HTTP transport for the Azure SDK backed by a reqwest client with |
357 | | /// an explicit rustls config using `NativeLink`'s ring crypto provider, so the |
358 | | /// SDK never falls back to guessing a provider (which breaks HTTPS here). |
359 | 0 | fn build_http_transport() -> Result<Transport, Error> { |
360 | 0 | install_default_rustls_crypto_provider(); |
361 | | |
362 | 0 | let mut roots = rustls::RootCertStore::empty(); |
363 | 0 | roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); |
364 | 0 | let tls_config = rustls::ClientConfig::builder() |
365 | 0 | .with_root_certificates(roots) |
366 | 0 | .with_no_client_auth(); |
367 | | |
368 | 0 | let client = reqwest::Client::builder() |
369 | 0 | .use_preconfigured_tls(tls_config) |
370 | 0 | .connect_timeout(AZURE_CONNECT_TIMEOUT) |
371 | 0 | .read_timeout(AZURE_READ_TIMEOUT) |
372 | 0 | .build() |
373 | 0 | .map_err(|e| make_err!(Code::Unavailable, "Failed to build Azure HTTP client: {e}"))?; |
374 | | |
375 | 0 | Ok(Transport::new(Arc::new(client))) |
376 | 0 | } |
377 | | |
378 | 63 | pub fn new_with_client_and_jitter( |
379 | 63 | spec: &ExperimentalAzureSpec, |
380 | 63 | client: BlobContainerClient, |
381 | 63 | jitter_fn: Arc<dyn Fn(Duration) -> Duration + Send + Sync>, |
382 | 63 | now_fn: NowFn, |
383 | 63 | ) -> Result<Arc<Self>, Error> { |
384 | 63 | let throttle_retry_delay = Duration::try_from_secs_f32(spec.common.throttle_retry_delay_s) |
385 | 63 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Invalid throttle_retry_delay_s: {e}"))?0 ; |
386 | 63 | let throttle_retry_max_delay = |
387 | 63 | Duration::try_from_secs_f32(spec.common.throttle_retry_max_delay_s).map_err(|e| {0 |
388 | 0 | make_err!( |
389 | 0 | Code::InvalidArgument, |
390 | | "Invalid throttle_retry_max_delay_s: {e}" |
391 | | ) |
392 | 0 | })?; |
393 | 63 | if throttle_retry_max_delay < throttle_retry_delay { |
394 | 0 | return Err(make_err!( |
395 | 0 | Code::InvalidArgument, |
396 | 0 | "throttle_retry_max_delay_s ({}) is less than throttle_retry_delay_s ({})", |
397 | 0 | spec.common.throttle_retry_max_delay_s, |
398 | 0 | spec.common.throttle_retry_delay_s |
399 | 0 | )); |
400 | 63 | } |
401 | 63 | let read_permits = (spec.common.max_concurrent_reads > 0).then(|| {11 |
402 | 11 | let max = spec.common.max_concurrent_reads; |
403 | 11 | let (min, grow_after) = if spec.common.adaptive_read_concurrency { |
404 | 2 | ( |
405 | 2 | spec.common.min_concurrent_reads.min(max), |
406 | 2 | READ_LIMIT_GROW_AFTER, |
407 | 2 | ) |
408 | | } else { |
409 | 9 | (max, 0) |
410 | | }; |
411 | 11 | AdaptivePermits::new("azure", max, min, max, grow_after) |
412 | 11 | }); |
413 | 63 | let max_concurrent_requests = spec.common.max_concurrent_requests; |
414 | 63 | let max_concurrent_reads = spec.common.max_concurrent_reads; |
415 | 63 | if max_concurrent_requests > 0 |
416 | 8 | && (max_concurrent_reads == 0 || max_concurrent_reads >= max_concurrent_requests7 ) |
417 | | { |
418 | 3 | return Err(make_err!( |
419 | 3 | Code::InvalidArgument, |
420 | 3 | "max_concurrent_requests ({max_concurrent_requests}) needs max_concurrent_reads between 1 and {}: a download holds its request permit while its body streams, so without that gap downloads can take every permit and stall existence checks and uploads (max_concurrent_reads is {max_concurrent_reads})", |
421 | 3 | max_concurrent_requests - 1 |
422 | 3 | )); |
423 | 60 | } |
424 | 60 | let request_permits = (max_concurrent_requests > 0) |
425 | 60 | .then(|| Arc::new5 (Semaphore::new5 (max_concurrent_requests5 ))); |
426 | 60 | let throttle_retry_budget = |
427 | 60 | Duration::try_from_secs_f32(spec.common.throttle_retry_budget_s).map_err(|e| {0 |
428 | 0 | make_err!( |
429 | 0 | Code::InvalidArgument, |
430 | | "Invalid throttle_retry_budget_s: {e}" |
431 | | ) |
432 | 0 | })?; |
433 | 60 | Ok(Arc::new(Self { |
434 | 60 | client: Arc::new(client), |
435 | 60 | now_fn, |
436 | 60 | container: spec.container.clone(), |
437 | 60 | blob_prefix: spec |
438 | 60 | .common |
439 | 60 | .key_prefix |
440 | 60 | .as_ref() |
441 | 60 | .unwrap_or(&String::new()) |
442 | 60 | .clone(), |
443 | 60 | retrier: Retrier::new( |
444 | 60 | Arc::new(|duration| Box::pin21 (sleep21 (duration21 ))), |
445 | 60 | jitter_fn, |
446 | 60 | spec.common.retry.clone(), |
447 | | ) |
448 | 60 | .with_client("azure") |
449 | 60 | .with_throttle_budget(throttle_retry_budget) |
450 | 60 | .with_throttle_max_wait(throttle_retry_max_delay), |
451 | 60 | consider_expired_after_s: i64::from(spec.common.consider_expired_after_s), |
452 | 60 | max_retry_buffer_per_request: spec |
453 | 60 | .common |
454 | 60 | .max_retry_buffer_per_request |
455 | 60 | .unwrap_or(DEFAULT_MAX_RETRY_BUFFER_PER_REQUEST), |
456 | 60 | max_concurrent_uploads: spec |
457 | 60 | .common |
458 | 60 | .multipart_max_concurrent_uploads |
459 | 60 | .unwrap_or(DEFAULT_MAX_CONCURRENT_UPLOADS), |
460 | 60 | throttle_retry_delay, |
461 | 60 | throttle_retry_max_delay, |
462 | 60 | read_permits, |
463 | 60 | request_permits, |
464 | 60 | max_concurrent_requests, |
465 | 60 | last_port_hint: Mutex::new(None), |
466 | | })) |
467 | 63 | } |
468 | | |
469 | | /// The read limit the permit pool stands at, or `None` with reads |
470 | | /// unbounded. |
471 | 6 | pub fn read_limit(&self) -> Option<usize> { |
472 | 6 | self.read_permits.as_ref().map(|pool| pool.limit()) |
473 | 6 | } |
474 | | |
475 | | /// A download permit when reads are bounded. Held for the request, |
476 | | /// body included, so what is in flight is what the limit says. Only |
477 | | /// `get_part` takes one: a `HEAD` moves no bytes and must not wait |
478 | | /// behind a body still being sent to a slow client, and a throttle |
479 | | /// on either side says nothing about the other. |
480 | 57 | async fn read_permit(&self) -> Option<AdaptivePermit> { |
481 | 57 | match &self.read_permits { |
482 | 28 | Some(pool) => Some(pool.acquire().await), |
483 | 29 | None => None, |
484 | | } |
485 | 57 | } |
486 | | |
487 | | /// A request permit when requests are bounded; see [`request_permit`]. |
488 | 277 | async fn request_permit(&self) -> Option<OwnedSemaphorePermit> { |
489 | 277 | request_permit(self.request_permits.as_ref()).await |
490 | 277 | } |
491 | | |
492 | | /// Names the likely cause, and the setting that bounds it, when a |
493 | | /// request failed because the host ran out of local ports. At most once |
494 | | /// per `PORT_HINT_INTERVAL`: under exhaustion most requests fail alike. |
495 | 29 | fn hint_if_out_of_local_ports(&self, err: &azure_core::Error) { |
496 | 29 | if !is_out_of_local_ports(err) { |
497 | 23 | return; |
498 | 6 | } |
499 | | { |
500 | 6 | let now = Instant::now(); |
501 | 6 | let mut last = self.last_port_hint.lock(); |
502 | 6 | if last.is_some_and(|last| now4 .duration_since(last) < PORT_HINT_INTERVAL4 ) { |
503 | 4 | return; |
504 | 2 | } |
505 | 2 | *last = Some(now); |
506 | | } |
507 | 2 | if self.max_concurrent_requests == 0 { |
508 | 1 | event!( |
509 | 1 | Level::WARN, |
510 | | container = %self.container, |
511 | | "Out of local ports toward the Azure endpoint (AddrNotAvailable), and this store's requests are unbounded. Set max_concurrent_requests on it to cap its connections; idle connections and ones in TIME_WAIT hold ports too" |
512 | | ); |
513 | | } else { |
514 | 1 | event!( |
515 | 1 | Level::WARN, |
516 | | container = %self.container, |
517 | | max_concurrent_requests = self.max_concurrent_requests, |
518 | | "Out of local ports toward the Azure endpoint (AddrNotAvailable). Lower max_concurrent_requests, and count every store in a shard, each of which holds up to that many; idle connections and ones in TIME_WAIT hold ports too" |
519 | | ); |
520 | | } |
521 | 29 | } |
522 | | |
523 | | /// A read was answered (found or not): one toward growing the limit. |
524 | 39 | fn note_read_ok(&self) { |
525 | 39 | if let Some(pool25 ) = &self.read_permits { |
526 | 25 | pool.record_success(); |
527 | 25 | }14 |
528 | 39 | } |
529 | | |
530 | | /// A read was throttled: the limit is cut. |
531 | 16 | fn note_read_throttled(&self) { |
532 | 16 | if let Some(pool3 ) = &self.read_permits { |
533 | 3 | pool.record_throttle(); |
534 | 13 | } |
535 | 16 | } |
536 | | |
537 | 259 | fn make_blob_path(&self, key: &StoreKey<'_>) -> String { |
538 | 259 | format!("{}{}", self.blob_prefix, key.as_str()) |
539 | 259 | } |
540 | | |
541 | | /// How long a throttled response (503 `ServerBusy` or 429) asked us to |
542 | | /// wait, or `None` when the error is anything else. The wait is the |
543 | | /// `Retry-After` header when present, never less than the configured |
544 | | /// floor and never more than the configured cap. Every failed request |
545 | | /// passes through here, so this is also where running out of local |
546 | | /// ports is named. |
547 | 29 | fn throttle_wait(&self, err: &azure_core::Error, op: &'static str) -> Option<Duration> { |
548 | 29 | self.hint_if_out_of_local_ports(err); |
549 | | let ErrorKind::HttpResponse { |
550 | 23 | status, |
551 | 23 | error_code, |
552 | 23 | raw_response, |
553 | 29 | } = err.kind() |
554 | | else { |
555 | 6 | return None; |
556 | | }; |
557 | 23 | let throttled = matches!6 ( |
558 | 23 | status, |
559 | | StatusCode::ServiceUnavailable | StatusCode::TooManyRequests |
560 | 6 | ) || error_code.as_deref() == Some(StorageErrorCode::ServerBusy.as_ref()); |
561 | 23 | if !throttled { |
562 | 6 | return None; |
563 | 17 | } |
564 | 17 | record_object_store_throttle("azure", op); |
565 | 17 | let header_wait = raw_response.as_ref().and_then(|response| { |
566 | 17 | let headers = response.headers(); |
567 | 17 | let millis = headers |
568 | 17 | .get_optional_str(&RETRY_AFTER_MS) |
569 | 17 | .or_else(|| headers.get_optional_str(&X_MS_RETRY_AFTER_MS)) |
570 | 17 | .and_then(|value| value.trim()0 .parse0 ::<u64>().ok0 ()) |
571 | 17 | .map(Duration::from_millis); |
572 | 17 | millis.or_else(|| { |
573 | 17 | headers |
574 | 17 | .get_optional_str(&RETRY_AFTER) |
575 | 17 | .and_then(|value| value.trim()2 .parse2 ::<u64>().ok2 ()) |
576 | 17 | .map(Duration::from_secs) |
577 | 17 | }) |
578 | 17 | }); |
579 | 17 | let asked = header_wait |
580 | 17 | .unwrap_or(Duration::ZERO) |
581 | 17 | .max(self.throttle_retry_delay); |
582 | 17 | if asked > self.throttle_retry_max_delay { |
583 | 1 | event!( |
584 | 1 | Level::WARN, |
585 | | ?op, |
586 | 1 | asked_s = asked.as_secs_f32(), |
587 | 1 | cap_s = self.throttle_retry_max_delay.as_secs_f32(), |
588 | | "Throttled response asked for a longer wait than throttle_retry_max_delay_s; waiting the cap" |
589 | | ); |
590 | 1 | return Some(self.throttle_retry_max_delay); |
591 | 16 | } |
592 | 16 | Some(asked) |
593 | 29 | } |
594 | | |
595 | 10 | fn retry_later<T>( |
596 | 10 | &self, |
597 | 10 | sdk_err: &azure_core::Error, |
598 | 10 | op: &'static str, |
599 | 10 | err: Error, |
600 | 10 | ) -> RetryResult<T> { |
601 | 10 | match self.throttle_wait(sdk_err, op) { |
602 | 1 | Some(wait) => RetryResult::RetryAfter(err, wait), |
603 | 9 | None => RetryResult::Retry(err), |
604 | | } |
605 | 10 | } |
606 | | |
607 | 159 | async fn has(self: Pin<&Self>, digest: &StoreKey<'_>) -> Result<Option<u64>, Error> { |
608 | 159 | let blob_path = self.make_blob_path(digest); |
609 | 159 | let declared_size = match digest.borrow() { |
610 | 159 | StoreKey::Digest(digest) => Some(digest.size_bytes()), |
611 | 0 | StoreKey::Str(_) => None, |
612 | | }; |
613 | | |
614 | 159 | self.retrier |
615 | 161 | .retry159 (unfold159 (()159 , move |state| { |
616 | 161 | let blob_path = blob_path.clone(); |
617 | 161 | let client = Arc::clone(&self.client); |
618 | 161 | async move { |
619 | 161 | let _request_permit = self.request_permit().await; |
620 | 161 | let _permit = match fs::get_permit().await { |
621 | 161 | Ok(permit) => permit, |
622 | 0 | Err(e) => { |
623 | 0 | return Some(( |
624 | 0 | RetryResult::Retry(make_err!( |
625 | 0 | Code::Unavailable, |
626 | 0 | "Failed to acquire permit: {e}" |
627 | 0 | )), |
628 | 0 | state, |
629 | 0 | )); |
630 | | } |
631 | | }; |
632 | | |
633 | 161 | let result = client.blob_client(&blob_path).get_properties(None).await; |
634 | | |
635 | 161 | match result { |
636 | 152 | Ok(props) => { |
637 | 152 | if self.consider_expired_after_s > 0 |
638 | 2 | && let Some(last_modified) = props.last_modified().ok().flatten() |
639 | | { |
640 | 2 | let now = (self.now_fn)() |
641 | 2 | .unix_timestamp() |
642 | 2 | .try_into() |
643 | 2 | .unwrap_or(i64::MAX); |
644 | 2 | if last_modified.unix_timestamp() + self.consider_expired_after_s |
645 | 2 | <= now |
646 | | { |
647 | 1 | return Some((RetryResult::Ok(None), state)); |
648 | 1 | } |
649 | 150 | } |
650 | 151 | let blob_size = props.content_length().ok().flatten().unwrap_or(0); |
651 | | // An empty object cannot be a blob whose digest |
652 | | // names bytes, compressed or not: a failed upload |
653 | | // published it. Reported present it is read as |
654 | | // DataLoss forever and never uploaded again, so |
655 | | // say it is missing and let the client replace it. |
656 | 151 | if blob_size == 0 && declared_size1 .is_some_and1 (|size| size1 > 0) { |
657 | 1 | event!( |
658 | 1 | Level::WARN, |
659 | | blob_path, |
660 | | "Azure holds an empty object for a digest that names bytes; reporting it missing" |
661 | | ); |
662 | 1 | return Some((RetryResult::Ok(None), state)); |
663 | 150 | } |
664 | 150 | Some((RetryResult::Ok(Some(blob_size)), state)) |
665 | | } |
666 | 9 | Err(err) => { |
667 | 9 | if err.http_status() == Some(StatusCode::NotFound) { |
668 | | // Distinguish a missing container (a config error) from a |
669 | | // missing blob (a normal cache miss). |
670 | | if let ErrorKind::HttpResponse { |
671 | 0 | error_code: Some(error_code), |
672 | | .. |
673 | 1 | } = err.kind() |
674 | 0 | && error_code == StorageErrorCode::ContainerNotFound.as_ref() |
675 | | { |
676 | 0 | return Some(( |
677 | 0 | RetryResult::Err(make_err!( |
678 | 0 | Code::InvalidArgument, |
679 | 0 | "Container not found: {err}" |
680 | 0 | )), |
681 | 0 | state, |
682 | 0 | )); |
683 | 1 | } |
684 | 1 | Some((RetryResult::Ok(None), state)) |
685 | | } else { |
686 | 8 | Some(( |
687 | 8 | self.retry_later( |
688 | 8 | &err, |
689 | 8 | "has", |
690 | 8 | make_err!( |
691 | 8 | Code::Unavailable, |
692 | 8 | "Failed to get blob properties: {err:?}" |
693 | 8 | ), |
694 | 8 | ), |
695 | 8 | state, |
696 | 8 | )) |
697 | | } |
698 | | } |
699 | | } |
700 | 161 | } |
701 | 161 | })) |
702 | 159 | .await |
703 | 159 | } |
704 | | |
705 | 12 | async fn upload_staged_blocks( |
706 | 12 | self: Pin<&Self>, |
707 | 12 | blob_path: &str, |
708 | 12 | mut reader: DropCloserReadHalf, |
709 | 12 | upload_size: UploadSizeInfo, |
710 | 12 | ) -> Result<u64, Error> { |
711 | 12 | let blob_path = blob_path.to_owned(); |
712 | 12 | let max_size = match upload_size { |
713 | 12 | UploadSizeInfo::ExactSize(size10 ) | UploadSizeInfo::MaxSize(size2 ) => size, |
714 | | }; |
715 | 12 | let block_size = |
716 | 12 | cmp::min(max_size / (MAX_BLOCKS as u64 - 1), MAX_BLOCK_SIZE).max(DEFAULT_BLOCK_SIZE); |
717 | | |
718 | 12 | let (tx, mut rx) = mpsc::channel(self.max_concurrent_uploads); |
719 | 12 | let mut block_ids: Vec<Vec<u8>> = Vec::with_capacity(MAX_BLOCKS); |
720 | 12 | let retrier = self.retrier.clone(); |
721 | | |
722 | 12 | let read_stream_fut = { |
723 | 12 | let tx = tx.clone(); |
724 | 12 | let blob_path = blob_path.clone(); |
725 | 12 | async move { |
726 | 12 | let mut total_uploaded = 0; |
727 | 33 | for block_id in 0..MAX_BLOCKS12 { |
728 | 33 | let write_buf32 = reader |
729 | 33 | .consume(Some( |
730 | 33 | usize::try_from(block_size) |
731 | 33 | .err_tip(|| "Could not convert block_size to usize")?0 , |
732 | | )) |
733 | 33 | .await |
734 | 32 | .err_tip(|| "Failed to read chunk in azure_store")?0 ; |
735 | | |
736 | 32 | if write_buf.is_empty() { |
737 | 11 | break; |
738 | 21 | } |
739 | | |
740 | 21 | total_uploaded += write_buf.len() as u64; |
741 | | |
742 | | // Fixed-width, zero-padded ids keep the committed block list ordered |
743 | | // after a lexicographic sort. |
744 | 21 | let block_id = format!("{block_id:032}").into_bytes(); |
745 | 21 | let blob_path = blob_path.clone(); |
746 | | |
747 | 21 | tx.send(async move { |
748 | 21 | self.retrier |
749 | 21 | .retry(unfold( |
750 | 21 | (write_buf, block_id), |
751 | 21 | move |(write_buf, block_id)| { |
752 | 21 | let client = Arc::clone(&self.client); |
753 | 21 | let blob_path = blob_path.clone(); |
754 | 21 | async move { |
755 | 21 | let _request_permit = self.request_permit().await; |
756 | 21 | let _permit = match fs::get_permit().await { |
757 | 21 | Ok(permit) => permit, |
758 | 0 | Err(e) => { |
759 | 0 | return Some(( |
760 | 0 | RetryResult::Retry(make_err!( |
761 | 0 | Code::Unavailable, |
762 | 0 | "Failed to acquire permit: {e}" |
763 | 0 | )), |
764 | 0 | (write_buf, block_id), |
765 | 0 | )); |
766 | | } |
767 | | }; |
768 | 21 | let content_length = write_buf.len() as u64; |
769 | 21 | let retry_result = client |
770 | 21 | .blob_client(&blob_path) |
771 | 21 | .block_blob_client() |
772 | 21 | .stage_block( |
773 | 21 | &block_id, |
774 | 21 | content_length, |
775 | 21 | RequestContent::from(write_buf.to_vec()), |
776 | 21 | None, |
777 | 21 | ) |
778 | 21 | .await |
779 | 21 | .map_or_else( |
780 | 2 | |e| { |
781 | 2 | self.retry_later( |
782 | 2 | &e, |
783 | 2 | "put", |
784 | 2 | make_err!( |
785 | 2 | Code::Aborted, |
786 | 2 | "Failed to upload block in Azure store: {e:?}" |
787 | 2 | ), |
788 | 2 | ) |
789 | 2 | }, |
790 | 19 | |_| RetryResult::Ok(block_id.clone()), |
791 | | ); |
792 | 21 | Some((retry_result, (write_buf, block_id))) |
793 | 21 | } |
794 | 21 | }, |
795 | | )) |
796 | 21 | .await |
797 | 21 | }) |
798 | 21 | .await |
799 | 21 | .map_err(|err| {0 |
800 | 0 | Error::from_std_err(Code::Internal, &err) |
801 | 0 | .append("Failed to send block to channel") |
802 | 0 | })?; |
803 | | } |
804 | 11 | Ok::<_, Error>(total_uploaded) |
805 | 11 | } |
806 | 12 | .fuse() |
807 | | }; |
808 | | |
809 | 12 | let mut upload_futures = FuturesUnordered::new(); |
810 | 12 | let mut total_uploaded = 0; |
811 | | |
812 | 12 | tokio::pin!(read_stream_fut); |
813 | | |
814 | 12 | let mut failure: Option<Error> = None; |
815 | | loop { |
816 | 63 | if read_stream_fut.is_terminated() && rx49 .is_empty49 () && upload_futures26 .is_empty26 () { |
817 | 9 | break; |
818 | 54 | } |
819 | 54 | tokio::select! { |
820 | 54 | result11 = &mut read_stream_fut => match result11 { |
821 | 11 | Ok(uploaded) => total_uploaded = uploaded, |
822 | 0 | Err(err) => { |
823 | 0 | failure = Some(err); |
824 | 0 | break; |
825 | | } |
826 | | }, |
827 | 54 | Some(block_id21 ) = upload_futures.next() => match block_id21 { |
828 | 19 | Ok(block_id) => block_ids.push(block_id), |
829 | 2 | Err(err) => { |
830 | 2 | failure = Some(err); |
831 | 2 | break; |
832 | | } |
833 | | }, |
834 | 54 | Some(fut21 ) = rx.recv() => upload_futures21 .push21 (fut21 ), |
835 | | } |
836 | | } |
837 | 11 | if let Some(err2 ) = failure { |
838 | | // The read future is never polled again, so nothing new is |
839 | | // staged; blocks already on their way to Azure are allowed to |
840 | | // land so the discard that follows sees every one of them |
841 | | // instead of racing a request still in flight. |
842 | 2 | drop(rx); |
843 | 2 | while upload_futures.next().await.is_some() {}0 |
844 | 2 | return Err(err); |
845 | 9 | } |
846 | | |
847 | | // Reject an incomplete transfer before publishing its staged blocks. |
848 | | // The caller discards them using the same cleanup as other failures. |
849 | 9 | upload_size.validate_size(total_uploaded)?6 ; |
850 | | |
851 | | // Sorting block IDs to ensure consistent ordering of the committed blob. |
852 | 3 | block_ids.sort_unstable(); |
853 | | |
854 | 3 | let block_list = BlockLookupList { |
855 | 3 | latest: Some(block_ids), |
856 | 3 | ..Default::default() |
857 | 3 | }; |
858 | | |
859 | 3 | retrier |
860 | 3 | .retry(unfold(block_list, move |block_list| { |
861 | 3 | let client = Arc::clone(&self.client); |
862 | 3 | let blob_path = blob_path.clone(); |
863 | | |
864 | 3 | async move { |
865 | 3 | let _request_permit = self.request_permit().await; |
866 | 3 | let _permit = match fs::get_permit().await { |
867 | 3 | Ok(permit) => permit, |
868 | 0 | Err(e) => { |
869 | 0 | return Some(( |
870 | 0 | RetryResult::Retry(make_err!( |
871 | 0 | Code::Unavailable, |
872 | 0 | "Failed to acquire permit: {e}" |
873 | 0 | )), |
874 | 0 | block_list, |
875 | 0 | )); |
876 | | } |
877 | | }; |
878 | | |
879 | 3 | let blocks = match RequestContent::try_from(block_list.clone()) { |
880 | 3 | Ok(blocks) => blocks, |
881 | 0 | Err(e) => { |
882 | 0 | return Some(( |
883 | 0 | RetryResult::Err(make_err!( |
884 | 0 | Code::Internal, |
885 | 0 | "Failed to serialize block list in Azure store: {e:?}" |
886 | 0 | )), |
887 | 0 | block_list, |
888 | 0 | )); |
889 | | } |
890 | | }; |
891 | | |
892 | 3 | let retry_result = client |
893 | 3 | .blob_client(&blob_path) |
894 | 3 | .block_blob_client() |
895 | 3 | .commit_block_list(blocks, None) |
896 | 3 | .await |
897 | 3 | .map_or_else( |
898 | 0 | |e| { |
899 | 0 | self.retry_later( |
900 | 0 | &e, |
901 | 0 | "put", |
902 | 0 | Error::from_std_err(Code::Aborted, &e) |
903 | 0 | .append("Failed to commit block list in Azure store:"), |
904 | 0 | ) |
905 | 0 | }, |
906 | 3 | |_| RetryResult::Ok(total_uploaded), |
907 | | ); |
908 | 3 | Some((retry_result, block_list)) |
909 | 3 | } |
910 | 3 | })) |
911 | 3 | .await |
912 | 11 | } |
913 | | } |
914 | | |
915 | | #[async_trait] |
916 | | impl<I, NowFn> StoreDriver for AzureBlobStore<NowFn> |
917 | | where |
918 | | I: InstantWrapper, |
919 | | NowFn: Fn() -> I + Send + Sync + Unpin + 'static, |
920 | | { |
921 | 0 | async fn post_init(self: Arc<Self>) -> Result<(), Error> { |
922 | | Ok(()) |
923 | 0 | } |
924 | | |
925 | | async fn has_with_results( |
926 | | self: Pin<&Self>, |
927 | | keys: &[StoreKey<'_>], |
928 | | results: &mut [Option<u64>], |
929 | 34 | ) -> Result<(), Error> { |
930 | | keys.iter() |
931 | | .zip(results.iter_mut()) |
932 | 160 | .map(|(key, result)| async move { |
933 | 160 | if is_zero_digest(key.borrow()) { |
934 | 1 | *result = Some(0); |
935 | 1 | return Ok::<_, Error>(()); |
936 | 159 | } |
937 | 159 | *result = self.has(key).await?6 ; |
938 | 153 | Ok::<_, Error>(()) |
939 | 320 | }) |
940 | | .collect::<FuturesUnordered<_>>() |
941 | | .try_collect() |
942 | | .await |
943 | 34 | } |
944 | | |
945 | 0 | fn optimized_for(&self, optimization: StoreOptimizations) -> bool { |
946 | 0 | matches!(optimization, StoreOptimizations::LazyExistenceOnSync) |
947 | 0 | } |
948 | | |
949 | 0 | fn max_upload_size(&self) -> u64 { |
950 | 0 | MAX_BLOCKS as u64 * MAX_BLOCK_SIZE |
951 | 0 | } |
952 | | |
953 | | async fn update( |
954 | | self: Pin<&Self>, |
955 | | digest: StoreKey<'_>, |
956 | | mut reader: DropCloserReadHalf, |
957 | | upload_size: UploadSizeInfo, |
958 | 61 | ) -> Result<u64, Error> { |
959 | | let blob_path = self.make_blob_path(&digest); |
960 | | // Handling zero-sized content check |
961 | | if is_zero_digest(digest.borrow()) { |
962 | | let size = reader.drain().await?; |
963 | | // The empty digest constrains the content; the caller's transfer |
964 | | // declaration must independently agree with the received size. |
965 | | UploadSizeInfo::ExactSize(0).validate_size(size)?; |
966 | | upload_size.validate_size(size)?; |
967 | | return Ok(0); |
968 | | } |
969 | | |
970 | | let max_size = match upload_size { |
971 | | UploadSizeInfo::ExactSize(sz) | UploadSizeInfo::MaxSize(sz) => sz, |
972 | | }; |
973 | | |
974 | | // For small files of a known size we buffer to `Bytes` and upload in a single request. |
975 | | if max_size < DEFAULT_BLOCK_SIZE && matches!(upload_size, UploadSizeInfo::ExactSize(_)) { |
976 | | let UploadSizeInfo::ExactSize(sz) = upload_size else { |
977 | | unreachable!("upload_size must be UploadSizeInfo::ExactSize here"); |
978 | | }; |
979 | | |
980 | | reader.set_max_recent_data_size( |
981 | | u64::try_from(self.max_retry_buffer_per_request) |
982 | | .err_tip(|| "Could not convert max_retry_buffer_per_request to u64")?, |
983 | | ); |
984 | | |
985 | | return self |
986 | | .retrier |
987 | 39 | .retry(unfold(reader, move |mut reader| { |
988 | 39 | let client = Arc::clone(&self.client); |
989 | 39 | let blob_path = blob_path.clone(); |
990 | 39 | async move { |
991 | | // Collect through EOF before issuing a PUT. A short or failed |
992 | | // producer must never replace a valid object with partial data. |
993 | | // No permit is held meanwhile: the bytes arrive at the |
994 | | // producer's pace, and no connection is open yet. |
995 | 39 | let content37 = match reader |
996 | 39 | .consume(Some(usize::try_from(sz).unwrap_or(usize::MAX).saturating_add(1))) |
997 | 39 | .await |
998 | | { |
999 | 37 | Ok(content) => content, |
1000 | 2 | Err(err) => return Some((RetryResult::Err(err), reader)), |
1001 | | }; |
1002 | 37 | if let Err(err2 ) = upload_size.validate_size(content.len() as u64) { |
1003 | 2 | return Some((RetryResult::Err(err), reader)); |
1004 | 35 | } |
1005 | | |
1006 | 35 | let _request_permit = self.request_permit().await; |
1007 | 35 | let _permit = match fs::get_permit().await { |
1008 | 35 | Ok(permit) => permit, |
1009 | 0 | Err(e) => { |
1010 | | // The next attempt reads the content again. |
1011 | 0 | let err = make_err!( |
1012 | 0 | Code::Unavailable, |
1013 | | "Failed to acquire permit: {e}" |
1014 | | ); |
1015 | 0 | let retry = match reader.try_reset_stream() { |
1016 | 0 | Ok(()) => RetryResult::Retry(err), |
1017 | 0 | Err(reset_err) => RetryResult::Err(err.merge(reset_err)), |
1018 | | }; |
1019 | 0 | return Some((retry, reader)); |
1020 | | } |
1021 | | }; |
1022 | 35 | let result = client |
1023 | 35 | .blob_client(&blob_path) |
1024 | 35 | .block_blob_client() |
1025 | 35 | .upload(RequestContent::from(content.to_vec()), None) |
1026 | 35 | .await |
1027 | 35 | .map(|_| ()) |
1028 | 35 | .map_err(|e| ( |
1029 | 1 | make_err!(Code::Aborted, "{e:?}"), |
1030 | 1 | self.throttle_wait(&e, "put"), |
1031 | | )); |
1032 | | |
1033 | 35 | match result { |
1034 | | Ok(()) => { |
1035 | 34 | Some((RetryResult::Ok(reader.get_bytes_received()), reader)) |
1036 | | } |
1037 | 1 | Err((mut err, throttle_wait)) => { |
1038 | 1 | err.code = Code::Aborted; |
1039 | 1 | let bytes_received = reader.get_bytes_received(); |
1040 | | |
1041 | 1 | if let Err(try_reset_err0 ) = reader.try_reset_stream() { |
1042 | 0 | event!( |
1043 | 0 | Level::ERROR, |
1044 | | ?bytes_received, |
1045 | | err = ?try_reset_err, |
1046 | | "Unable to reset stream after failed upload in AzureStore::update" |
1047 | | ); |
1048 | 0 | Some(( |
1049 | 0 | RetryResult::Err(err.merge(try_reset_err).append(format!( |
1050 | 0 | "Failed to retry upload with {bytes_received} bytes received in AzureStore::update" |
1051 | 0 | ))), |
1052 | 0 | reader, |
1053 | 0 | )) |
1054 | | } else { |
1055 | 1 | let err = err.append(format!( |
1056 | | "Retry on upload happened with {bytes_received} bytes received in AzureStore::update" |
1057 | | )); |
1058 | 1 | event!( |
1059 | 1 | Level::INFO, |
1060 | | ?err, |
1061 | | ?bytes_received, |
1062 | | "Retryable Azure error" |
1063 | | ); |
1064 | 1 | let retry = match throttle_wait { |
1065 | 0 | Some(wait) => RetryResult::RetryAfter(err, wait), |
1066 | 1 | None => RetryResult::Retry(err), |
1067 | | }; |
1068 | 1 | Some((retry, reader)) |
1069 | | } |
1070 | | } |
1071 | | } |
1072 | 39 | } |
1073 | 39 | })) |
1074 | | .await; |
1075 | | } |
1076 | | |
1077 | | // For larger files we stream the content as staged blocks and commit a block list. |
1078 | | // A block that is staged but never committed is billed until Azure collects it a |
1079 | | // week later, so a failure anywhere past this point discards what was staged. |
1080 | | let mut guard = StagedBlocksGuard { |
1081 | | client: Arc::clone(&self.client), |
1082 | | requests: self.request_permits.clone(), |
1083 | | blob_path: blob_path.clone(), |
1084 | | armed: true, |
1085 | | }; |
1086 | | let result = self |
1087 | | .upload_staged_blocks(&blob_path, reader, upload_size) |
1088 | | .await; |
1089 | | if result.is_ok() { |
1090 | | guard.disarm(); |
1091 | | } |
1092 | | result |
1093 | 61 | } |
1094 | | |
1095 | | async fn get_part( |
1096 | | self: Pin<&Self>, |
1097 | | key: StoreKey<'_>, |
1098 | | writer: &mut DropCloserWriteHalf, |
1099 | | offset: u64, |
1100 | | length: Option<u64>, |
1101 | 40 | ) -> Result<(), Error> { |
1102 | | if is_zero_digest(key.borrow()) { |
1103 | | writer |
1104 | | .send_eof() |
1105 | | .err_tip(|| "Failed to send zero EOF in azure store get_part")?; |
1106 | | return Ok(()); |
1107 | | } |
1108 | | |
1109 | | let blob_path = self.make_blob_path(&key); |
1110 | | |
1111 | | // How many bytes this read may yield: the explicit length when the |
1112 | | // caller gives one, else the whole object. The digest's own size is |
1113 | | // not a bound here. A compression store above reads the object it |
1114 | | // wrote under this key, and an lz4 frame of data that does not |
1115 | | // compress is longer than the digest it is stored under; cut at the |
1116 | | // digest's size it is a torn frame and every read of that blob fails. |
1117 | | // A raw object longer than its digest, which the upload-size |
1118 | | // validation no longer publishes, is the verify store's to judge. |
1119 | | let size_from_key = match &key { |
1120 | | StoreKey::Digest(digest) => Some(digest.size_bytes()), |
1121 | | StoreKey::Str(_) => None, |
1122 | | }; |
1123 | | let max_bytes = length; |
1124 | | |
1125 | | self.retrier |
1126 | 57 | .retry(unfold(writer, move |writer| { |
1127 | | // Resumes where the writer stopped, so a retry continues the |
1128 | | // read instead of starting it again and appending a second |
1129 | | // copy of what the consumer already has. The S3 store does |
1130 | | // the same; this one did not, and with a throttling account |
1131 | | // every retry doubled up. |
1132 | 57 | let sent = writer.get_bytes_written(); |
1133 | 57 | let remaining = max_bytes.map(|max| max4 .saturating_sub4 (sent4 )); |
1134 | 57 | let range = match remaining53 { |
1135 | 0 | Some(0) => None, |
1136 | 4 | Some(rem) => Some(HttpRange::new(offset + sent, rem)), |
1137 | 53 | None if offset + sent == 0 => None, |
1138 | 0 | None => Some(HttpRange::from_offset(offset + sent)), |
1139 | | }; |
1140 | 57 | let client = Arc::clone(&self.client); |
1141 | 57 | let blob_path = blob_path.clone(); |
1142 | 57 | async move { |
1143 | | // Held through the body: what is in flight is what the |
1144 | | // read limit says, not only what has been started. |
1145 | 57 | let _read_permit = self.read_permit().await; |
1146 | 57 | let _request_permit = self.request_permit().await; |
1147 | 57 | let _permit = match fs::get_permit().await { |
1148 | 57 | Ok(permit) => permit, |
1149 | 0 | Err(e) => { |
1150 | 0 | return Some(( |
1151 | 0 | RetryResult::Retry(make_err!( |
1152 | 0 | Code::Unavailable, |
1153 | 0 | "Failed to acquire permit: {e}" |
1154 | 0 | )), |
1155 | 0 | writer, |
1156 | 0 | )); |
1157 | | } |
1158 | | }; |
1159 | | |
1160 | | // The error carries the wait a throttled response asked for. |
1161 | 57 | let result: Result<(), (Error, Option<Duration>)> = async { |
1162 | 57 | let options = BlobClientDownloadOptions { |
1163 | 57 | range, |
1164 | 57 | ..Default::default() |
1165 | 57 | }; |
1166 | 57 | let response38 = client |
1167 | 57 | .blob_client(&blob_path) |
1168 | 57 | .download(Some(options)) |
1169 | 57 | .await |
1170 | 57 | .map_err(|e| {19 |
1171 | 19 | if e.http_status() == Some(StatusCode::NotFound) { |
1172 | 1 | ( |
1173 | 1 | make_err!(Code::NotFound, "Blob not found in Azure: {e:?}"), |
1174 | 1 | None, |
1175 | 1 | ) |
1176 | | } else { |
1177 | 18 | ( |
1178 | 18 | make_err!( |
1179 | 18 | Code::Aborted, |
1180 | 18 | "Failed to start download from Azure: {e:?}" |
1181 | 18 | ), |
1182 | 18 | self.throttle_wait(&e, "get"), |
1183 | 18 | ) |
1184 | | } |
1185 | 19 | })?; |
1186 | | |
1187 | 38 | let mut body = response.body; |
1188 | 73 | while let Some(chunk38 ) = body.try_next().await.map_err(|e| {0 |
1189 | 0 | ( |
1190 | 0 | make_err!(Code::Aborted, "Error reading from Azure stream: {e:?}"), |
1191 | 0 | self.throttle_wait(&e, "get"), |
1192 | 0 | ) |
1193 | 0 | })? { |
1194 | 38 | if chunk.is_empty() { |
1195 | 2 | continue; |
1196 | 36 | } |
1197 | | // Trim the last chunk rather than forward it |
1198 | | // whole. Azure hands back the block it read, so |
1199 | | // the final one runs past the end of a blob whose |
1200 | | // length is not a multiple of the block size. |
1201 | 36 | let chunk = match max_bytes { |
1202 | 3 | Some(max) => { |
1203 | 3 | let sent = writer.get_bytes_written(); |
1204 | 3 | if sent >= max { |
1205 | 0 | break; |
1206 | 3 | } |
1207 | 3 | let room = usize::try_from(max - sent).unwrap_or(usize::MAX); |
1208 | 3 | if chunk.len() > room { |
1209 | 2 | chunk.slice(..room) |
1210 | | } else { |
1211 | 1 | chunk |
1212 | | } |
1213 | | } |
1214 | 33 | None => chunk, |
1215 | | }; |
1216 | 36 | writer.send(chunk).await.map_err(|e| {0 |
1217 | 0 | ( |
1218 | 0 | make_err!( |
1219 | 0 | Code::Aborted, |
1220 | 0 | "Failed to send data to writer: {e:?}" |
1221 | 0 | ), |
1222 | 0 | None, |
1223 | 0 | ) |
1224 | 0 | })?; |
1225 | 36 | if max_bytes.is_some_and(|max| writer3 .get_bytes_written3 () >= max3 ) { |
1226 | 3 | break; |
1227 | 33 | } |
1228 | | } |
1229 | | |
1230 | | // A body of no bytes where the digest names some is |
1231 | | // the empty object a failed upload left behind, not |
1232 | | // the blob: NotFound sends the client back to upload |
1233 | | // it, where EOF would hand on an empty input. |
1234 | 38 | if writer.get_bytes_written() == 0 |
1235 | 2 | && size_from_key.is_some_and(|size| size > offset) |
1236 | 2 | && max_bytes.is_none_or(|max| max0 > 0) |
1237 | | { |
1238 | 2 | return Err(( |
1239 | 2 | make_err!( |
1240 | 2 | Code::NotFound, |
1241 | 2 | "Azure holds an empty object for {blob_path}, which names {} bytes; treating it as missing so the client uploads it again", |
1242 | 2 | size_from_key.unwrap_or(0) |
1243 | 2 | ), |
1244 | 2 | None, |
1245 | 2 | )); |
1246 | 36 | } |
1247 | 36 | writer.send_eof().map_err(|e| {0 |
1248 | 0 | ( |
1249 | 0 | make_err!(Code::Aborted, "Failed to send EOF to writer: {e:?}"), |
1250 | 0 | None, |
1251 | 0 | ) |
1252 | 0 | })?; |
1253 | 36 | Ok(()) |
1254 | 57 | } |
1255 | 57 | .await; |
1256 | | |
1257 | 18 | match result { |
1258 | | Ok(()) => { |
1259 | 36 | self.note_read_ok(); |
1260 | 36 | Some((RetryResult::Ok(()), writer)) |
1261 | | } |
1262 | 21 | Err((e3 , _)) if e.code == Code::NotFound3 => { |
1263 | 3 | self.note_read_ok(); |
1264 | 3 | Some((RetryResult::Err(e), writer)) |
1265 | | } |
1266 | 16 | Err((e, Some(wait))) => { |
1267 | 16 | self.note_read_throttled(); |
1268 | 16 | Some((RetryResult::RetryAfter(e, wait), writer)) |
1269 | | } |
1270 | 2 | Err((e, None)) => Some((RetryResult::Retry(e), writer)), |
1271 | | } |
1272 | 57 | } |
1273 | 57 | })) |
1274 | | .await |
1275 | 40 | } |
1276 | | |
1277 | 0 | fn inner_store(&self, _digest: Option<StoreKey>) -> &'_ dyn StoreDriver { |
1278 | 0 | self |
1279 | 0 | } |
1280 | | |
1281 | 0 | fn as_any<'a>(&'a self) -> &'a (dyn core::any::Any + Sync + Send + 'static) { |
1282 | 0 | self |
1283 | 0 | } |
1284 | | |
1285 | 0 | fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send + 'static> { |
1286 | 0 | self |
1287 | 0 | } |
1288 | | |
1289 | 0 | fn register_health(self: Arc<Self>, registry: &mut HealthRegistryBuilder) { |
1290 | 0 | registry.register_indicator(self); |
1291 | 0 | } |
1292 | | |
1293 | 0 | fn register_remove_callback(self: Arc<Self>, _callback: RemoveCallback) -> Result<(), Error> { |
1294 | | // Azure Blob Storage manages object lifecycle externally, |
1295 | | // so we can safely ignore remove callbacks. |
1296 | 0 | Ok(()) |
1297 | 0 | } |
1298 | | } |
1299 | | |
1300 | | #[async_trait] |
1301 | | impl<I, NowFn> HealthStatusIndicator for AzureBlobStore<NowFn> |
1302 | | where |
1303 | | I: InstantWrapper, |
1304 | | NowFn: Fn() -> I + Send + Sync + Unpin + 'static, |
1305 | | { |
1306 | 0 | fn get_name(&self) -> &'static str { |
1307 | 0 | "AzureBlobStore" |
1308 | 0 | } |
1309 | | |
1310 | 0 | async fn check_health(&self, namespace: Cow<'static, str>) -> HealthStatus { |
1311 | | StoreDriver::check_health(Pin::new(self), namespace).await |
1312 | 0 | } |
1313 | | } |