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, Deserialize)]
28#[non_exhaustive]
29pub struct BatchStatementRequest {
30 #[serde(rename = "Statement")]
31 pub statement: String,
32 #[serde(rename = "Parameters", default)]
33 pub parameters: Option<Vec<AttributeValue>>,
34 /// Per member, not per batch. Does not change which rows come back, because
35 /// every read against SQLite is already strongly consistent, but it does
36 /// change the rate the read is charged at: a keyed batch `SELECT` costs 0.5
37 /// without it and 1 with it, and a batch mixing the two sums both rates.
38 /// Captured eu-west-2 2026-08-15.
39 #[serde(rename = "ConsistentRead", default)]
40 pub consistent_read: Option<bool>,
41 /// Accepted and inert, which is what DynamoDB does with it. A batch member
42 /// whose condition fails returns the same response whether this is
43 /// `ALL_OLD`, `NONE`, or absent, and never carries the item: measured
44 /// against a `TransactWriteItems` `ConditionCheck` in the same round, which
45 /// does return it. The field is deserialised so a client setting it meets a
46 /// field dynoxide knows rather than one it drops.
47 #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
48 pub return_values_on_condition_check_failure: Option<String>,
49}
50
51#[derive(Debug, Default, Serialize)]
52pub struct BatchExecuteStatementResponse {
53 #[serde(rename = "Responses")]
54 pub responses: Vec<BatchStatementResponse>,
55 /// Per-table capacity, aggregated across the statements in the batch. No
56 /// transactional factor applies. Absent when no mode was asked for, and also
57 /// when every statement failed, which is what AWS does.
58 #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
59 pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
60}
61
62#[derive(Debug, Default, Serialize)]
63#[non_exhaustive]
64pub struct BatchStatementResponse {
65 #[serde(rename = "Error", skip_serializing_if = "Option::is_none")]
66 pub error: Option<BatchStatementError>,
67 #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
68 pub item: Option<Item>,
69 /// The member statement's target table, echoed on a successful response
70 /// (with or without an `Item`), matching DynamoDB. Omitted on a
71 /// per-statement error and when the statement fails to parse.
72 #[serde(rename = "TableName", skip_serializing_if = "Option::is_none")]
73 pub table_name: Option<String>,
74}
75
76#[derive(Debug, Default, Serialize)]
77pub struct BatchStatementError {
78 #[serde(rename = "Code")]
79 pub code: String,
80 #[serde(rename = "Message")]
81 pub message: String,
82}
83
84pub async fn execute<S: StorageBackend>(
85 storage: &S,
86 request: BatchExecuteStatementRequest,
87) -> Result<BatchExecuteStatementResponse> {
88 if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
89 request.return_consumed_capacity.as_deref(),
90 ) {
91 return Err(DynoxideError::ValidationException(
92 crate::validation::envelope_message(&msg),
93 ));
94 }
95
96 if request.statements.is_empty() {
97 return Err(DynoxideError::ValidationException(
98 "1 validation error detected: Value '[]' at 'statements' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string(),
99 ));
100 }
101
102 if request.statements.len() > 25 {
103 return Err(DynoxideError::ValidationException(
104 "Too many statements in BatchExecuteStatement; limit is 25".to_string(),
105 ));
106 }
107
108 // Parse once, and carry the resolved target alongside. Statements that fail
109 // to parse keep their error for the per-statement path below: a batch
110 // holding one is not rejected outright, it reports `ValidationError` against
111 // that member and runs the rest. Parse failure and an unresolvable target
112 // are different things, which is why they are a `Result` and an `Option`
113 // rather than one fallible value.
114 let (prepared, tables) = prepare(storage, &request.statements).await;
115
116 // A batch is all-read or all-write. AWS rejects a mixed one up front,
117 // before any statement runs, rather than per statement. A statement that
118 // did not parse takes no part in the classification, and its presence does
119 // not stop the check firing on the ones that did.
120 let kinds = prepared.iter().filter_map(|p| p.stmt.as_ref().ok());
121 let (reads, writes): (Vec<_>, Vec<_>) =
122 kinds.partition(|stmt| matches!(stmt, partiql::parser::Statement::Select { .. }));
123 if !reads.is_empty() && !writes.is_empty() {
124 return Err(DynoxideError::ValidationException(
125 "Read and write requests together in the same batch is not supported.".to_string(),
126 ));
127 }
128
129 // Two statements against one item are rejected up front, reads included. A
130 // statement whose key cannot be read off the request has no target and is
131 // left to fail on its own terms below.
132 let mut seen_targets = HashSet::new();
133 for target in prepared.iter().filter_map(|p| p.target.as_ref()) {
134 if !seen_targets.insert(target) {
135 return Err(DynoxideError::ValidationException(
136 "Provided list of item keys contains duplicates".to_string(),
137 ));
138 }
139 }
140
141 let mut responses = Vec::with_capacity(request.statements.len());
142 let mut records: Vec<WriteCapacity> = Vec::new();
143 // Table, bytes read, and whether that member asked for a consistent read.
144 let mut read_units: Vec<(String, usize, bool)> = Vec::new();
145 let mut failures: Vec<(String, f64)> = Vec::new();
146
147 for (stmt_req, prepared) in request.statements.iter().zip(prepared) {
148 // A COUNT projection is rejected before parsing with the bare message
149 // captured on ExecuteStatement, carried here under the same
150 // per-statement ValidationError code a parse failure uses.
151 if let Some(msg) = partiql::parser::count_projection_rejection(&stmt_req.statement) {
152 responses.push(BatchStatementResponse {
153 error: Some(BatchStatementError {
154 code: "ValidationError".to_string(),
155 message: msg,
156 }),
157 item: None,
158 table_name: None,
159 });
160 continue;
161 }
162
163 let response = match prepared.stmt {
164 Err(e) => BatchStatementResponse {
165 error: Some(BatchStatementError {
166 // A per-statement parse failure carries the short-form
167 // `ValidationError` code, the same as an execution error,
168 // matching DynamoDB.
169 code: "ValidationError".to_string(),
170 message: e.into_message(),
171 }),
172 item: None,
173 table_name: None,
174 },
175 Ok(stmt) => {
176 // DynamoDB echoes the target table on a successful response, but
177 // not on a per-statement error.
178 let table = partiql::parser::table_name(&stmt).map(str::to_string);
179 let params = stmt_req.parameters.as_deref().unwrap_or_default();
180 // A batch read must name a single item. A SELECT that does not
181 // resolve to one, or that names an index, is rejected against
182 // itself while the rest of the batch runs. Both shapes carry the
183 // same message, so an index-qualified read is unreachable here
184 // even when it does name the primary key. Captured eu-west-2
185 // 2026-08-15.
186 //
187 // Only for a table that resolved. A statement has no target
188 // either when its WHERE names no key or when its table could not
189 // be read, and the second is not this rejection: an INSERT or a
190 // DELETE against a table that does not exist reports
191 // ResourceNotFound with the table echoed, and a SELECT must say
192 // the same rather than claim the key is missing when it is
193 // there. Letting it through to `execute_page` is what produces
194 // that, because table resolution is the first thing a SELECT
195 // does.
196 let table_resolved = table.as_deref().is_some_and(|t| tables.contains_key(t));
197 let unkeyed_read = matches!(stmt, partiql::parser::Statement::Select { .. })
198 && table_resolved
199 && (prepared.target.is_none() || partiql::parser::index_name(&stmt).is_some());
200 if unkeyed_read {
201 responses.push(BatchStatementResponse {
202 error: Some(BatchStatementError {
203 code: "ValidationError".to_string(),
204 message: "Select statements within BatchExecuteStatement must \
205 specify the primary key in the where clause."
206 .to_string(),
207 }),
208 item: None,
209 table_name: None,
210 });
211 continue;
212 }
213 let run = partiql::executor::execute_page(
214 storage,
215 &stmt,
216 params,
217 None,
218 None,
219 stmt_req.consistent_read.unwrap_or(false),
220 request.return_consumed_capacity.as_deref(),
221 table.as_deref().and_then(|t| tables.get(t)),
222 );
223 // One transaction per statement, not per batch: a batch is not
224 // atomic across its statements, so each one commits or rolls
225 // back on its own, base write and index fan-out together.
226 let outcome = if matches!(stmt, partiql::parser::Statement::Select { .. }) {
227 run.await
228 } else {
229 super::helpers::with_write_transaction(storage, run).await
230 };
231 match outcome {
232 Ok(page) => {
233 match page.capacity {
234 Some(capacity) => records.push(capacity),
235 // A SELECT is charged read units against the rows it
236 // walked, with no index arm on a base table read.
237 None => {
238 if let Some(ref name) = table {
239 read_units.push((
240 name.clone(),
241 page.size,
242 stmt_req.consistent_read.unwrap_or(false),
243 ));
244 }
245 }
246 }
247 // A SELECT yields its row here; a DELETE/UPDATE carrying a
248 // RETURNING clause yields the returned item. Batch surfaces
249 // a single item per statement, so take the first.
250 BatchStatementResponse {
251 error: None,
252 item: page.items.and_then(|items| items.into_iter().next()),
253 table_name: table,
254 }
255 }
256 Err(e) => {
257 // A failed statement is charged the write it attempted,
258 // added to its table's total while appearing in no arm.
259 // Captured: a failing 3KB insert beside a small success
260 // reports total 5 against arms summing to 2, and three
261 // failures of 1, 1 and 3 units report total 7.
262 //
263 // Only an INSERT carries its item in the statement, so
264 // that is the one kind whose attempt can be sized
265 // without reading anything. Everything else falls back
266 // to the one-unit minimum.
267 if let Some(ref name) = table {
268 let units =
269 attempted_units(storage, &stmt, params, prepared.target.as_ref())
270 .await;
271 failures.push((name.clone(), units));
272 }
273 // DynamoDB echoes the table on a member whose statement
274 // ran and failed, and omits it on one rejected before it
275 // ran. `ConditionalCheckFailed` and `DuplicateItem` both
276 // carry it; a `ValidationError` does not, which is what
277 // an invalid RETURNING variant or a bad expression is.
278 // Captured eu-west-2 2026-08-15 for the first pair and
279 // 2026-07 for the second.
280 let code = e.short_error_code().to_string();
281 let echoes_table = code != "ValidationError";
282 BatchStatementResponse {
283 error: Some(BatchStatementError {
284 code,
285 message: e.to_string(),
286 }),
287 item: None,
288 table_name: if echoes_table { table } else { None },
289 }
290 }
291 }
292 }
293 };
294
295 responses.push(response);
296 }
297
298 Ok(BatchExecuteStatementResponse {
299 consumed_capacity: build_capacity(
300 &records,
301 &read_units,
302 &failures,
303 &request.return_consumed_capacity,
304 ),
305 responses,
306 })
307}
308
309/// One statement, parsed once and resolved once.
310struct Prepared {
311 /// The parse result. An `Err` is a per-statement error, not a request-level
312 /// one: AWS reports `ValidationError` against that member and runs the rest.
313 stmt: std::result::Result<partiql::parser::Statement, partiql::parser::ParseError>,
314 /// The item this statement targets, for duplicate detection. `None` when it
315 /// does not resolve to one, which covers a partition-spanning `SELECT` and
316 /// anything whose key cannot be read off the request.
317 target: Option<(String, String, String)>,
318}
319
320/// Parse every statement and resolve its target, once each.
321///
322/// The statement text was previously parsed three times per call: to classify
323/// reads against writes, to find duplicate targets, and to execute. Measured on
324/// a 25-statement batch, each pass costs about as much as everything the target
325/// resolution does, so the parsing was the larger half of the overhead by more
326/// than two to one.
327///
328/// Target resolution loads the table's metadata and parses its key schema, and
329/// both are now kept rather than thrown away once the target is read off them.
330/// The executor takes them as they are instead of resolving the same table a
331/// second time, which halves the metadata loads a batch performs. That is worth
332/// most on the wasm backend, where every load crosses the bridge to a JS worker
333/// and nothing caches the result, but the key schema half is a JSON parse and is
334/// paid on both backends.
335async fn prepare<S: StorageBackend>(
336 storage: &S,
337 statements: &[BatchStatementRequest],
338) -> (Vec<Prepared>, HashMap<String, ResolvedTable>) {
339 let mut prepared = Vec::with_capacity(statements.len());
340 // Keyed by table, not by statement: a batch is usually 25 statements
341 // against one table, and that is one resolution rather than 25.
342 let mut tables: HashMap<String, ResolvedTable> = HashMap::new();
343
344 for stmt_req in statements {
345 let parsed = partiql::parser::parse(&stmt_req.statement);
346 let mut target = None;
347 if let Ok(ref stmt) = parsed
348 && let Some(name) = partiql::parser::table_name(stmt)
349 {
350 if !tables.contains_key(name)
351 && let Ok(resolved) = ResolvedTable::load(storage, name).await
352 {
353 tables.insert(name.to_string(), resolved);
354 }
355 // Absent when the table could not be read, which leaves the
356 // statement to fail on its own terms during execution.
357 if let Some(resolved) = tables.get(name) {
358 let params = stmt_req.parameters.as_deref().unwrap_or_default();
359 target = partiql::executor::statement_target_in(stmt, params, resolved);
360 }
361 }
362 prepared.push(Prepared {
363 stmt: parsed,
364 target,
365 });
366 }
367 (prepared, tables)
368}
369
370/// The write units a failed statement is charged.
371///
372/// Sized on the larger of the item already stored at the target and the item
373/// the statement carried, which is the same rule a successful write follows.
374///
375/// The stored side is load-bearing rather than incidental. Captured: a batch of
376/// three failing inserts alongside one success reports 7 against arms summing
377/// to 2. Two of the failures name tiny items and cost 1 each; the third names a
378/// tiny item too, but the row already at that key is 3KB, and it costs 3. Sizing
379/// on the statement alone reports 5.
380/// `target` is the one the preparation pass already resolved. Sizing used to
381/// resolve it again here, which made a batch of failures cost three metadata
382/// loads per statement rather than two.
383async fn attempted_units<S: StorageBackend>(
384 storage: &S,
385 stmt: &partiql::parser::Statement,
386 parameters: &[AttributeValue],
387 target: Option<&(String, String, String)>,
388) -> f64 {
389 let stored_size = match target {
390 Some((table, pk, sk)) => storage
391 .get_item(table, pk, sk)
392 .await
393 .ok()
394 .flatten()
395 .and_then(|json| serde_json::from_str::<Item>(&json).ok())
396 .map(|item| crate::types::item_size(&item)),
397 None => None,
398 };
399
400 // Only an INSERT carries its item in the statement text.
401 let attempted_size = match stmt {
402 partiql::parser::Statement::Insert { item, .. } => {
403 let mut resolved = Item::new();
404 for (name, value) in item {
405 let av = match value {
406 partiql::parser::PartiqlValue::Literal(av) => av.clone(),
407 // An unresolvable parameter is why the statement failed, so
408 // size on what did resolve.
409 partiql::parser::PartiqlValue::Parameter(idx) => match parameters.get(*idx) {
410 Some(av) => av.clone(),
411 None => continue,
412 },
413 };
414 resolved.insert(name.clone(), av);
415 }
416 Some(crate::types::item_size(&resolved))
417 }
418 _ => None,
419 };
420
421 crate::types::table_write_capacity_units(stored_size, attempted_size)
422}
423
424/// Fold the batch into one `ConsumedCapacity` per table.
425///
426/// No transactional factor applies. A batch in which nothing succeeded reports
427/// no capacity at all, which is what AWS does even though the same failure
428/// counts for a unit when another statement in the batch succeeds.
429fn build_capacity(
430 records: &[WriteCapacity],
431 read_units: &[(String, usize, bool)],
432 failures: &[(String, f64)],
433 mode: &Option<String>,
434) -> Option<Vec<crate::types::ConsumedCapacity>> {
435 if !matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
436 return None;
437 }
438 if records.is_empty() && read_units.is_empty() {
439 return None;
440 }
441
442 let mut by_table = aggregate_by_table(records, 1.0);
443 for (table, size, consistent) in read_units {
444 by_table.entry(table.clone()).or_default().table_units +=
445 crate::types::read_capacity_units_with_consistency(*size, *consistent);
446 }
447
448 let mut entries = per_table_capacity(
449 &by_table,
450 mode,
451 crate::types::consumed_capacity_with_secondary_indexes,
452 )?;
453
454 // The surcharge is applied here rather than inside the shared fold, because
455 // it is the one rule of the three that differs and it has already been got
456 // wrong once. A failed statement lands on the total without reaching the
457 // Table arm or any index arm, so it cannot be folded into the units the
458 // builder sees. A table whose statements all failed has no entry to attach
459 // it to and gets none: captured against a two-table batch where the failing
460 // table was omitted entirely and only the succeeding one was reported.
461 let mut surcharge: HashMap<&str, f64> = HashMap::new();
462 for (table, units) in failures {
463 *surcharge.entry(table.as_str()).or_default() += units;
464 }
465 for entry in &mut entries {
466 entry.capacity_units += surcharge
467 .get(entry.table_name.as_str())
468 .copied()
469 .unwrap_or(0.0);
470 }
471
472 Some(entries)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::storage::Storage;
479 use crate::types::AttributeValue;
480
481 fn s(v: &str) -> AttributeValue {
482 AttributeValue::S(v.to_string())
483 }
484
485 async fn seed(storage: &Storage) {
486 let req = serde_json::from_value(serde_json::json!({
487 "TableName": "pq_btx",
488 "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
489 "AttributeDefinitions": [
490 {"AttributeName": "pk", "AttributeType": "S"},
491 {"AttributeName": "g1", "AttributeType": "S"},
492 {"AttributeName": "g2", "AttributeType": "S"}
493 ],
494 "BillingMode": "PAY_PER_REQUEST",
495 "GlobalSecondaryIndexes": [
496 {
497 "IndexName": "gsi-one",
498 "KeySchema": [{"AttributeName": "g1", "KeyType": "HASH"}],
499 "Projection": {"ProjectionType": "ALL"}
500 },
501 {
502 "IndexName": "gsi-two",
503 "KeySchema": [{"AttributeName": "g2", "KeyType": "HASH"}],
504 "Projection": {"ProjectionType": "ALL"}
505 }
506 ]
507 }))
508 .unwrap();
509 crate::actions::create_table::execute(storage, req)
510 .await
511 .unwrap();
512 for pk in ["a", "b"] {
513 let put = crate::actions::put_item::PutItemRequest {
514 table_name: "pq_btx".to_string(),
515 item: [
516 ("pk".to_string(), s(pk)),
517 ("g1".to_string(), s("x")),
518 ("g2".to_string(), s("y")),
519 ("val".to_string(), s("before")),
520 ]
521 .into_iter()
522 .collect(),
523 ..Default::default()
524 };
525 crate::actions::put_item::execute(storage, put)
526 .await
527 .unwrap();
528 }
529 }
530
531 async fn val_of(storage: &Storage, pk: &str) -> String {
532 let req = crate::actions::get_item::GetItemRequest {
533 table_name: "pq_btx".to_string(),
534 key: [("pk".to_string(), s(pk))].into_iter().collect(),
535 ..Default::default()
536 };
537 match crate::actions::get_item::execute(storage, req)
538 .await
539 .unwrap()
540 .item
541 .unwrap()
542 .get("val")
543 .unwrap()
544 {
545 AttributeValue::S(v) => v.clone(),
546 other => panic!("unexpected val: {other:?}"),
547 }
548 }
549
550 fn stmt(statement: &str) -> BatchStatementRequest {
551 BatchStatementRequest {
552 statement: statement.to_string(),
553 ..Default::default()
554 }
555 }
556
557 #[tokio::test]
558 async fn a_failed_statement_rolls_back_without_taking_its_neighbour_with_it() {
559 // The transaction is per statement, not per batch. A batch is not
560 // atomic across its statements on DynamoDB, so the one that fails must
561 // roll its own fan-out back and leave the one that succeeded standing.
562 let storage = Storage::memory().unwrap();
563 seed(&storage).await;
564
565 // The first statement's fan-out runs clean (two inserts), the second
566 // gets one insert in and fails on the other.
567 storage.fail_gsi_insert_after(3);
568 let out = execute(
569 &storage,
570 BatchExecuteStatementRequest {
571 statements: vec![
572 stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='a'"),
573 stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='b'"),
574 ],
575 ..Default::default()
576 },
577 )
578 .await
579 .unwrap();
580
581 assert!(
582 out.responses[0].error.is_none(),
583 "the first statement should stand: {:?}",
584 out.responses[0].error
585 );
586 assert_eq!(
587 val_of(&storage, "a").await,
588 "after",
589 "a committed statement must survive its neighbour failing"
590 );
591 assert_eq!(
592 val_of(&storage, "b").await,
593 "before",
594 "the failed statement must roll its own base row back"
595 );
596 }
597}