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)]
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}