Skip to content

Commit 1fd007c

Browse files
authored
fix(s3,dynamodb,athena,route53,cloudwatch,ses,firehose): correct output/filter/pagination divergences (bug-hunt) (#2315)
2 parents 997826a + b014e90 commit 1fd007c

18 files changed

Lines changed: 791 additions & 69 deletions

File tree

crates/fakecloud-athena/src/service/mod.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,99 @@ mod tests {
12281228
serde_json::from_slice(resp.body.expect_bytes()).unwrap()
12291229
}
12301230

1231+
#[test]
1232+
fn get_query_results_paginates_large_result_set() {
1233+
// Regression: GetQueryResults honors MaxResults + NextToken. Before
1234+
// the fix it returned only the first MaxResults rows and emitted no
1235+
// NextToken, leaving the rest of a large result set unreachable.
1236+
let svc = AthenaService::new(SharedAthenaState::default());
1237+
let id = "qe-paginate";
1238+
{
1239+
let mut state = svc.state.write();
1240+
let account = super::account_mut(&mut state, "123456789012");
1241+
let now = Utc::now();
1242+
account.query_executions.insert(
1243+
id.to_string(),
1244+
crate::state::QueryExecution {
1245+
query_execution_id: id.to_string(),
1246+
query: "SELECT n FROM t".to_string(),
1247+
statement_type: "DML".to_string(),
1248+
work_group: "primary".to_string(),
1249+
state: "SUCCEEDED".to_string(),
1250+
state_change_reason: None,
1251+
submission_time: now,
1252+
completion_time: Some(now),
1253+
query_execution_context: None,
1254+
result_configuration: None,
1255+
engine_version: None,
1256+
data_scanned_bytes: 0,
1257+
engine_execution_time_ms: 1,
1258+
query_planning_time_ms: 1,
1259+
total_execution_time_ms: 2,
1260+
result_rows: (0..5).map(|n| vec![n.to_string()]).collect(),
1261+
result_columns: vec![("n".to_string(), "integer".to_string())],
1262+
},
1263+
);
1264+
}
1265+
1266+
let mut data_rows: Vec<String> = Vec::new();
1267+
let mut next: Option<String> = None;
1268+
let mut pages = 0;
1269+
loop {
1270+
pages += 1;
1271+
let mut body = json!({ "QueryExecutionId": id, "MaxResults": 3 });
1272+
if let Some(t) = &next {
1273+
body["NextToken"] = json!(t);
1274+
}
1275+
let resp = svc
1276+
.get_query_results(&req("GetQueryResults", body))
1277+
.unwrap();
1278+
let parsed = parse_json(&resp);
1279+
let rows = parsed["ResultSet"]["Rows"].as_array().unwrap();
1280+
// The column-header row is emitted only on the first page.
1281+
let start = if pages == 1 { 1 } else { 0 };
1282+
for row in &rows[start..] {
1283+
data_rows.push(row["Data"][0]["VarCharValue"].as_str().unwrap().to_string());
1284+
}
1285+
match parsed.get("NextToken").and_then(|v| v.as_str()) {
1286+
Some(t) => next = Some(t.to_string()),
1287+
None => break,
1288+
}
1289+
assert!(pages < 10, "pagination did not terminate");
1290+
}
1291+
1292+
assert!(pages >= 2, "large result set must span multiple pages");
1293+
assert_eq!(data_rows, vec!["0", "1", "2", "3", "4"]);
1294+
1295+
// MaxResults=1 (header consumes the only slot on page 1) must still
1296+
// terminate and surface every data row rather than loop forever.
1297+
let mut data_rows: Vec<String> = Vec::new();
1298+
let mut next: Option<String> = None;
1299+
let mut pages = 0;
1300+
loop {
1301+
pages += 1;
1302+
let mut body = json!({ "QueryExecutionId": id, "MaxResults": 1 });
1303+
if let Some(t) = &next {
1304+
body["NextToken"] = json!(t);
1305+
}
1306+
let resp = svc
1307+
.get_query_results(&req("GetQueryResults", body))
1308+
.unwrap();
1309+
let parsed = parse_json(&resp);
1310+
let rows = parsed["ResultSet"]["Rows"].as_array().unwrap();
1311+
let start = if pages == 1 { 1 } else { 0 };
1312+
for row in &rows[start..] {
1313+
data_rows.push(row["Data"][0]["VarCharValue"].as_str().unwrap().to_string());
1314+
}
1315+
match parsed.get("NextToken").and_then(|v| v.as_str()) {
1316+
Some(t) => next = Some(t.to_string()),
1317+
None => break,
1318+
}
1319+
assert!(pages < 100, "MaxResults=1 pagination did not terminate");
1320+
}
1321+
assert_eq!(data_rows, vec!["0", "1", "2", "3", "4"]);
1322+
}
1323+
12311324
#[test]
12321325
fn substitute_parameters_replaces_placeholders() {
12331326
let out = substitute_parameters(

crates/fakecloud-athena/src/service/queries.rs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,14 @@ impl AthenaService {
266266
) -> Result<AwsResponse, AwsServiceError> {
267267
let body = req.json_body();
268268
let id = require_str(&body, "QueryExecutionId")?;
269+
// Smithy: MaxResults targets MaxRowsCount @range(1,1000);
270+
// NextToken targets Token @length(1,1024).
269271
let max_results = validate_max_results(&body, 1, 1000)?;
272+
validate_opt_string_len(&body, "NextToken", 1, 1024)?;
273+
let next_token = body
274+
.get("NextToken")
275+
.and_then(Value::as_str)
276+
.map(str::to_owned);
270277
let mut state = self.state.write();
271278
let account = account_mut(&mut state, &req.account_id);
272279
let q = account
@@ -297,22 +304,58 @@ impl AthenaService {
297304
})
298305
})
299306
.collect();
300-
let header_row = json!({
301-
"Data": q.result_columns.iter().map(|(n, _)| json!({"VarCharValue": n})).collect::<Vec<_>>(),
302-
});
303-
let mut rows = vec![header_row];
304-
for row in q.result_rows.iter().take(max_results.saturating_sub(1)) {
307+
// Pagination: the header row (column names) is emitted only on the
308+
// first page (NextToken absent) and counts against MaxResults there.
309+
// The NextToken is a numeric offset into the data rows; subsequent
310+
// pages carry data rows only. Without this, result sets larger than
311+
// MaxResults were unreachable past page 1 (bug-hunt 2026-07-16).
312+
let offset: usize = match next_token.as_deref() {
313+
None => 0,
314+
Some(tok) => tok
315+
.parse()
316+
.map_err(|_| invalid_request("Invalid NextToken"))?,
317+
};
318+
let total = q.result_rows.len();
319+
let start = offset.min(total);
320+
// The column-header row is emitted only on the first invocation (no
321+
// NextToken supplied). Keying this on token absence rather than
322+
// `offset == 0` avoids an infinite loop when MaxResults is 1: the
323+
// first page returns only the header with NextToken "0", and the
324+
// resumed page must then advance past offset 0 rather than treating
325+
// itself as the first page again.
326+
let include_header = next_token.is_none();
327+
let data_budget = if include_header {
328+
max_results.saturating_sub(1)
329+
} else {
330+
max_results
331+
};
332+
let end = start.saturating_add(data_budget).min(total);
333+
334+
let mut rows = Vec::new();
335+
if include_header {
336+
rows.push(json!({
337+
"Data": q.result_columns.iter().map(|(n, _)| json!({"VarCharValue": n})).collect::<Vec<_>>(),
338+
}));
339+
}
340+
for row in &q.result_rows[start..end] {
305341
rows.push(json!({
306342
"Data": row.iter().map(|v| json!({"VarCharValue": v})).collect::<Vec<_>>(),
307343
}));
308344
}
309-
Ok(AwsResponse::ok_json(json!({
345+
let mut response = json!({
310346
"ResultSet": {
311347
"Rows": rows,
312348
"ResultSetMetadata": {"ColumnInfo": column_info},
313349
},
314350
"UpdateCount": 0,
315-
})))
351+
});
352+
if end < total {
353+
response
354+
.as_object_mut()
355+
.unwrap()
356+
.insert("NextToken".to_string(), Value::String(end.to_string()));
357+
}
358+
Ok(AwsResponse::ok_json(response))
316359
}
317360

318361
pub(super) fn get_query_runtime_statistics(

crates/fakecloud-cloudwatch/src/service.rs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,45 @@ pub(crate) fn parse_dimensions_query(req: &AwsRequest, prefix: &str) -> BTreeMap
532532
out
533533
}
534534

535+
/// Parse `{prefix}.member.N.Name` / `.Value` query params into ListMetrics'
536+
/// `DimensionFilter` list, where `Value` is OPTIONAL (per the Smithy model).
537+
/// A name-only filter matches any metric carrying a dimension with that name
538+
/// (any value); a name+value filter is an exact match.
539+
///
540+
/// Distinct from [`parse_dimensions_query`], which drops a name-only entry —
541+
/// correct for the put/statistics APIs (exact dimension sets) but wrong for
542+
/// ListMetrics, where a name-only filter must still narrow the results
543+
/// instead of silently returning every metric in the namespace.
544+
pub(crate) fn parse_dimension_filters(
545+
req: &AwsRequest,
546+
prefix: &str,
547+
) -> Vec<(String, Option<String>)> {
548+
let mut dims: BTreeMap<u32, (Option<String>, Option<String>)> = BTreeMap::new();
549+
let needle = format!("{prefix}.member.");
550+
for (k, v) in req.query_params.iter() {
551+
let Some(rest) = k.strip_prefix(&needle) else {
552+
continue;
553+
};
554+
let mut parts = rest.splitn(2, '.');
555+
let Some(idx_str) = parts.next() else {
556+
continue;
557+
};
558+
let Ok(idx) = idx_str.parse::<u32>() else {
559+
continue;
560+
};
561+
let field = parts.next().unwrap_or("");
562+
let entry = dims.entry(idx).or_default();
563+
match field {
564+
"Name" => entry.0 = Some(v.clone()),
565+
"Value" => entry.1 = Some(v.clone()),
566+
_ => {}
567+
}
568+
}
569+
dims.into_values()
570+
.filter_map(|(name, value)| name.map(|n| (n, value)))
571+
.collect()
572+
}
573+
535574
/// Validate the length of an optional string param against `[min, max]`.
536575
/// Returns a 4xx on violation. AWS measures length in characters; the
537576
/// conformance probe only sends ASCII so byte length is equivalent here.
@@ -954,7 +993,7 @@ impl CloudWatchService {
954993
validate_enum(req, "RecentlyActive", &["PT3H"])?;
955994
let namespace = optional_query_param(req, "Namespace");
956995
let metric_name = optional_query_param(req, "MetricName");
957-
let dim_filter = parse_dimensions_query(req, "Dimensions");
996+
let dim_filter = parse_dimension_filters(req, "Dimensions");
958997
// ListMetrics has no MaxResults param — AWS caps each page at 500 and
959998
// round-trips a NextToken.
960999
const LIST_METRICS_PAGE: usize = 500;
@@ -981,13 +1020,14 @@ impl CloudWatchService {
9811020
}
9821021
}
9831022
// ListMetrics filters by dimension containment (a metric
984-
// matches if it carries all the requested name/value
985-
// pairs), unlike the exact-set match used by the
986-
// statistics APIs.
1023+
// matches if it carries all the requested filters),
1024+
// unlike the exact-set match used by the statistics
1025+
// APIs. A name-only DimensionFilter matches any value.
9871026
if !dim_filter.is_empty()
988-
&& !dim_filter
989-
.iter()
990-
.all(|(k, v)| d.dimensions.get(k) == Some(v))
1027+
&& !dim_filter.iter().all(|(k, v)| match v {
1028+
Some(val) => d.dimensions.get(k) == Some(val),
1029+
None => d.dimensions.contains_key(k),
1030+
})
9911031
{
9921032
continue;
9931033
}

crates/fakecloud-cloudwatch/src/tests.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,56 @@ async fn put_metric_data_json_and_query_roundtrip() {
825825
assert!(body_of(&xml_list).contains("<MetricName>Latency</MetricName>"));
826826
}
827827

828+
#[tokio::test]
829+
async fn list_metrics_name_only_dimension_filter_narrows_results() {
830+
// Regression: a DimensionFilter with only a Name (no Value) must still
831+
// narrow the results. Before the fix such a filter was dropped and every
832+
// metric in the namespace was returned.
833+
let svc = service();
834+
call_json(
835+
&svc,
836+
"PutMetricData",
837+
serde_json::json!({
838+
"Namespace": "NS",
839+
"MetricData": [{
840+
"MetricName": "M1",
841+
"Value": 1.0,
842+
"Dimensions": [{"Name": "Host", "Value": "a"}]
843+
}]
844+
}),
845+
)
846+
.await;
847+
call_json(
848+
&svc,
849+
"PutMetricData",
850+
serde_json::json!({
851+
"Namespace": "NS",
852+
"MetricData": [{
853+
"MetricName": "M2",
854+
"Value": 2.0,
855+
"Dimensions": [{"Name": "Region", "Value": "us"}]
856+
}]
857+
}),
858+
)
859+
.await;
860+
861+
let resp = call(
862+
&svc,
863+
"ListMetrics",
864+
&[("Namespace", "NS"), ("Dimensions.member.1.Name", "Host")],
865+
)
866+
.await;
867+
let body = body_of(&resp);
868+
assert!(
869+
body.contains("<MetricName>M1</MetricName>"),
870+
"M1 (has dimension Host) expected: {body}"
871+
);
872+
assert!(
873+
!body.contains("<MetricName>M2</MetricName>"),
874+
"M2 (no Host dimension) must be filtered out: {body}"
875+
);
876+
}
877+
828878
#[tokio::test]
829879
async fn get_metric_statistics_json_roundtrip() {
830880
let svc = service();

crates/fakecloud-dynamodb/src/service/queries.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,21 @@ impl DynamoDbService {
134134
let mut matched: Vec<&HashMap<String, AttributeValue>> = items_to_scan
135135
.iter()
136136
.filter(|item| {
137+
// Sparse index: an item only appears in a GSI/LSI when it
138+
// carries every one of that index's key attributes. AWS never
139+
// returns (or counts) an item missing the index hash/range key
140+
// on an index query, so skip those before evaluating the key
141+
// condition (mirrors the scan() guard, bug-hunt 2026-07-16).
142+
if index_name.is_some() {
143+
if !item.contains_key(hash_key_name.as_str()) {
144+
return false;
145+
}
146+
if let Some(ref rk) = range_key_name {
147+
if !item.contains_key(rk.as_str()) {
148+
return false;
149+
}
150+
}
151+
}
137152
evaluate_key_condition(&key_condition, item, &expr_attr_names, &expr_attr_values)
138153
})
139154
.collect();

0 commit comments

Comments
 (0)