Skip to main content

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}