dynoxide/actions/batch_execute_statement.rs
1use crate::actions::index_capacity::{WriteCapacity, aggregate_by_table, per_table_capacity};
2use crate::errors::{DynoxideError, Result};
3use crate::partiql;
4use crate::partiql::executor::ResolvedTable;
5use crate::storage_backend::StorageBackend;
6use crate::types::{AttributeValue, Item};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10#[derive(Debug, Default, Deserialize)]
11pub struct BatchExecuteStatementRequest {
12 #[serde(rename = "Statements")]
13 pub statements: Vec<BatchStatementRequest>,
14 #[serde(rename = "ReturnConsumedCapacity", default)]
15 pub return_consumed_capacity: Option<String>,
16}
17
18/// One member of a batch.
19///
20/// `#[non_exhaustive]` because DynamoDB grows this shape and has already grown
21/// it twice here: `ConsistentRead` and `ReturnValuesOnConditionCheckFailure`
22/// both arrived after the type was first written. The attribute means the next
23/// one costs consumers nothing. Construct one from `Default` and assign, or
24/// deserialise it. The enclosing `BatchExecuteStatementRequest` is deliberately
25/// not marked: it carries `Statements` and `ReturnConsumedCapacity`, which is
26/// the whole of DynamoDB's shape.
27#[derive(Debug, Default)]
28#[non_exhaustive]
29pub struct BatchStatementRequest {
30 pub statement: String,
31 pub parameters: Option<Vec<AttributeValue>>,
32 /// Per member, not per batch. Does not change which rows come back, because
33 /// every read against SQLite is already strongly consistent, but it does
34 /// change the rate the read is charged at: a keyed batch `SELECT` costs 0.5
35 /// without it and 1 with it, and a batch mixing the two sums both rates.
36 /// Captured eu-west-2 2026-08-15.
37 pub consistent_read: Option<bool>,
38 /// `ALL_OLD` hands back the item this member's UPDATE or DELETE failed its
39 /// condition on, on the member's `Error`; `NONE`, or leaving it out, does
40 /// not (captured eu-west-2 and us-east-1, 2026-10-10, which corrects a
41 /// 2026-08-15 measurement that found no item either way).
42 pub return_values_on_condition_check_failure: Option<String>,
43 /// A parameter DynamoDB refused as the request was decoded, such as a
44 /// number it can't store. DynamoDB reports it against this statement and
45 /// runs the rest (captured eu-west-2 and us-east-1, 2026-10-09), so the
46 /// refusal is carried here rather than failing the whole request.
47 pub(crate) parameter_rejection: Option<String>,
48}
49
50impl<'de> Deserialize<'de> for BatchStatementRequest {
51 fn deserialize<D: serde::Deserializer<'de>>(
52 deserializer: D,
53 ) -> std::result::Result<Self, D::Error> {
54 #[derive(Deserialize)]
55 struct Wire {
56 #[serde(rename = "Statement")]
57 statement: String,
58 #[serde(rename = "Parameters", default)]
59 parameters: Option<Vec<serde_json::Value>>,
60 #[serde(rename = "ConsistentRead", default)]
61 consistent_read: Option<bool>,
62 #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
63 return_values_on_condition_check_failure: Option<String>,
64 }
65 let wire = Wire::deserialize(deserializer)?;
66 let mut parameter_rejection = None;
67 let parameters = match wire.parameters {
68 None => None,
69 Some(raw) => {
70 let mut parameters = Vec::with_capacity(raw.len());
71 const EMPTY: &str = "Supplied AttributeValue is empty, must contain exactly \
72 one of the supported datatypes";
73 for value in raw {
74 if value.as_object().is_some_and(|fields| fields.is_empty()) {
75 parameter_rejection.get_or_insert_with(|| EMPTY.to_string());
76 continue;
77 }
78 match crate::types::reading_parameters(|| {
79 serde_json::from_value::<AttributeValue>(value)
80 }) {
81 Ok(parameter) => parameters.push(parameter),
82 // A value refusal belongs to this statement, an empty
83 // value among them, and `{"NULL": false}`, which is
84 // raised in the request-validation class (captured
85 // eu-west-2 and us-east-1, 2026-10-10: only its
86 // statement fails); anything else, a malformed value,
87 // still fails the request.
88 Err(e) => {
89 let message = e.to_string();
90 // `VALIDATION_REQUEST:` extends `VALIDATION`, but
91 // not `VALIDATION:`, so both are checked.
92 if !message.starts_with(crate::serde_errors::VALIDATION_MARKER)
93 && !message
94 .starts_with(crate::serde_errors::REQUEST_VALIDATION_MARKER)
95 && !message.starts_with(EMPTY)
96 {
97 return Err(serde::de::Error::custom(message));
98 }
99 parameter_rejection.get_or_insert_with(|| {
100 crate::serde_errors::clean_serde_message(&message).to_string()
101 });
102 }
103 }
104 }
105 Some(parameters)
106 }
107 };
108 Ok(Self {
109 statement: wire.statement,
110 parameters,
111 consistent_read: wire.consistent_read,
112 return_values_on_condition_check_failure: wire.return_values_on_condition_check_failure,
113 parameter_rejection,
114 })
115 }
116}
117
118#[derive(Debug, Default, Serialize)]
119pub struct BatchExecuteStatementResponse {
120 #[serde(rename = "Responses")]
121 pub responses: Vec<BatchStatementResponse>,
122 /// Per-table capacity, aggregated across the statements in the batch. No
123 /// transactional factor applies. Absent when no mode was asked for, and also
124 /// when every statement failed, which is what AWS does.
125 #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
126 pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
127}
128
129#[derive(Debug, Default, Serialize)]
130#[non_exhaustive]
131pub struct BatchStatementResponse {
132 #[serde(rename = "Error", skip_serializing_if = "Option::is_none")]
133 pub error: Option<BatchStatementError>,
134 #[serde(
135 rename = "Item",
136 skip_serializing_if = "Option::is_none",
137 serialize_with = "crate::set_order::item_in_hash_order"
138 )]
139 // Number sets in Java hash-set order here, as DynamoDB returns them;
140 // pinned by set_order's every_response_item_field test.
141 pub item: Option<Item>,
142 /// The member statement's target table, echoed on a successful response
143 /// (with or without an `Item`), matching DynamoDB. Omitted on a
144 /// per-statement error and when the statement fails to parse.
145 #[serde(rename = "TableName", skip_serializing_if = "Option::is_none")]
146 pub table_name: Option<String>,
147}
148
149#[derive(Debug, Default, Serialize)]
150pub struct BatchStatementError {
151 #[serde(rename = "Code")]
152 pub code: String,
153 #[serde(rename = "Message")]
154 pub message: String,
155 /// The item a failed condition was evaluated against, when the member set
156 /// `ReturnValuesOnConditionCheckFailure` to `ALL_OLD`.
157 #[serde(
158 rename = "Item",
159 skip_serializing_if = "Option::is_none",
160 serialize_with = "crate::set_order::item_in_hash_order"
161 )]
162 // Number sets in Java hash-set order here, as DynamoDB returns them;
163 // pinned by set_order's every_response_item_field test.
164 pub item: Option<Item>,
165}
166
167/// The statement list as DynamoDB echoes it in a constraint message: each
168/// request's Java `toString`, in us-east-1's form, captured 2026-10-10.
169/// eu-west-2 printed `BatchStatementRequest@` and a hash in its place, the same
170/// hash for each of the 26 identical statements captured, so it is derived from
171/// the value; no obvious Java formula reproduces it, so us-east-1's form is the
172/// one followed. A member's parameters follow the same Java model, uncaptured
173/// (the capture's statements had none). Bounded by
174/// [`crate::actions::batch_write_item::ECHO_LIMIT`].
175fn statements_echo(statements: &[BatchStatementRequest]) -> String {
176 let mut out = crate::actions::batch_write_item::BoundedEcho::new();
177 let _ = write_statements(&mut out, statements);
178 out.finish()
179}
180
181fn write_statements(
182 out: &mut dyn std::fmt::Write,
183 statements: &[BatchStatementRequest],
184) -> std::fmt::Result {
185 out.write_str("[")?;
186 for (i, s) in statements.iter().enumerate() {
187 if i > 0 {
188 out.write_str(", ")?;
189 }
190 write!(
191 out,
192 "BatchStatementRequest(statement={}, parameters=",
193 s.statement
194 )?;
195 match s.parameters {
196 Some(ref parameters) => {
197 out.write_str("[")?;
198 for (j, parameter) in parameters.iter().enumerate() {
199 if j > 0 {
200 out.write_str(", ")?;
201 }
202 crate::actions::batch_write_item::write_java_attribute_value(out, parameter)?;
203 }
204 out.write_str("]")?;
205 }
206 None => out.write_str("null")?,
207 }
208 out.write_str(", consistentRead=")?;
209 match s.consistent_read {
210 Some(c) => write!(out, "{c}")?,
211 None => out.write_str("null")?,
212 }
213 out.write_str(", returnValuesOnConditionCheckFailure=")?;
214 out.write_str(
215 s.return_values_on_condition_check_failure
216 .as_deref()
217 .unwrap_or("null"),
218 )?;
219 out.write_str(")")?;
220 }
221 out.write_str("]")
222}
223
224pub async fn execute<S: StorageBackend>(
225 storage: &S,
226 request: BatchExecuteStatementRequest,
227) -> Result<BatchExecuteStatementResponse> {
228 // Every member DynamoDB refuses is listed in one error: ReturnConsumedCapacity,
229 // then the statements (the list's length, then each statement's
230 // ReturnValuesOnConditionCheckFailure, text and parameters by index). Any
231 // of them refuses the whole request, before a statement is parsed or
232 // runs. Captured eu-west-2 and us-east-1, 2026-10-10: statement then
233 // parameters across members, every over-long statement listed,
234 // ReturnConsumedCapacity ahead of an over-long list and of a member's own
235 // violations, and a member's mode ahead of its statement.
236 let mut violations = Vec::new();
237 violations.extend(crate::validation::return_consumed_capacity_rejection(
238 request.return_consumed_capacity.as_deref(),
239 ));
240 if request.statements.is_empty() {
241 violations.push(
242 "Value '[]' at 'statements' failed to satisfy constraint: Member must have length \
243 greater than or equal to 1"
244 .to_string(),
245 );
246 }
247 if request.statements.len() > 25 {
248 violations.push(format!(
249 "Value '{}' at 'statements' failed to satisfy constraint: Member must have length \
250 less than or equal to 25",
251 statements_echo(&request.statements)
252 ));
253 }
254 for (i, statement) in request.statements.iter().enumerate() {
255 let member = format!("statements.{}.member", i + 1);
256 violations.extend(
257 crate::validation::return_values_on_condition_check_failure_rejection(
258 statement
259 .return_values_on_condition_check_failure
260 .as_deref(),
261 &format!("{member}.returnValuesOnConditionCheckFailure"),
262 ),
263 );
264 violations.extend(crate::partiql::statement_length_violation(
265 &statement.statement,
266 &format!("{member}.statement"),
267 ));
268 // A member whose every parameter was refused as it was decoded holds
269 // an empty list too, but that is its own statement's refusal, not an
270 // empty list sent.
271 let sent = statement
272 .parameters
273 .as_deref()
274 .filter(|_| statement.parameter_rejection.is_none());
275 violations.extend(crate::partiql::parameters_violation(
276 sent,
277 &format!("{member}.parameters"),
278 ));
279 }
280 if let Some(e) = crate::partiql::member_rejection(&violations) {
281 return Err(e);
282 }
283
284 // Parse once, and carry the resolved target alongside. Statements that fail
285 // to parse keep their error for the per-statement path below: a batch
286 // holding one is not rejected outright, it reports `ValidationError` against
287 // that member and runs the rest. Parse failure and an unresolvable target
288 // are different things, which is why they are a `Result` and an `Option`
289 // rather than one fallible value.
290 let (prepared, tables) = prepare(storage, &request.statements).await;
291
292 // A statement refused for its own parameters, or for anything else about
293 // it alone, fails on its own and takes no part in the batch-wide checks
294 // below, as one that does not parse doesn't (captured eu-west-2 and
295 // us-east-1, 2026-10-09, for a parameter nested 63 levels and for an empty
296 // attribute name in a parameter; inferred for the other refusals here).
297 let own_refusals: Vec<Option<String>> = request.statements.iter().map(own_refusal).collect();
298 let counted = || {
299 prepared
300 .iter()
301 .zip(&own_refusals)
302 .filter(|(_, refusal)| refusal.is_none())
303 .map(|(p, _)| p)
304 };
305
306 // A batch is all-read or all-write. AWS rejects a mixed one up front,
307 // before any statement runs, rather than per statement. A statement that
308 // did not parse takes no part in the classification, and its presence does
309 // not stop the check firing on the ones that did.
310 let kinds = counted().filter_map(|p| p.stmt.as_ref().ok());
311 let (reads, writes): (Vec<_>, Vec<_>) =
312 kinds.partition(|stmt| matches!(stmt, partiql::parser::Statement::Select { .. }));
313 if !reads.is_empty() && !writes.is_empty() {
314 return Err(DynoxideError::ValidationException(
315 "Read and write requests together in the same batch is not supported.".to_string(),
316 ));
317 }
318
319 // Two statements against one item are rejected up front, reads included. A
320 // statement whose key cannot be read off the request has no target and is
321 // left to fail on its own terms below.
322 let mut seen_targets = HashSet::new();
323 for target in counted().filter_map(|p| p.target.as_ref()) {
324 if !seen_targets.insert(target) {
325 return Err(DynoxideError::ValidationException(
326 "Provided list of item keys contains duplicates".to_string(),
327 ));
328 }
329 }
330
331 let mut responses = Vec::with_capacity(request.statements.len());
332 let mut records: Vec<WriteCapacity> = Vec::new();
333 // Table, bytes read, and whether that member asked for a consistent read.
334 let mut read_units: Vec<(String, usize, bool)> = Vec::new();
335 let mut failures: Vec<(String, f64)> = Vec::new();
336
337 for ((stmt_req, prepared), own_refusal) in
338 request.statements.iter().zip(prepared).zip(own_refusals)
339 {
340 if let Some(message) = own_refusal {
341 responses.push(BatchStatementResponse {
342 error: Some(BatchStatementError {
343 code: "ValidationError".to_string(),
344 message,
345 item: None,
346 }),
347 item: None,
348 table_name: None,
349 });
350 continue;
351 }
352
353 let response = match prepared.stmt {
354 Err(e) => BatchStatementResponse {
355 error: Some(BatchStatementError {
356 // A per-statement parse failure carries the short-form
357 // `ValidationError` code, the same as an execution error,
358 // matching DynamoDB.
359 code: "ValidationError".to_string(),
360 message: e.into_message(),
361 item: None,
362 }),
363 item: None,
364 table_name: None,
365 },
366 Ok(stmt) => {
367 // DynamoDB echoes the target table on a successful response, but
368 // not on a per-statement error.
369 let table = partiql::parser::table_name(&stmt).map(str::to_string);
370 let params = stmt_req.parameters.as_deref().unwrap_or_default();
371 // A batch read must name a single item. A SELECT that does not
372 // resolve to one, or that names an index, is rejected against
373 // itself while the rest of the batch runs. Both shapes carry the
374 // same message, so an index-qualified read is unreachable here
375 // even when it does name the primary key. Captured eu-west-2
376 // 2026-08-15.
377 //
378 // Only for a table that resolved. A statement has no target
379 // either when its WHERE names no key or when its table could not
380 // be read, and the second is not this rejection: an INSERT or a
381 // DELETE against a table that does not exist reports
382 // ResourceNotFound with the table echoed, and a SELECT must say
383 // the same rather than claim the key is missing when it is
384 // there. Letting it through to `execute_written_page` is what
385 // produces that, because table resolution is the first thing a
386 // SELECT does.
387 //
388 // Overlapping key conditions are refused first, and a key of the
389 // wrong type is reported in ExecuteStatement's words (captured
390 // eu-west-2 and us-east-1, 2026-10-09).
391 let resolved_table = table.as_deref().and_then(|t| tables.get(t));
392 let unkeyed = || {
393 "Select statements within BatchExecuteStatement must specify the \
394 primary key in the where clause."
395 .to_string()
396 };
397 let refusal = match resolved_table {
398 Some(resolved) if matches!(stmt, partiql::parser::Statement::Select { .. }) => {
399 if let Some(index) = partiql::parser::index_name(&stmt) {
400 // A multi-attribute index is refused in its own
401 // words first (captured eu-west-2 and us-east-1,
402 // 2026-10-09).
403 Some(
404 partiql::executor::reject_multi_attribute_index(resolved, index)
405 .err()
406 .map_or_else(unkeyed, |e| e.to_string()),
407 )
408 } else if let Err(e) =
409 partiql::executor::reject_overlapping_select(&stmt, params, resolved)
410 {
411 Some(e.to_string())
412 } else {
413 match partiql::executor::keyed_select(&stmt, params, resolved) {
414 partiql::executor::KeyedSelect::ByPrimaryKey => None,
415 partiql::executor::KeyedSelect::NotByPrimaryKey => Some(unkeyed()),
416 partiql::executor::KeyedSelect::KeyType(message) => Some(message),
417 }
418 }
419 }
420 _ => None,
421 };
422 if let Some(message) = refusal {
423 responses.push(BatchStatementResponse {
424 error: Some(BatchStatementError {
425 code: "ValidationError".to_string(),
426 message,
427 item: None,
428 }),
429 item: None,
430 table_name: None,
431 });
432 continue;
433 }
434 let run = partiql::executor::execute_written_page(
435 storage,
436 &stmt_req.statement,
437 &stmt,
438 params,
439 None,
440 None,
441 stmt_req.consistent_read.unwrap_or(false),
442 request.return_consumed_capacity.as_deref(),
443 table.as_deref().and_then(|t| tables.get(t)),
444 stmt_req.return_values_on_condition_check_failure.as_deref() == Some("ALL_OLD"),
445 );
446 // One transaction per statement, not per batch: a batch is not
447 // atomic across its statements, so each one commits or rolls
448 // back on its own, base write and index fan-out together.
449 let outcome = if matches!(stmt, partiql::parser::Statement::Select { .. }) {
450 run.await
451 } else {
452 super::helpers::with_write_transaction(storage, run).await
453 };
454 match outcome {
455 Ok(page) => {
456 match page.capacity {
457 Some(capacity) => records.push(capacity),
458 // A SELECT is charged read units against the rows it
459 // walked, with no index arm on a base table read.
460 None => {
461 if let Some(ref name) = table {
462 read_units.push((
463 name.clone(),
464 page.size,
465 stmt_req.consistent_read.unwrap_or(false),
466 ));
467 }
468 }
469 }
470 // A SELECT yields its row here; a DELETE/UPDATE carrying a
471 // RETURNING clause yields the returned item. Batch surfaces
472 // a single item per statement, so take the first.
473 BatchStatementResponse {
474 error: None,
475 item: page.items.and_then(|items| items.into_iter().next()),
476 table_name: table,
477 }
478 }
479 Err(e) => {
480 let code = e.short_error_code().to_string();
481 let message = e.to_string();
482 // Present only when the member asked for it.
483 let item = match e {
484 DynoxideError::ConditionalCheckFailedException(_, item) => item,
485 _ => None,
486 };
487 // A key too large to be one is refused before anything
488 // is read or written, and is charged nothing (captured
489 // eu-west-2 and us-east-1, 2026-10-10). So is an UPDATE
490 // of a key attribute (refused before the item is read,
491 // captured eu-west-2 and us-east-1, 10 October 2026;
492 // that it is charged nothing is inferred).
493 let refused_before_reading = code == "ValidationError"
494 && (message == crate::actions::helpers::HASH_KEY_TOO_LARGE
495 || message == crate::actions::helpers::RANGE_KEY_TOO_LARGE
496 || message
497 .starts_with(crate::actions::helpers::KEY_ATTRIBUTE_UPDATE));
498 // A failed statement is charged the write it attempted,
499 // added to its table's total while appearing in no arm.
500 // Captured: a failing 3KB insert beside a small success
501 // reports total 5 against arms summing to 2, and three
502 // failures of 1, 1 and 3 units report total 7.
503 //
504 // Only an INSERT carries its item in the statement, so
505 // that is the one kind whose attempt can be sized
506 // without reading anything. Everything else falls back
507 // to the one-unit minimum.
508 if let Some(ref name) = table
509 && !refused_before_reading
510 {
511 let units =
512 attempted_units(storage, &stmt, params, prepared.target.as_ref())
513 .await;
514 failures.push((name.clone(), units));
515 }
516 // DynamoDB echoes the table on a member whose statement
517 // ran and failed, and omits it on one rejected before it
518 // ran. `ConditionalCheckFailed` and `DuplicateItem` both
519 // carry it; a `ValidationError` does not, which is what
520 // an invalid RETURNING variant or a bad expression is.
521 // Captured eu-west-2 2026-08-15 for the first pair and
522 // 2026-07 for the second. A key too large to be one is
523 // the exception: DynamoDB echoes the table with that
524 // ValidationError, for a partition key and a sort key
525 // alike (captured eu-west-2 and us-east-1, 2026-10-10),
526 // and with the refusal of an UPDATE of a key attribute
527 // (captured eu-west-2 and us-east-1, 10 October 2026).
528 let echoes_table = code != "ValidationError" || refused_before_reading;
529 BatchStatementResponse {
530 error: Some(BatchStatementError {
531 code,
532 message,
533 item,
534 }),
535 item: None,
536 table_name: if echoes_table { table } else { None },
537 }
538 }
539 }
540 }
541 };
542
543 responses.push(response);
544 }
545
546 Ok(BatchExecuteStatementResponse {
547 consumed_capacity: build_capacity(
548 &records,
549 &read_units,
550 &failures,
551 &request.return_consumed_capacity,
552 ),
553 responses,
554 })
555}
556
557/// One statement, parsed once and resolved once.
558struct Prepared {
559 /// The parse result. An `Err` is a per-statement error, not a request-level
560 /// one: AWS reports `ValidationError` against that member and runs the rest.
561 stmt: std::result::Result<partiql::parser::Statement, partiql::parser::ParseError>,
562 /// The item this statement targets, for duplicate detection. `None` when it
563 /// does not resolve to one, which covers a partition-spanning `SELECT` and
564 /// anything whose key cannot be read off the request.
565 target: Option<(String, String, String)>,
566}
567
568/// What fails a batch member on its own, before its statement runs: in this
569/// order, a parameter refused as it was decoded, one nested too deep (the
570/// nesting message ahead of the value checks), one DynamoDB can't store, a
571/// COUNT projection (refused before parsing with the bare message captured on
572/// ExecuteStatement) and an empty attribute name in a parameter. Each is
573/// reported against that member while the rest run (captured eu-west-2 and
574/// us-east-1, 2026-10-09, and the empty name in ten regions). That an
575/// unstorable value drops the member out of the read/write and duplicate
576/// checks is inferred, not captured. For a COUNT projection the exclusion
577/// changes nothing: COUNT doesn't parse, so the member was already outside both
578/// checks.
579fn own_refusal(stmt_req: &BatchStatementRequest) -> Option<String> {
580 if let Some(rejection) = &stmt_req.parameter_rejection {
581 return Some(rejection.clone());
582 }
583 let parameters = stmt_req.parameters.as_deref().unwrap_or_default();
584 if crate::value_nesting::parameters_nest_too_deep(parameters) {
585 return Some(crate::value_nesting::deep_parameter_error().to_string());
586 }
587 if let Err(e) = parameters
588 .iter()
589 .try_for_each(partiql::executor::check_parameter_value)
590 {
591 return Some(e.to_string());
592 }
593 if let Some(message) = partiql::parser::count_projection_rejection(&stmt_req.statement) {
594 return Some(message);
595 }
596 if crate::empty_names::parameters_have_empty_name(stmt_req.parameters.as_deref()) {
597 return Some(crate::empty_names::MESSAGE.to_string());
598 }
599 None
600}
601
602/// Parse every statement and resolve its target, once each.
603///
604/// The statement text was previously parsed three times per call: to classify
605/// reads against writes, to find duplicate targets, and to execute. Measured on
606/// a 25-statement batch, each pass costs about as much as everything the target
607/// resolution does, so the parsing was the larger half of the overhead by more
608/// than two to one.
609///
610/// Target resolution loads the table's metadata and parses its key schema, and
611/// both are now kept rather than thrown away once the target is read off them.
612/// The executor takes them as they are instead of resolving the same table a
613/// second time, which halves the metadata loads a batch performs. That is worth
614/// most on the wasm backend, where every load crosses the bridge to a JS worker
615/// and nothing caches the result, but the key schema half is a JSON parse and is
616/// paid on both backends.
617async fn prepare<S: StorageBackend>(
618 storage: &S,
619 statements: &[BatchStatementRequest],
620) -> (Vec<Prepared>, HashMap<String, ResolvedTable>) {
621 let mut prepared = Vec::with_capacity(statements.len());
622 // Keyed by table, not by statement: a batch is usually 25 statements
623 // against one table, and that is one resolution rather than 25.
624 let mut tables: HashMap<String, ResolvedTable> = HashMap::new();
625
626 for stmt_req in statements {
627 let supplied = stmt_req.parameters.as_ref().map_or(0, Vec::len);
628 let parsed = partiql::parser::parse_with_parameters(&stmt_req.statement, supplied).map_err(
629 |failure| match failure {
630 partiql::parser::ParseFailure::Statement(e) => e,
631 partiql::parser::ParseFailure::Literal(message) => {
632 partiql::parser::ParseError::Validation(message)
633 }
634 },
635 );
636 let mut target = None;
637 if let Ok(ref stmt) = parsed
638 && let Some(name) = partiql::parser::table_name(stmt)
639 {
640 if !tables.contains_key(name)
641 && let Ok(resolved) = ResolvedTable::load(storage, name).await
642 {
643 tables.insert(name.to_string(), resolved);
644 }
645 // Absent when the table could not be read, which leaves the
646 // statement to fail on its own terms during execution.
647 if let Some(resolved) = tables.get(name) {
648 let params = stmt_req.parameters.as_deref().unwrap_or_default();
649 target = partiql::executor::statement_target_in(stmt, params, resolved);
650 }
651 }
652 prepared.push(Prepared {
653 stmt: parsed,
654 target,
655 });
656 }
657 (prepared, tables)
658}
659
660/// The write units a failed statement is charged.
661///
662/// Sized on the larger of the item already stored at the target and the item
663/// the statement carried, which is the same rule a successful write follows.
664///
665/// The stored side is load-bearing rather than incidental. Captured: a batch of
666/// three failing inserts alongside one success reports 7 against arms summing
667/// to 2. Two of the failures name tiny items and cost 1 each; the third names a
668/// tiny item too, but the row already at that key is 3KB, and it costs 3. Sizing
669/// on the statement alone reports 5.
670/// `target` is the one the preparation pass already resolved. Sizing used to
671/// resolve it again here, which made a batch of failures cost three metadata
672/// loads per statement rather than two.
673async fn attempted_units<S: StorageBackend>(
674 storage: &S,
675 stmt: &partiql::parser::Statement,
676 parameters: &[AttributeValue],
677 target: Option<&(String, String, String)>,
678) -> f64 {
679 let stored_size = match target {
680 Some((table, pk, sk)) => storage
681 .get_item(table, pk, sk)
682 .await
683 .ok()
684 .flatten()
685 .and_then(|json| serde_json::from_str::<Item>(&json).ok())
686 .map(|item| crate::types::item_size(&item)),
687 None => None,
688 };
689
690 // Only an INSERT carries its item in the statement text.
691 let attempted_size = match stmt {
692 partiql::parser::Statement::Insert { item, .. } => {
693 let mut resolved = Item::new();
694 for (name, value) in item {
695 let av = match value {
696 partiql::parser::PartiqlValue::Literal(av) => av.clone(),
697 // An unresolvable parameter is why the statement failed, so
698 // size on what did resolve.
699 partiql::parser::PartiqlValue::Parameter(idx) => match parameters.get(*idx) {
700 Some(av) => av.clone(),
701 None => continue,
702 },
703 };
704 resolved.insert(name.clone(), av);
705 }
706 Some(crate::types::item_size(&resolved))
707 }
708 _ => None,
709 };
710
711 crate::types::table_write_capacity_units(stored_size, attempted_size)
712}
713
714/// Fold the batch into one `ConsumedCapacity` per table.
715///
716/// No transactional factor applies. A batch in which nothing succeeded reports
717/// no capacity at all, which is what AWS does even though the same failure
718/// counts for a unit when another statement in the batch succeeds.
719fn build_capacity(
720 records: &[WriteCapacity],
721 read_units: &[(String, usize, bool)],
722 failures: &[(String, f64)],
723 mode: &Option<String>,
724) -> Option<Vec<crate::types::ConsumedCapacity>> {
725 if !matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
726 return None;
727 }
728 if records.is_empty() && read_units.is_empty() {
729 return None;
730 }
731
732 let mut by_table = aggregate_by_table(records, 1.0);
733 for (table, size, consistent) in read_units {
734 by_table.entry(table.clone()).or_default().table_units +=
735 crate::types::read_capacity_units_with_consistency(*size, *consistent);
736 }
737
738 let mut entries = per_table_capacity(
739 &by_table,
740 mode,
741 crate::types::consumed_capacity_with_secondary_indexes,
742 )?;
743
744 // The surcharge is applied here rather than inside the shared fold, because
745 // it is the one rule of the three that differs and it has already been got
746 // wrong once. A failed statement lands on the total without reaching the
747 // Table arm or any index arm, so it cannot be folded into the units the
748 // builder sees. A table whose statements all failed has no entry to attach
749 // it to and gets none: captured against a two-table batch where the failing
750 // table was omitted entirely and only the succeeding one was reported.
751 let mut surcharge: HashMap<&str, f64> = HashMap::new();
752 for (table, units) in failures {
753 *surcharge.entry(table.as_str()).or_default() += units;
754 }
755 for entry in &mut entries {
756 entry.capacity_units += surcharge
757 .get(entry.table_name.as_str())
758 .copied()
759 .unwrap_or(0.0);
760 }
761
762 Some(entries)
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::storage::Storage;
769 use crate::types::AttributeValue;
770
771 fn s(v: &str) -> AttributeValue {
772 AttributeValue::S(v.to_string())
773 }
774
775 async fn seed(storage: &Storage) {
776 let req = serde_json::from_value(serde_json::json!({
777 "TableName": "pq_btx",
778 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
779 "AttributeDefinitions": [
780 {"AttributeName": "pk", "AttributeType": "S"},
781 {"AttributeName": "g1", "AttributeType": "S"},
782 {"AttributeName": "g2", "AttributeType": "S"}
783 ],
784 "BillingMode": "PAY_PER_REQUEST",
785 "GlobalSecondaryIndexes": [
786 {
787 "IndexName": "gsi-one",
788 "KeySchema": [{"AttributeName": "g1", "KeyType": "HASH"}],
789 "Projection": {"ProjectionType": "ALL"}
790 },
791 {
792 "IndexName": "gsi-two",
793 "KeySchema": [{"AttributeName": "g2", "KeyType": "HASH"}],
794 "Projection": {"ProjectionType": "ALL"}
795 }
796 ]
797 }))
798 .unwrap();
799 crate::actions::create_table::execute(storage, req)
800 .await
801 .unwrap();
802 for pk in ["a", "b"] {
803 let put = crate::actions::put_item::PutItemRequest {
804 table_name: "pq_btx".to_string(),
805 item: [
806 ("pk".to_string(), s(pk)),
807 ("g1".to_string(), s("x")),
808 ("g2".to_string(), s("y")),
809 ("val".to_string(), s("before")),
810 ]
811 .into_iter()
812 .collect(),
813 ..Default::default()
814 };
815 crate::actions::put_item::execute(storage, put)
816 .await
817 .unwrap();
818 }
819 }
820
821 async fn val_of(storage: &Storage, pk: &str) -> String {
822 let req = crate::actions::get_item::GetItemRequest {
823 table_name: "pq_btx".to_string(),
824 key: [("pk".to_string(), s(pk))].into_iter().collect(),
825 ..Default::default()
826 };
827 match crate::actions::get_item::execute(storage, req)
828 .await
829 .unwrap()
830 .item
831 .unwrap()
832 .get("val")
833 .unwrap()
834 {
835 AttributeValue::S(v) => v.clone(),
836 other => panic!("unexpected val: {other:?}"),
837 }
838 }
839
840 fn stmt(statement: &str) -> BatchStatementRequest {
841 BatchStatementRequest {
842 statement: statement.to_string(),
843 ..Default::default()
844 }
845 }
846
847 #[tokio::test]
848 async fn a_failed_statement_rolls_back_without_taking_its_neighbour_with_it() {
849 // The transaction is per statement, not per batch. A batch is not
850 // atomic across its statements on DynamoDB, so the one that fails must
851 // roll its own fan-out back and leave the one that succeeded standing.
852 let storage = Storage::memory().unwrap();
853 seed(&storage).await;
854
855 // The first statement's fan-out runs clean (two inserts), the second
856 // gets one insert in and fails on the other.
857 storage.fail_gsi_insert_after(3);
858 let out = execute(
859 &storage,
860 BatchExecuteStatementRequest {
861 statements: vec![
862 stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='a'"),
863 stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='b'"),
864 ],
865 ..Default::default()
866 },
867 )
868 .await
869 .unwrap();
870
871 assert!(
872 out.responses[0].error.is_none(),
873 "the first statement should stand: {:?}",
874 out.responses[0].error
875 );
876 assert_eq!(
877 val_of(&storage, "a").await,
878 "after",
879 "a committed statement must survive its neighbour failing"
880 );
881 assert_eq!(
882 val_of(&storage, "b").await,
883 "before",
884 "the failed statement must roll its own base row back"
885 );
886 }
887}