Skip to content

Commit 289a319

Browse files
authored
test(gateway): add S3 SDK conformance (#488)
1 parent c4b75a3 commit 289a319

5 files changed

Lines changed: 563 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ jobs:
345345
fi
346346
347347
s3-e2e:
348-
name: s3 backend e2e (localstack)
348+
name: s3 backend and gateway e2e (localstack)
349349
runs-on: ubuntu-latest
350350
# Exercises the real S3Backend (SigV4 signing + path-style endpoint) against
351351
# LocalStack, reading bytes an object actually contains. The test skips when
@@ -368,10 +368,16 @@ jobs:
368368
TALON_S3_TEST_BUCKET: talon-e2e
369369
TALON_S3_TEST_KEY: e2e/object.bin
370370
TALON_S3_TEST_REGION: us-east-1
371+
TALON_S3_SDK_PYTHON: python
371372
steps:
372373
- uses: actions/checkout@v4
374+
- uses: actions/setup-python@v5
375+
with:
376+
python-version: "3.11"
373377
- name: Install protoc
374378
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
379+
- name: Install S3 client libraries
380+
run: python -m pip install boto3==1.40.0 minio==7.2.16
375381
- name: Seed the bucket with a deterministic object
376382
run: |
377383
# 4096 bytes where byte i == i % 251 (matches the test's expectation).
@@ -382,8 +388,10 @@ jobs:
382388
aws --endpoint-url http://127.0.0.1:4566 s3 mb s3://talon-e2e
383389
aws --endpoint-url http://127.0.0.1:4566 s3 cp /tmp/object.bin s3://talon-e2e/e2e/object.bin
384390
- uses: Swatinem/rust-cache@v2
385-
- name: Run the S3 e2e test
391+
- name: Run the S3 backend e2e test
386392
run: cargo test -p talon-backend --test s3_e2e --locked -- --nocapture
393+
- name: Run the S3 SDK gateway conformance test
394+
run: cargo test -p talon-gateway --test s3_sdk_e2e --locked -- --nocapture
387395

388396
gcs-e2e:
389397
name: gcs backend e2e (fake-gcs)

crates/talon-gateway/src/s3.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,26 @@ impl S3RequestError {
682682
}
683683
}
684684

