Skip to content

Commit e4b4dd0

Browse files
authored
feat(gateway): implement Azure Blob read adapter (#485)
1 parent 147bbe7 commit e4b4dd0

10 files changed

Lines changed: 1699 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ axum = "0.8"
5757
tower = { version = "0.5", features = ["limit", "util"] }
5858
tower-http = { version = "0.6", features = ["timeout"] }
5959
http-body-util = "0.1"
60+
httpdate = "1"
6061
serde_yaml = "0.9"
6162
# Kubernetes client for the Lease-based ClusterStateStore backend (feature
6263
# "kubernetes"). rustls avoids a system OpenSSL build dependency. k8s-openapi is

crates/talon-backend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ talon-core.workspace = true
1313
serde_json.workspace = true
1414
async-trait.workspace = true
1515
bytes.workspace = true
16+
futures.workspace = true
1617
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
1718
tokio = { workspace = true, features = ["time"] }
1819
tokio-util.workspace = true

crates/talon-backend/src/azure.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use talon_core::{
2020
BackendStore, Error, ListPage, ListedObject, ObjectId, ObjectStat, Result, Version,
2121
};
2222

23-
use crate::http::{HttpClient, HttpRequest, Method};
23+
use crate::http::{HttpClient, HttpRequest, HttpResponse, HttpStreamResponse, Method};
2424

2525
/// Percent-encode a query value. `/` is escaped: a listing prefix contains
2626
/// slashes and an unescaped one would change the request path.
@@ -163,6 +163,18 @@ impl AzureBackend {
163163
prefix: &str,
164164
cursor: Option<&str>,
165165
max: u32,
166+
) -> String {
167+
self.list_url_with_delimiter(container, prefix, None, cursor, max)
168+
}
169+
170+
/// Build a container listing URL with the full gateway read surface.
171+
pub fn list_url_with_delimiter(
172+
&self,
173+
container: &str,
174+
prefix: &str,
175+
delimiter: Option<&str>,
176+
cursor: Option<&str>,
177+
max: u32,
166178
) -> String {
167179
let scheme = if self.config.tls { "https" } else { "http" };
168180
let host =
@@ -178,6 +190,9 @@ impl AzureBackend {
178190
if !prefix.is_empty() {
179191
query.push_str(&format!("&prefix={}", encode_query_value(prefix)));
180192
}
193+
if let Some(delimiter) = delimiter.filter(|value| !value.is_empty()) {
194+
query.push_str(&format!("&delimiter={}", encode_query_value(delimiter)));
195+
}
181196
if let Some(marker) = cursor {
182197
query.push_str(&format!("&marker={}", encode_query_value(marker)));
183198
}
@@ -298,6 +313,57 @@ impl AzureBackend {
298313
}
299314
}
300315

316+
/// Execute a raw metadata request while forwarding only validated
317+
/// conditional headers supplied by a protocol adapter.
318+
pub async fn execute_head_raw(
319+
&self,
320+
obj: &ObjectId,
321+
conditions: &[(String, String)],
322+
) -> std::result::Result<HttpResponse, String> {
323+
let mut request = self.build_head(obj);
324+
request.headers.extend_from_slice(conditions);
325+
self.http.execute(self.authorized(request)).await
326+
}
327+
328+
/// Execute a whole or ranged GET without buffering its response body.
329+
pub async fn execute_get_stream_raw(
330+
&self,
331+
obj: &ObjectId,
332+
range: Option<(u64, u64)>,
333+
conditions: &[(String, String)],
334+
) -> std::result::Result<HttpStreamResponse, String> {
335+
let mut headers = self.common_headers();
336+
if let Some((start, end)) = range {
337+
headers.push(("x-ms-range".into(), format!("bytes={start}-{end}")));
338+
}
339+
headers.extend_from_slice(conditions);
340+
let request = HttpRequest::new(Method::Get, self.blob_url(obj), headers);
341+
self.http.execute_stream(self.authorized(request)).await
342+
}
343+
344+
/// Execute one raw Azure List Blobs page.
345+
pub async fn execute_list_raw(
346+
&self,
347+
container: &str,
348+
prefix: &str,
349+
delimiter: Option<&str>,
350+
marker: Option<&str>,
351+
max_results: u32,
352+
conditions: &[(String, String)],
353+
) -> std::result::Result<HttpResponse, String> {
354+
let url = self.list_url_with_delimiter(
355+
container,
356+
prefix,
357+
delimiter,
358+
marker,
359+
max_results.clamp(1, 5000),
360+
);
361+
let mut headers = self.common_headers();
362+
headers.extend_from_slice(conditions);
363+
let request = HttpRequest::new(Method::Get, url, headers);
364+
self.http.execute(self.authorized(request)).await
365+
}
366+
301367
/// Build the whole-blob PUT request (exposed for testing).
302368
///
303369
/// Azure block-blob upload requires `x-ms-blob-type: BlockBlob`. When
@@ -407,7 +473,11 @@ impl BackendStore for AzureBackend {
407473
let max_keys = max_keys.clamp(1, 5000);
408474
let url = self.list_url(bucket, prefix, cursor, max_keys);
409475
let req = HttpRequest::new(Method::Get, url, self.common_headers());
410-
let resp = self.http.execute(req).await.map_err(Error::Backend)?;
476+
let resp = self
477+
.http
478+
.execute(self.authorized(req))
479+
.await
480+
.map_err(Error::Backend)?;
411481
if resp.status == 404 {
412482
return Err(Error::NotFound(bucket.to_string()));
413483
}

crates/talon-backend/src/http.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
//! asserts on the constructed [`HttpRequest`].
99
1010
use async_trait::async_trait;
11+
use bytes::Bytes;
12+
use futures::Stream;
1113
use std::path::Path;
14+
use std::pin::Pin;
1215

1316
/// An HTTP method (only the verbs the backends need).
1417
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -82,6 +85,31 @@ pub struct HttpResponse {
8285
pub body: bytes::Bytes,
8386
}
8487

88+
/// Streaming HTTP response used when buffering an object body is not allowed.
89+
pub struct HttpStreamResponse {
90+
/// HTTP status code.
91+
pub status: u16,
92+
/// Response headers.
93+
pub headers: Vec<(String, String)>,
94+
/// Demand-driven response body chunks.
95+
pub body: Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>,
96+
}
97+
98+
impl HttpStreamResponse {
99+
/// Look up the first response header value (case-insensitive).
100+
pub fn header(&self, name: &str) -> Option<&str> {
101+
self.headers
102+
.iter()
103+
.find(|(key, _)| key.eq_ignore_ascii_case(name))
104+
.map(|(_, value)| value.as_str())
105+
}
106+
107+
/// Whether the status is in the 2xx range.
108+
pub fn is_success(&self) -> bool {
109+
(200..300).contains(&self.status)
110+
}
111+
}
112+
85113
impl HttpResponse {
86114
/// Look up the first response header value (case-insensitive).
87115
pub fn header(&self, name: &str) -> Option<&str> {
@@ -220,6 +248,19 @@ pub trait HttpClient: Send + Sync {
220248
/// failure (DNS/connect/timeout).
221249
async fn execute(&self, req: HttpRequest) -> Result<HttpResponse, String>;
222250

251+
/// Execute without buffering the response body.
252+
///
253+
/// The compatibility default converts an existing buffered implementation
254+
/// into one chunk. Network clients must override this for object gateways.
255+
async fn execute_stream(&self, req: HttpRequest) -> Result<HttpStreamResponse, String> {
256+
let response = self.execute(req).await?;
257+
Ok(HttpStreamResponse {
258+
status: response.status,
259+
headers: response.headers,
260+
body: Box::pin(futures::stream::once(async move { Ok(response.body) })),
261+
})
262+
}
263+
223264
/// Execute a request whose body is streamed from `path`.
224265
///
225266
/// Implementations must send exactly `len` bytes with bounded memory. The

crates/talon-backend/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub mod xml;
2020
pub use azure::{AzureBackend, AzureConfig};
2121
pub use delay::{DelayConfig, DelayingHttpClient};
2222
pub use gcs::{GcsBackend, GcsConfig};
23-
pub use http::{HttpClient, HttpRequest, HttpResponse, Method};
23+
pub use http::{HttpClient, HttpRequest, HttpResponse, HttpStreamResponse, Method};
2424
pub use reqwest_client::ReqwestClient;
2525
pub use retry::{RetryConfig, RetryObserver, RetryingHttpClient};
2626
pub use s3::{S3Backend, S3Config, S3Credentials};

crates/talon-backend/src/reqwest_client.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
//! client does no signing of its own.
1313
1414
use async_trait::async_trait;
15+
use futures::StreamExt;
1516
use std::path::Path;
1617
use tokio::io::AsyncReadExt;
1718
use tokio_util::io::ReaderStream;
1819

19-
use crate::http::{HttpClient, HttpRequest, HttpResponse, Method};
20+
use crate::http::{HttpClient, HttpRequest, HttpResponse, HttpStreamResponse, Method};
2021

2122
/// Cap on establishing a TCP+TLS connection. Independent of transfer size, so a
2223
/// slow connect always indicates a fault rather than a large object.
@@ -101,6 +102,42 @@ impl HttpClient for ReqwestClient {
101102
})
102103
}
103104

105+
async fn execute_stream(&self, req: HttpRequest) -> Result<HttpStreamResponse, String> {
106+
let method = match req.method {
107+
Method::Get => reqwest::Method::GET,
108+
Method::Head => reqwest::Method::HEAD,
109+
Method::Put => reqwest::Method::PUT,
110+
Method::Delete => reqwest::Method::DELETE,
111+
};
112+
let mut builder = self.inner.request(method, &req.url);
113+
for (key, value) in &req.headers {
114+
builder = builder.header(key.as_str(), value.as_str());
115+
}
116+
if !req.body.is_empty() {
117+
builder = builder.body(req.body.clone());
118+
}
119+
let response = builder.send().await.map_err(sanitize_error)?;
120+
let status = response.status().as_u16();
121+
let headers = response
122+
.headers()
123+
.iter()
124+
.map(|(key, value)| {
125+
(
126+
key.as_str().to_string(),
127+
value.to_str().unwrap_or("").to_string(),
128+
)
129+
})
130+
.collect();
131+
let body = response
132+
.bytes_stream()
133+
.map(|chunk| chunk.map_err(sanitize_error));
134+
Ok(HttpStreamResponse {
135+
status,
136+
headers,
137+
body: Box::pin(body),
138+
})
139+
}
140+
104141
async fn execute_file(
105142
&self,
106143
req: HttpRequest,

crates/talon-gateway/Cargo.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,20 @@ description = "Shared bounded HTTP runtime for Talon object-store gateways"
99

1010
[dependencies]
1111
talon-core.workspace = true
12+
talon-backend = { path = "../talon-backend" }
13+
talon-cache-client.workspace = true
1214
async-trait.workspace = true
1315
axum.workspace = true
16+
bytes.workspace = true
1417
futures.workspace = true
1518
http-body-util.workspace = true
19+
httpdate.workspace = true
20+
percent-encoding.workspace = true
1621
serde.workspace = true
1722
serde_json.workspace = true
1823
thiserror.workspace = true
1924
tokio.workspace = true
2025
tower.workspace = true
2126
tower-http.workspace = true
2227
tracing.workspace = true
23-
24-
[dev-dependencies]
25-
bytes.workspace = true
28+
url.workspace = true

0 commit comments

Comments
 (0)