685+
fn not_found() -> Self {
686+
Self {
687+
status: StatusCode::NOT_FOUND,
688+
code: "NoSuchKey",
689+
message: "The specified key does not exist".into(),
690+
failure: FailureReason::NotFound,
691+
content_range: None,
692+
}
693+
}
694+
695+
fn precondition_failed() -> Self {
696+
Self {
697+
status: StatusCode::PRECONDITION_FAILED,
698+
code: "PreconditionFailed",
699+
message: "At least one precondition failed".into(),
700+
failure: FailureReason::Precondition,
701+
content_range: None,
702+
}
703+
}
704+
685705
fn origin_unavailable(message: impl Into<String>) -> Self {
686706
let _ = message.into();
687707
Self {
@@ -898,6 +918,12 @@ impl S3Adapter {
898918
.head(&object, &conditions)
899919
.await
900920
.map_err(S3RequestError::origin_unavailable)?;
921+
if metadata.status == 404 {
922+
return Err(S3RequestError::not_found());
923+
}
924+
if metadata.status == 412 {
925+
return Err(S3RequestError::precondition_failed());
926+
}
901927
if !(200..300).contains(&metadata.status) {
902928
return Ok(raw_response(
903929
metadata,
@@ -1071,6 +1097,8 @@ impl S3Adapter {
10711097
#[cfg(test)]
10721098
mod tests {
10731099
use super::*;
1100+
use std::collections::VecDeque;
1101+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10741102
use std::sync::Mutex;
10751103

10761104
use axum::body::to_bytes;
@@ -1090,11 +1118,57 @@ mod tests {
10901118
Err(CacheReadError::Unavailable(message)) => {
10911119
Err(CacheReadError::Unavailable(message.clone()))
10921120
}
1121+
Err(CacheReadError::Timeout(message)) => {
1122+
Err(CacheReadError::Timeout(message.clone()))
1123+
}
1124+
Err(CacheReadError::Protocol(message)) => {
1125+
Err(CacheReadError::Protocol(message.clone()))
1126+
}
10931127
_ => unreachable!("test cache response"),
10941128
}
10951129
}
10961130
}
10971131

1132+
struct DropSignal(Arc<AtomicBool>);
1133+
1134+
impl Drop for DropSignal {
1135+
fn drop(&mut self) {
1136+
self.0.store(true, Ordering::SeqCst);
1137+
}
1138+
}
1139+
1140+
struct DemandCache {
1141+
polls: Arc<AtomicUsize>,
1142+
dropped: Arc<AtomicBool>,
1143+
}
1144+
1145+
impl DemandCache {
1146+
fn new() -> Arc<Self> {
1147+
Arc::new(Self {
1148+
polls: Arc::new(AtomicUsize::new(0)),
1149+
dropped: Arc::new(AtomicBool::new(false)),
1150+
})
1151+
}
1152+
}
1153+
1154+
impl S3Cache for DemandCache {
1155+
fn stream(&self, _request: S3CacheRequest<'_>) -> Result<CacheStream, CacheReadError> {
1156+
let chunks = VecDeque::from([Bytes::from_static(b"abc"), Bytes::from_static(b"def")]);
1157+
let polls = Arc::clone(&self.polls);
1158+
let guard = DropSignal(Arc::clone(&self.dropped));
1159+
Ok(Box::pin(futures::stream::unfold(
1160+
(chunks, guard),
1161+
move |(mut chunks, guard)| {
1162+
let polls = Arc::clone(&polls);
1163+
async move {
1164+
polls.fetch_add(1, Ordering::SeqCst);
1165+
chunks.pop_front().map(|chunk| (Ok(chunk), (chunks, guard)))
1166+
}
1167+
},
1168+
)))
1169+
}
1170+
}
1171+
10981172
struct MockOrigin {
10991173
lists: Mutex<Vec<ListRequest>>,
11001174
}
@@ -1171,6 +1245,40 @@ mod tests {
11711245
}
11721246
}
11731247

1248+
struct UnavailableOrigin;
1249+
1250+
#[async_trait]
1251+
impl S3Origin for UnavailableOrigin {
1252+
async fn head(
1253+
&self,
1254+
_object: &ObjectId,
1255+
_conditions: &[(String, String)],
1256+
) -> Result<HttpResponse, String> {
1257+
Err("connect failed at https://s3.example/?secret=credential".into())
1258+
}
1259+
1260+
async fn get(
1261+
&self,
1262+
_object: &ObjectId,
1263+
_range: Option<(u64, u64)>,
1264+
_conditions: &[(String, String)],
1265+
) -> Result<HttpStreamResponse, String> {
1266+
unreachable!("HEAD failure must stop GET dispatch")
1267+
}
1268+
1269+
async fn list(
1270+
&self,
1271+
_bucket: &str,
1272+
_prefix: &str,
1273+
_delimiter: Option<&str>,
1274+
_continuation_token: Option<&str>,
1275+
_max_keys: u32,
1276+
_encoding_type: Option<&str>,
1277+
) -> Result<HttpResponse, String> {
1278+
unreachable!("test only dispatches object reads")
1279+
}
1280+
}
1281+
11741282
fn adapter(cache: MockCache, origin: Arc<MockOrigin>) -> S3Adapter {
11751283
S3Adapter::new(
11761284
S3AdapterConfig::path_style("localhost"),
@@ -1278,6 +1386,78 @@ mod tests {
12781386
);
12791387
}
12801388

1389+
#[tokio::test]
1390+
async fn timeout_falls_back_but_protocol_failure_is_terminal() {
1391+
let timeout = adapter(
1392+
MockCache {
1393+
response: Err(CacheReadError::Timeout("slow worker".into())),
1394+
},
1395+
MockOrigin::new(),
1396+
)
1397+
.handle(request(axum::http::Method::GET, "/bucket/key"), context())
1398+
.await;
1399+
assert_eq!(timeout.response.status(), StatusCode::OK);
1400+
assert_eq!(timeout.outcome, GatewayOutcome::Fallback);
1401+
1402+
let protocol = adapter(
1403+
MockCache {
1404+
response: Err(CacheReadError::Protocol("bad frame".into())),
1405+
},
1406+
MockOrigin::new(),
1407+
)
1408+
.handle(request(axum::http::Method::GET, "/bucket/key"), context())
1409+
.await;
1410+
assert_eq!(
1411+
protocol.response.status(),
1412+
StatusCode::INTERNAL_SERVER_ERROR
1413+
);
1414+
assert_eq!(protocol.outcome, GatewayOutcome::Failed);
1415+
}
1416+
1417+
#[tokio::test]
1418+
async fn cache_stream_respects_backpressure_and_cancellation() {
1419+
let cache = DemandCache::new();
1420+
let response = S3Adapter::new(
1421+
S3AdapterConfig::path_style("localhost"),
1422+
Arc::clone(&cache) as Arc<dyn S3Cache>,
1423+
MockOrigin::new(),
1424+
)
1425+
.unwrap()
1426+
.handle(request(axum::http::Method::GET, "/bucket/key"), context())
1427+
.await;
1428+
1429+
assert_eq!(cache.polls.load(Ordering::SeqCst), 1);
1430+
tokio::task::yield_now().await;
1431+
assert_eq!(cache.polls.load(Ordering::SeqCst), 1);
1432+
let mut body = response.response.into_body().into_data_stream();
1433+
assert_eq!(
1434+
body.next().await.unwrap().unwrap(),
1435+
Bytes::from_static(b"abc")
1436+
);
1437+
assert_eq!(cache.polls.load(Ordering::SeqCst), 1);
1438+
drop(body);
1439+
assert!(cache.dropped.load(Ordering::SeqCst));
1440+
}
1441+
1442+
#[tokio::test]
1443+
async fn origin_outage_returns_a_sanitized_s3_error() {
1444+
let response = S3Adapter::new(
1445+
S3AdapterConfig::path_style("localhost"),
1446+
Arc::new(MockCache {
1447+
response: Ok(Vec::new()),
1448+
}),
1449+
Arc::new(UnavailableOrigin),
1450+
)
1451+
.unwrap()
1452+
.handle(request(axum::http::Method::GET, "/bucket/key"), context())
1453+
.await;
1454+
1455+
assert_eq!(response.response.status(), StatusCode::SERVICE_UNAVAILABLE);
1456+
let body = to_bytes(response.response.into_body(), 4096).await.unwrap();
1457+
assert!(!body.windows(6).any(|window| window == b"secret"));
1458+
assert!(!body.windows(10).any(|window| window == b"credential"));
1459+
}
1460+
12811461
#[tokio::test]
12821462
async fn forwards_bounded_list_v2_parameters() {
12831463
let origin = MockOrigin::new();

0 commit comments

Comments
 (0)