diff --git a/crates/core/src/expression/mod.rs b/crates/core/src/expression/mod.rs index 4ef721471..c3b345d8a 100755 --- a/crates/core/src/expression/mod.rs +++ b/crates/core/src/expression/mod.rs @@ -20,6 +20,7 @@ mod resolver; mod search_condition; mod tokenizer; mod update_evaluator; +mod update_nesting; mod update_parser; pub use ast::{ArithOp, CompareOp, Expr, PathElement, UpdateAction, removes_only}; @@ -43,5 +44,5 @@ pub use search_condition::{ validate_search_condition_expression, }; pub use tokenizer::{Token, tokenize, tokenize_for, tokenize_with_limit}; -pub use update_evaluator::apply_update_validated; +pub use update_evaluator::{apply_update_item_validated, apply_update_validated}; pub use update_parser::{parse_update, parse_update_from}; diff --git a/crates/core/src/expression/update_evaluator.rs b/crates/core/src/expression/update_evaluator.rs index 52f2a149b..fb8a2193b 100755 --- a/crates/core/src/expression/update_evaluator.rs +++ b/crates/core/src/expression/update_evaluator.rs @@ -87,12 +87,80 @@ pub(crate) fn apply_update( /// not reject an unrelated update to an item carrying a pre-existing invalid /// value (measured 2026-08-14), and whole-image validation would make items /// the backfill deliberately skipped permanently un-updatable. +/// +/// Returns `ValidationException` `Nesting Levels have exceeded supported +/// limits` when an attribute that a `SET` or an `ADD` writes nests past the +/// limit, before the vector check. pub fn apply_update_validated( actions: &[UpdateAction], item: &mut BTreeMap, maps: &ExpressionMaps, vector_indexes: &[VectorIndexKeyInfo], attribute_definitions: &[AttributeDefinition], +) -> Result<(), DynamoDbError> { + apply_update_checked( + actions, + item, + maps, + vector_indexes, + attribute_definitions, + None, + ) +} + +/// Apply the actions of a standalone `UpdateItem`, validate the result as +/// [`apply_update_validated`] does, and size the update as +/// [`crate::validation::validate_update_item_size`] does. +/// +/// The service sizes the statement before it checks the nesting depth of the +/// updated item, and sizes the updated item after that check (measured +/// us-east-1, 2026-11-10). An update whose statement is over the limit is +/// refused for its size, whatever the depth. An update whose statement is +/// within the limit and whose result nests too deep gets the depth refusal, +/// even when the updated item is over the limit. Only these orders are +/// measured, so the statement is sized ahead of the depth check only when that +/// check refuses. The order of the depth check against the vector check is not +/// measured, and the vector check keeps its place after the depth check and +/// ahead of the size check. A PartiQL `UPDATE` (`maps.finished_item_sizing`) +/// is sized by its updated item alone. The figure of the legacy +/// `AttributeUpdates` form (`maps.legacy_attribute_updates`) is computed from +/// the finished values of the attributes it names. The statement check does +/// not apply to either form, so their checks run in the order of +/// [`apply_update_validated`]. +/// +/// # Errors +/// +/// The errors of [`apply_update_validated`], and `ValidationException` `Item +/// size to update has exceeded the maximum allowed size` when the update is +/// over `max_item_size_bytes`. +pub fn apply_update_item_validated( + actions: &[UpdateAction], + item: &mut BTreeMap, + maps: &ExpressionMaps, + vector_indexes: &[VectorIndexKeyInfo], + attribute_definitions: &[AttributeDefinition], + max_item_size_bytes: usize, +) -> Result<(), DynamoDbError> { + apply_update_checked( + actions, + item, + maps, + vector_indexes, + attribute_definitions, + Some(max_item_size_bytes), + )?; + crate::validation::validate_update_item_size(item, actions, maps, max_item_size_bytes) +} + +/// [`apply_update_validated`]. With `statement_limit`, a depth refusal gives +/// way to the size refusal of a statement over that limit. +fn apply_update_checked( + actions: &[UpdateAction], + item: &mut BTreeMap, + maps: &ExpressionMaps, + vector_indexes: &[VectorIndexKeyInfo], + attribute_definitions: &[AttributeDefinition], + statement_limit: Option, ) -> Result<(), DynamoDbError> { // Snapshot only the vector-relevant attributes (the vector attribute and // the search-schema attributes of each index) rather than cloning the @@ -113,6 +181,12 @@ pub fn apply_update_validated( } } apply_update(actions, item, maps)?; + if let Err(depth) = super::update_nesting::validate_written_nesting_depth(actions, item, maps) { + if let Some(max_bytes) = statement_limit { + crate::validation::validate_update_statement_size(actions, maps, max_bytes)?; + } + return Err(depth); + } crate::validation::validate_vector_write_changed( item, &before, diff --git a/crates/core/src/expression/update_evaluator_tests.rs b/crates/core/src/expression/update_evaluator_tests.rs index 968e6d356..bb2a3955b 100755 --- a/crates/core/src/expression/update_evaluator_tests.rs +++ b/crates/core/src/expression/update_evaluator_tests.rs @@ -660,3 +660,141 @@ fn set_parenthesised_arithmetic() { apply("SET c = (c - :v)", &mut item, HashMap::new(), values).unwrap(); assert_eq!(item.get("c"), Some(&AttributeValue::N("7".into()))); } + +/// The size checks of a standalone `UpdateItem` on either side of the depth +/// check (measured us-east-1, 2026-11-10). +mod update_item_sizing { + use super::*; + use crate::validation::ITEM_SIZE_TO_UPDATE_EXCEEDED; + + const LIMIT: usize = 409_600; + const DEPTH: &str = "Nesting Levels have exceeded supported limits"; + + fn nmap(wrappers: usize) -> AttributeValue { + let mut value = AttributeValue::S("leaf".to_owned()); + for _ in 0..wrappers { + value = AttributeValue::M(BTreeMap::from([("k".to_owned(), value)])); + } + value + } + + fn text(bytes: usize) -> AttributeValue { + AttributeValue::S("a".repeat(bytes)) + } + + /// An item whose `m` holds 30 wrappers, beside a string of `stored` bytes. + fn item(stored: usize) -> BTreeMap { + BTreeMap::from([ + ("pk".to_owned(), AttributeValue::S("k1".to_owned())), + ("m".to_owned(), nmap(30)), + ("stored".to_owned(), text(stored)), + ]) + } + + /// `SET #added = :added` of `added` bytes and a SET of 2 wrappers at `depth` + /// keys below `m`, in the order `depth_first` gives. + fn update( + added: usize, + depth: usize, + depth_first: bool, + ) -> (Vec, ExpressionMaps) { + let deep = format!("#m{} = :v", ".k".repeat(depth)); + let sets = if depth_first { + format!("SET {deep}, #added = :added") + } else { + format!("SET #added = :added, {deep}") + }; + let maps = ExpressionMaps::new( + HashMap::from([ + ("m".to_owned(), "m".to_owned()), + ("added".to_owned(), "added".to_owned()), + ]), + HashMap::from([("v".to_owned(), nmap(2)), ("added".to_owned(), text(added))]), + ); + (parse_update(&tokenize(&sets).unwrap()).unwrap(), maps) + } + + fn message(result: Result<(), DynamoDbError>) -> String { + match result { + Err(DynamoDbError::ValidationException(message)) => message, + other => panic!("expected a ValidationException, got {other:?}"), + } + } + + /// The answer to the update on `item` as a standalone `UpdateItem`. + fn update_item( + mut item: BTreeMap, + added: usize, + depth: usize, + depth_first: bool, + ) -> String { + let (actions, maps) = update(added, depth, depth_first); + message(apply_update_item_validated( + &actions, + &mut item, + &maps, + &[], + &[], + LIMIT, + )) + } + + #[test] + fn a_statement_over_the_limit_is_refused_for_its_size_ahead_of_the_depth() { + for depth_first in [false, true] { + assert_eq!( + update_item(item(0), 409_500, 30, depth_first), + ITEM_SIZE_TO_UPDATE_EXCEEDED + ); + } + } + + #[test] + fn an_updated_item_over_the_limit_meets_the_depth_check_first() { + for depth_first in [false, true] { + assert_eq!(update_item(item(405_000), 8_000, 30, depth_first), DEPTH); + } + assert_eq!(update_item(item(300_000), 120_000, 30, false), DEPTH); + } + + #[test] + fn the_same_update_through_a_shallow_path_is_refused_for_its_size() { + assert_eq!( + update_item(item(405_000), 8_000, 28, false), + ITEM_SIZE_TO_UPDATE_EXCEEDED + ); + } + + #[test] + fn a_partiql_or_legacy_update_keeps_the_depth_refusal() { + // A PartiQL UPDATE is sized by its updated item alone, and the figure + // of the legacy AttributeUpdates form is computed from the finished + // values of the attributes it names, so the statement check does not + // apply to either. The expression's statement figure is over the + // limit, and the answer is the one apply_update_validated gives. + for partiql in [true, false] { + let (actions, mut maps) = update(409_500, 30, false); + maps.finished_item_sizing = partiql; + maps.legacy_attribute_updates = !partiql; + let plain = message(apply_update_validated( + &actions, + &mut item(0), + &maps, + &[], + &[], + )); + assert_eq!(plain, DEPTH); + assert_eq!( + message(apply_update_item_validated( + &actions, + &mut item(0), + &maps, + &[], + &[], + LIMIT + )), + plain + ); + } + } +} diff --git a/crates/core/src/expression/update_nesting.rs b/crates/core/src/expression/update_nesting.rs new file mode 100644 index 000000000..639be5abb --- /dev/null +++ b/crates/core/src/expression/update_nesting.rs @@ -0,0 +1,138 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Nesting depth of the attributes an update expression writes. + +use std::collections::BTreeMap; + +use crate::error::DynamoDbError; +use crate::types::AttributeValue; +use crate::validation::nests_beyond_item_limit; + +use super::ast::UpdateAction; +use super::resolver::{ExpressionMaps, resolve_element_name}; + +/// Refuse an updated item whose SET or ADD targets now nest beyond the limit. +/// +/// The limit is the item limit of `crate::validation`: 31 M or L wrappers +/// around a scalar is 32 levels, the service's limit. +/// +/// Only the top-level attributes a SET or an ADD writes are checked, each +/// once however many actions write into it. ADD is checked because it +/// creates the missing maps on its path. REMOVE and DELETE cannot deepen a +/// value, and an attribute the update does not touch is left alone, so an +/// item stored deeper than the limit before this check existed stays +/// updatable through its other attributes. +/// +/// # Errors +/// +/// `ValidationException` `Nesting Levels have exceeded supported limits`, the +/// service's text for UpdateItem and for a TransactWriteItems Update +/// cancellation reason (measured 2026-10-25). +pub(super) fn validate_written_nesting_depth( + actions: &[UpdateAction], + item: &BTreeMap, + maps: &ExpressionMaps, +) -> Result<(), DynamoDbError> { + let mut written = Vec::new(); + for action in actions { + let (UpdateAction::Set { path, .. } | UpdateAction::Add { path, .. }) = action else { + continue; + }; + let Some(first) = path.first() else { + continue; + }; + let name = resolve_element_name(first, maps)?; + if !written.contains(&name) { + written.push(name); + } + } + for name in &written { + if item.get(name.as_ref()).is_some_and(nests_beyond_item_limit) { + return Err(DynamoDbError::ValidationException( + "Nesting Levels have exceeded supported limits".to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use super::*; + use crate::expression::{parse_update, tokenize}; + + fn nmap(wrappers: usize) -> AttributeValue { + let mut v = AttributeValue::S("leaf".to_owned()); + for _ in 0..wrappers { + v = AttributeValue::M(BTreeMap::from([("k".to_owned(), v)])); + } + v + } + + fn check(expr: &str, item: &BTreeMap) -> Result<(), DynamoDbError> { + let actions = parse_update(&tokenize(expr).unwrap()).unwrap(); + // The names map is keyed without the leading `#`. + let maps = ExpressionMaps::new( + HashMap::from([("m".to_owned(), "m".to_owned())]), + HashMap::new(), + ); + validate_written_nesting_depth(&actions, item, &maps) + } + + fn is_nesting_error(result: Result<(), DynamoDbError>) -> bool { + matches!( + result, + Err(DynamoDbError::ValidationException(m)) + if m == "Nesting Levels have exceeded supported limits" + ) + } + + #[test] + fn thirty_one_wrappers_pass_and_thirty_two_are_refused() { + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("SET #m = :v", &at).is_ok()); + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("SET #m.k = :v", &over))); + } + + #[test] + fn a_list_counts_as_a_wrapper_and_every_branch_is_checked() { + let mut deep = AttributeValue::S("leaf".to_owned()); + for _ in 0..32 { + deep = AttributeValue::L(vec![AttributeValue::N("1".to_owned()), deep]); + } + let item = BTreeMap::from([("m".to_owned(), deep)]); + assert!(is_nesting_error(check("SET #m[0] = :v", &item))); + } + + #[test] + fn attributes_the_update_does_not_set_are_not_checked() { + let item = BTreeMap::from([ + ("m".to_owned(), nmap(40)), + ("x".to_owned(), AttributeValue::S("y".to_owned())), + ]); + assert!(check("SET x = :v", &item).is_ok()); + assert!(check("REMOVE #m.k", &item).is_ok()); + assert!(is_nesting_error(check("SET #m.k = :v", &item))); + assert!(check("DELETE #m.k :v", &item).is_ok()); + } + + #[test] + fn an_add_target_is_checked() { + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("ADD #m.k :v", &over))); + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("ADD #m.k :v", &at).is_ok()); + } + + #[test] + fn two_actions_into_one_attribute_are_checked_as_one() { + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("SET #m.k = :v, #m.j = :v", &over))); + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("SET #m.k = :v, #m.j = :v ADD #m.i :v", &at).is_ok()); + } +} diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index 50fb2a0f3..5c15c0d32 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -2317,6 +2317,33 @@ pub fn validate_update_item_size( Ok(()) } +/// The statement half of [`validate_update_item_size`], for a standalone +/// `UpdateItem` written as an expression. It passes an update from a PartiQL +/// `UPDATE` (`maps.finished_item_sizing`), which is sized by its updated item +/// alone. It also passes the legacy `AttributeUpdates` form +/// (`maps.legacy_attribute_updates`), whose figure is computed from the +/// finished values of the attributes it names. +/// +/// # Errors +/// +/// Returns `DynamoDbError::ValidationException` with the update wording when +/// the statement figure exceeds `max_bytes`. +pub(crate) fn validate_update_statement_size( + actions: &[crate::expression::UpdateAction], + maps: &crate::expression::ExpressionMaps, + max_bytes: usize, +) -> Result<(), DynamoDbError> { + if maps.finished_item_sizing || maps.legacy_attribute_updates { + return Ok(()); + } + if update_statement_size(actions, maps)? > max_bytes { + return Err(DynamoDbError::ValidationException( + ITEM_SIZE_TO_UPDATE_EXCEEDED.to_owned(), + )); + } + Ok(()) +} + /// The bytes the service charges the operand expression of a `SET` clause: /// value placeholders at their size, function calls, arithmetic operators and /// document paths at their fixed costs. @@ -2540,8 +2567,8 @@ pub(crate) const MAX_ITEM_NESTING_DEPTH: usize = 32; /// Validate that no attribute value in `item` nests beyond `MAX_ITEM_NESTING_DEPTH`. pub fn validate_item_nesting_depth(item: &Item) -> Result<(), DynamoDbError> { - for value in item.values() { - check_attribute_value_depth(value, 0)?; + if item.values().any(nests_beyond_item_limit) { + return Err(nesting_depth_error()); } Ok(()) } @@ -2552,38 +2579,32 @@ pub fn validate_attribute_values_nesting_depth<'a, I>(values: I) -> Result<(), D where I: IntoIterator, { - for v in values { - check_attribute_value_depth(v, 0)?; + if values.into_iter().any(nests_beyond_item_limit) { + return Err(nesting_depth_error()); } Ok(()) } -fn check_attribute_value_depth( - value: &AttributeValue, - current_depth: usize, -) -> Result<(), DynamoDbError> { +/// Whether `value` nests beyond `MAX_ITEM_NESTING_DEPTH` levels. A check that +/// refuses such a value with its own text calls this, so the limit has one +/// definition. +pub(crate) fn nests_beyond_item_limit(value: &AttributeValue) -> bool { + wrappers_reach_limit(value, 0) +} + +/// Whether `value`, under `current_depth` M or L wrappers, brings the number +/// of wrappers on some path to `MAX_ITEM_NESTING_DEPTH`. +fn wrappers_reach_limit(value: &AttributeValue, current_depth: usize) -> bool { + let next = current_depth + 1; match value { AttributeValue::M(map) => { - let next = current_depth + 1; - if next >= MAX_ITEM_NESTING_DEPTH { - return Err(nesting_depth_error()); - } - for v in map.values() { - check_attribute_value_depth(v, next)?; - } + next >= MAX_ITEM_NESTING_DEPTH || map.values().any(|v| wrappers_reach_limit(v, next)) } AttributeValue::L(list) => { - let next = current_depth + 1; - if next >= MAX_ITEM_NESTING_DEPTH { - return Err(nesting_depth_error()); - } - for v in list { - check_attribute_value_depth(v, next)?; - } + next >= MAX_ITEM_NESTING_DEPTH || list.iter().any(|v| wrappers_reach_limit(v, next)) } - _ => {} + _ => false, } - Ok(()) } fn nesting_depth_error() -> DynamoDbError { diff --git a/crates/engine/src/partiql/item_depth.rs b/crates/engine/src/partiql/item_depth.rs index 1b37fc461..ee5acc74f 100644 --- a/crates/engine/src/partiql/item_depth.rs +++ b/crates/engine/src/partiql/item_depth.rs @@ -106,13 +106,17 @@ fn holds_a_value_past_the_item_limit(operand: &Operand, params: &[AttributeValue /// key attribute is refused first. The update is then evaluated as the /// storage layer evaluates any update, against the stored item, and nothing /// is written: a condition that fails or an update that does not apply is the -/// answer, then the item check on the updated item. +/// answer. An update that stores the deep value is refused by +/// `apply_update_validated` itself, which checks each attribute a `SET` or an +/// `ADD` writes and answers the text of `ITEM_NESTING_MESSAGE`. The item check +/// on the updated item runs last. It refuses only an item that already held +/// an attribute past the limit, one the update does not write. /// -/// `None` when the updated item passes the item check, which happens only -/// when the deep value is not stored, such as `if_not_exists` on an attribute -/// the item has. The caller then sends the update to `UpdateItem`, whose -/// nesting check of every `ExpressionAttributeValue` refuses it, where the -/// service applies the update (measured 2026-11-02). +/// `None` when neither check refuses, which happens only when the deep value +/// is not stored, such as `if_not_exists` on an attribute the item has. The +/// caller then sends the update to `UpdateItem`, whose nesting check of every +/// `ExpressionAttributeValue` refuses it, where the service applies the +/// update (measured 2026-11-02). pub(super) async fn unwritable_update_error( ctx: &OperationContext, parts: &UpdateParts, diff --git a/crates/storage-postgres/src/data/update_item.rs b/crates/storage-postgres/src/data/update_item.rs index a92ae9660..8f933a31d 100755 --- a/crates/storage-postgres/src/data/update_item.rs +++ b/crates/storage-postgres/src/data/update_item.rs @@ -192,21 +192,21 @@ impl PostgresEngine { // Apply update actions and validate the resulting image against the // table's vector indexes: vector validity is a property of the stored // value, and expression-form checks cannot cover if_not_exists / - // list_append / copy-from-attribute forms. - expression::apply_update_validated( + // list_append / copy-from-attribute forms. Size the update the way + // the service does: what the statement writes plus a fixed cost per + // clause, against the item limit. A statement over the limit is + // refused ahead of the depth check, and an updated item over the + // limit after it. + expression::apply_update_item_validated( actions, &mut item, maps, &key_info.vector_indexes, &key_info.attribute_definitions, + self.max_item_size_bytes, ) .map_err(|e| StorageError::Validation(e.to_string()))?; - // Size the update the way the service does: what the statement - // writes plus a fixed cost per clause, against the item limit. - validation::validate_update_item_size(&item, actions, maps, self.max_item_size_bytes) - .map_err(|e| StorageError::Validation(e.to_string()))?; - // Secondary-index keys on the written item, as the transacted Update // path and the SQLite backend already do. Literal operands were // checked ahead of the condition, above; a value computed from the diff --git a/crates/storage-sqlite/src/data/update_item.rs b/crates/storage-sqlite/src/data/update_item.rs index 0eabdde21..2ac84bbc5 100644 --- a/crates/storage-sqlite/src/data/update_item.rs +++ b/crates/storage-sqlite/src/data/update_item.rs @@ -80,19 +80,20 @@ impl SqliteEngine { } // Start from the existing image, or from the key for a fresh upsert. + // Size the update the way the service does: what the statement writes + // plus a fixed cost per clause, against the item limit. A statement + // over the limit is refused ahead of the depth check, and an updated + // item over the limit after it. let mut item = old.clone().unwrap_or_else(|| key.clone()); - expression::apply_update_validated( + expression::apply_update_item_validated( actions, &mut item, maps, &key_info.vector_indexes, &key_info.attribute_definitions, + self.max_item_size_bytes, ) .map_err(|e| StorageError::Validation(e.to_string()))?; - // Size the update the way the service does: what the statement writes - // plus a fixed cost per clause, against the item limit. - validation::validate_update_item_size(&item, actions, maps, self.max_item_size_bytes) - .map_err(|e| StorageError::Validation(e.to_string()))?; // Secondary-index key validation on the post-update item, matching the // TransactWriteItems update path: a wrong-typed index key attribute or // an index key set to an empty value is a ValidationException up front, diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index b768e0a76..c3e0492dd 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -178,14 +178,19 @@ remains. The key and untouched attributes are left out. Both the statement figur and the finished item are checked against the limit, and a breach refuses with `Item size to update has exceeded the maximum allowed size`. -A transacted `Update` and a PartiQL `UPDATE` are sized by the finished item -alone, with the same wording. For PartiQL this holds in `ExecuteStatement`, +A transacted `Update` and a PartiQL `UPDATE` are sized by the finished item, +with the same wording. For PartiQL this holds in `ExecuteStatement`, `BatchExecuteStatement` and `ExecuteTransaction` (measured in us-east-1 on -2026-11-04). +2026-11-04). The service also refused one transacted `Update` for its size +before the transaction ran (measured in us-east-1 on 2026-11-10). That +`Update` set a 409,500-byte value beside a `SET` that took a map past the +nesting limit. The rule behind that check is not measured, and ExtendDB does +not have it (the second row below). | Area | DynamoDB | ExtendDB | |------|----------|------| -| `UpdateItem` item-size accounting | In 31 of 33 commercial regions a standalone `UpdateItem` is sized by the statement (the rule above). At a one-byte key the largest finished item it can produce is 409,581 bytes. Two regions (`eu-south-2`, `me-central-1`) size an `UpdateItem` on the finished item, like a `PutItem`. | PostgreSQL and SQLite follow the 31-region statement rule. A transacted `Update` and a PartiQL `UPDATE` are sized on the finished item on both, as on the service. Cassandra sizes every update on the finished item against a fixed 409,600 bytes; MongoDB applies no item-size limit on updates. | +| `UpdateItem` item-size accounting | In 31 of 33 commercial regions a standalone `UpdateItem` is sized by the statement (the rule above). At a one-byte key the largest finished item it can produce is 409,581 bytes. Two regions (`eu-south-2`, `me-central-1`) size an `UpdateItem` on the finished item, like a `PutItem`. | PostgreSQL and SQLite follow the 31-region statement rule. On both, a transacted `Update` and a PartiQL `UPDATE` are sized on the finished item, as on the service. The next row gives a transacted `Update` that the service refuses before the transaction runs. Cassandra sizes every update on the finished item against a fixed 409,600 bytes. MongoDB applies no item-size limit on updates. | +| Transacted `Update` refused before the transaction | A TransactWriteItems `Update` sets a 409,500-byte value, and a second `SET` action takes a map past the nesting limit. The service refuses it with `ValidationException` `Item size to update has exceeded the maximum allowed size` before the transaction runs. The item is unchanged (measured in us-east-1 on 2026-11-10). The rule behind this check is not measured. | The transaction runs and is cancelled with one `ValidationError` reason that carries `Nesting Levels have exceeded supported limits`. In builds before #590, PostgreSQL and SQLite cancelled it with a reason that carried the size message. Tracked in #937. | ## Capacity and Throttling diff --git a/docs/dynamodb-limits.md b/docs/dynamodb-limits.md index 39167fb48..37214e2b5 100755 --- a/docs/dynamodb-limits.md +++ b/docs/dynamodb-limits.md @@ -42,7 +42,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Partition key size | 1–2,048 bytes | Enforced | `validate_key_sizes`, `LimitsConfig::max_partition_key_size_bytes` | | Sort key size | 1–1,024 bytes | Enforced | `validate_key_sizes`, `LimitsConfig::max_sort_key_size_bytes` | | Attribute name size | 1 to 65,535 bytes (64 KB) | Enforced | `validate_attribute_name_sizes`, `validate_expression_attribute_name_sizes`, `validate_attribute_updates_name_sizes`, `LimitsConfig::max_attribute_name_bytes`; UTF-8 bytes, top-level names and map keys at every depth. PutItem, BatchWriteItem.PutRequest, TransactWriteItems.Put and ImportTable check the item. PutItem, UpdateItem, DeleteItem and every TransactWriteItems member (Put, Update, Delete, ConditionCheck) check every `ExpressionAttributeNames` value and every map key inside every `ExpressionAttributeValues` value; UpdateItem also checks the names and values of `AttributeUpdates`. PutItem and UpdateItem report the name ahead of the table lookup; UpdateItem also reports it ahead of the condition and the index-key checks. TransactWriteItems checks every member, and BatchWriteItem every PutRequest, before any table lookup, so neither a missing table (in any member) nor a PutRequest item missing its key is reported first; a transaction fails as a whole with a top-level ValidationException. With an over-limit name in both expression maps, UpdateItem reports the `ExpressionAttributeNames` placeholder and a transacted Update or Put reports the `ExpressionAttributeValues` placeholder; a transacted Put with a long item name and a long `ExpressionAttributeNames` value reports the placeholder, a standalone PutItem the item. The message depends on the request form (`AttributeNameRequestForm`): PutItem, UpdateItem and DeleteItem answer `1 validation error detected: Attribute name is too large, must be less than 65536 bytes`, followed by ` for key #n` when the name came through `ExpressionAttributeNames`; a TransactWriteItems member answers `ExpressionAttributeNames contains invalid value: Attribute name is too large, must be less than 65536 bytes for key #n`, or `ExpressionAttributeValues contains invalid value: ... for key :v` for a map key inside a value; TransactWriteItems.Put and BatchWriteItem.PutRequest answer `Attribute name is too large, must be less than 65536 bytes` for a name in the item. | -| Attribute nesting depth | 32 levels | Enforced | `validate_item_nesting_depth`, applied on PutItem, UpdateItem, BatchWriteItem.PutRequest, TransactWriteItems.Put, ImportTable | +| Attribute nesting depth | 32 levels | Enforced | `validate_item_nesting_depth`, applied on PutItem, BatchWriteItem.PutRequest, TransactWriteItems.Put, ImportTable. UpdateItem checks its `ExpressionAttributeValues` and `AttributeUpdates` values with `validate_attribute_values_nesting_depth`. After an update is applied, `validate_written_nesting_depth` checks each attribute that a `SET` or an `ADD` writes with `nests_beyond_item_limit`, on UpdateItem, the TransactWriteItems `Update` and the PartiQL `UPDATE`, and refuses with `Nesting Levels have exceeded supported limits` | | Number of attributes per item | No practical limit | Enforced | ExtendDB has no per-item attribute count limit | ## Secondary Indexes diff --git a/tests/test_partiql_item_depth.py b/tests/test_partiql_item_depth.py index 4ae3f3501..b8bc03678 100644 --- a/tests/test_partiql_item_depth.py +++ b/tests/test_partiql_item_depth.py @@ -9,17 +9,22 @@ UpdateItem give the same value. An UPDATE meets the item check only once its condition holds and its SET applies: on a missing item it fails its condition, and a SET through a path the item does not have answers the update's own -message. A SET of a key attribute is refused ahead of all of this, and a -32-level value the statement does not store (an if_not_exists value the item -does not take, a value only in the WHERE clause) is accepted. In -ExecuteTransaction two members on one item are refused first; otherwise the -item check cancels the transaction with a ValidationError reason at the -member's position, beside each other member's own reason. A 33-level +message. A SET of a shallow parameter through a path that takes a stored value +past the limit meets the same item check: in ExecuteStatement, as the +statement's ValidationError entry in BatchExecuteStatement, as a ValidationError +reason in ExecuteTransaction, and ahead of the size check when the statement +also sets a 409,500-byte parameter. A SET of a key attribute is refused +ahead of all of this, and a 32-level value the statement does not store (an +if_not_exists value the item does not take, a value only in the WHERE clause) +is accepted. In ExecuteTransaction two members on one item are refused first; +otherwise the item check cancels the transaction with a ValidationError reason +at the member's position, beside each other member's own reason. A 33-level parameter is refused by the parameter check in every form. Runs against real DynamoDB (EXTENDDB_TEST_ENDPOINT unset, AWS credentials) and against an extenddb server (EXTENDDB_TEST_ENDPOINT set) with identical -assertions. Every expected value was measured on the service (2026-11-02). +assertions. Every expected value was measured on the service (2026-11-02; the +SET through a path 30 maps deep on 2026-11-10). Tables use the ``extenddb-measure-pqd-`` prefix and are deleted at the end. """ @@ -36,6 +41,7 @@ ITEM_CHECK = "Nesting Levels have exceeded supported limits" NESTED = f"{ITEM_CHECK}: Attributes in the item have nested levels beyond supported limit" BAD_PATH = "The document path provided in the update expression is invalid for update" +SIZE_TO_UPDATE = "Item size to update has exceeded the maximum allowed size" CONDITION_FAILED = "The conditional request failed" CANCELLED = "Transaction cancelled, please refer cancellation reasons for specific reasons" ITEM_REASON = {"Code": "ValidationError", "Message": ITEM_CHECK} @@ -56,6 +62,19 @@ strict=True, ) +# The PostgreSQL and SQLite storage layers size updates the way the service +# does. The MongoDB layer applies no limit to updates, and the Cassandra layer +# sizes every update against a fixed 409,600 bytes (see +# docs/differences-from-dynamodb.md) and words the refusal "Item size has +# exceeded the maximum allowed size" (validate_item_size in +# crates/core/src/validation/mod.rs). The case that needs the size refusal is +# skipped on those two backends, as in tests/test_partiql_item_size.py. +refused_for_size_on_default_backends_only = pytest.mark.skipif( + os.environ.get("EXTENDDB_TEST_BACKEND", "") in {"mongodb", "cassandra"}, + reason="updates are sized against a fixed limit (Cassandra) or not at all (MongoDB); " + "see docs/differences-from-dynamodb.md", +) + def deep(levels: int) -> dict: """``levels`` maps wrapped around a string.""" @@ -259,6 +278,124 @@ def test_a_deep_value_only_in_the_where_clause_is_accepted( assert stored_item(dynamodb_client, table, stored)["y"] == {"N": "1"} +class TestPartiqlUpdateThroughADeepPath: + """A SET of a shallow parameter at a path 30 maps deep, on an item that holds + ``x`` and 30 maps in ``m``: in ExecuteStatement, as a BatchExecuteStatement + member and as an ExecuteTransaction member, and beside a SET of a + 409,500-byte parameter (measured 2026-11-10).""" + + @staticmethod + def deep_set(table: str, key: str, levels: int) -> dict: + """The UPDATE that sets ``levels`` maps at 30 keys below ``m``.""" + return { + "Statement": f"UPDATE \"{table}\" SET m" + ".n" * 30 + " = ? WHERE pk = ?", + "Parameters": [deep(levels), {"S": key}], + } + + @staticmethod + def x_set(table: str, key: str) -> dict: + return { + "Statement": f"UPDATE \"{table}\" SET x = ? WHERE pk = ?", + "Parameters": [{"S": "y"}, {"S": key}], + } + + @staticmethod + def fresh(client, table: str) -> str: + """The key of a fresh item that holds ``x`` and 30 maps in ``m``.""" + key = f"deep-path-{uuid.uuid4().hex[:8]}" + client.put_item( + TableName=table, Item={"pk": {"S": key}, "x": {"S": "x"}, "m": deep(30)} + ) + return key + + @staticmethod + def unchanged(key: str) -> dict: + return {"pk": {"S": key}, "x": {"S": "x"}, "m": deep(30)} + + def set_leaf(self, client, table: str, key: str, levels: int): + return client.execute_statement(**self.deep_set(table, key, levels)) + + def set_big_beside(self, client, table: str, key: str, depth: int, big_first: bool): + """SET a 409,500-byte ``big`` and SET 2 maps at ``depth`` keys below ``m``, + as two SET clauses in the order ``big_first`` gives.""" + clauses = ["big = ?", "m" + ".n" * depth + " = ?"] + params = [{"S": "a" * 409_500}, deep(2)] + if not big_first: + clauses, params = clauses[::-1], params[::-1] + return client.execute_statement( + Statement=f"UPDATE \"{table}\" SET {clauses[0]} SET {clauses[1]} WHERE pk = ?", + Parameters=[*params, {"S": key}], + ) + + @pytest.fixture + def deep_item(self, dynamodb_client, table) -> str: + return self.fresh(dynamodb_client, table) + + def test_a_value_that_brings_the_item_to_the_limit_is_applied( + self, dynamodb_client, table, deep_item + ): + assert self.set_leaf(dynamodb_client, table, deep_item, 1)["Items"] == [] + assert stored_item(dynamodb_client, table, deep_item)["m"] == deep(31) + + def test_a_value_that_takes_the_item_past_the_limit_meets_the_item_check( + self, dynamodb_client, table, deep_item + ): + assert ( + validation_message(lambda: self.set_leaf(dynamodb_client, table, deep_item, 2)) + == ITEM_CHECK + ) + assert stored_item(dynamodb_client, table, deep_item)["m"] == deep(30) + + def test_a_batch_member_past_the_limit_fails_only_that_statement( + self, dynamodb_client, table, deep_item + ): + other = self.fresh(dynamodb_client, table) + resp = dynamodb_client.batch_execute_statement( + Statements=[self.x_set(table, other), self.deep_set(table, deep_item, 2)] + ) + assert [entry.get("Error") for entry in resp["Responses"]] == [ + None, + {"Code": "ValidationError", "Message": ITEM_CHECK}, + ] + assert stored_item(dynamodb_client, table, other)["x"] == {"S": "y"} + assert stored_item(dynamodb_client, table, deep_item) == self.unchanged(deep_item) + + def test_a_transaction_member_past_the_limit_cancels_the_transaction( + self, dynamodb_client, table, deep_item + ): + other = self.fresh(dynamodb_client, table) + statements = [self.x_set(table, other), self.deep_set(table, deep_item, 2)] + assert cancellation(dynamodb_client, statements) == [{"Code": "None"}, ITEM_REASON] + assert stored_item(dynamodb_client, table, other) == self.unchanged(other) + assert stored_item(dynamodb_client, table, deep_item) == self.unchanged(deep_item) + + @pytest.mark.parametrize("big_first", [True, False], ids=["big_first", "deep_first"]) + def test_a_large_parameter_beside_the_deep_set_meets_the_item_check( + self, dynamodb_client, table, deep_item, big_first + ): + assert ( + validation_message( + lambda: self.set_big_beside(dynamodb_client, table, deep_item, 30, big_first) + ) + == ITEM_CHECK + ) + assert stored_item(dynamodb_client, table, deep_item) == self.unchanged(deep_item) + + @refused_for_size_on_default_backends_only + def test_the_large_parameter_beside_a_set_at_28_keys_is_refused_for_its_size( + self, dynamodb_client, table, deep_item + ): + # The control for the test above: m keeps 30 maps, so only the size of + # the updated item is past a limit. + assert ( + validation_message( + lambda: self.set_big_beside(dynamodb_client, table, deep_item, 28, True) + ) + == SIZE_TO_UPDATE + ) + assert stored_item(dynamodb_client, table, deep_item) == self.unchanged(deep_item) + + class TestPartiqlDeleteAndSelect: """A 32-level value in the WHERE clause of a DELETE or a SELECT.""" diff --git a/tests/test_update_result_nesting_depth.py b/tests/test_update_result_nesting_depth.py new file mode 100644 index 000000000..26ee57c3a --- /dev/null +++ b/tests/test_update_result_nesting_depth.py @@ -0,0 +1,319 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Nesting depth of the item an UpdateItem or a TransactWriteItems Update produces. + +Amazon DynamoDB refuses an update whose result nests an attribute value more +than 32 levels (31 M or L wrappers around a scalar) and writes nothing: +UpdateItem answers ValidationException ``Nesting Levels have exceeded +supported limits``, and a TransactWriteItems Update is cancelled with a +``ValidationError`` reason carrying the same text. A failing condition is +reported first. Measured on 2026-10-25 at 19:53 UTC and 2026-10-26 at 00:55 +UTC (us-east-1), each case twice. + +The item size checks come on either side of the depth check (measured on +2026-11-10 from 19:28 to 19:37 UTC, us-east-1, each case twice). UpdateItem +sizes its statement before the depth check: a statement over the 409,600-byte +limit is refused with ``Item size to update has exceeded the maximum allowed +size``, with the SET actions in either order. The updated item is sized after +the depth check: when only the updated item is over the limit, the depth check +answers, for UpdateItem in either SET order and for a TransactWriteItems +Update. + +Depth below is counted in wrappers: a value with w wrappers has w + 1 levels. + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import os + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +MESSAGE = "Nesting Levels have exceeded supported limits" +NAMES = {"#m": "m", "#k": "k"} + + +def nmap(wrappers: int, leaf: dict | None = None) -> dict: + value = leaf or {"S": "leaf"} + for _ in range(wrappers): + value = {"M": {"k": value}} + return value + + +def nlist(wrappers: int) -> dict: + value = {"S": "leaf"} + for _ in range(wrappers): + value = {"L": [value]} + return value + + +def wrappers(value: dict) -> int: + """Most wrappers on any branch; an empty map or list adds none.""" + children = list(value.get("M", {}).values()) or value.get("L", []) + return 1 + max(wrappers(c) for c in children) if children else 0 + + +def path(j: int) -> str: + return "#m" + ".#k" * j + + +@pytest.fixture(scope="module") +def table(dynamodb_client): + with scoped_table( + dynamodb_client, + attribute_definitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + key_schema=[ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + ) as name: + yield name + + +def _put(client, table, sk, m): + key = {"pk": {"S": "nest"}, "sk": {"S": sk}} + item = {**key, "x": {"S": "x"}} + if m is not None: + item["m"] = m + client.put_item(TableName=table, Item=item) + return key + + +def _stored_wrappers(client, table, key): + item = client.get_item(TableName=table, Key=key, ConsistentRead=True)["Item"] + return wrappers(item["m"]) if "m" in item else None + + +# (case, base value of m, update expression, names, values, wrappers stored after) +ACCEPTED = [ + ("j29_w2", nmap(30), f"SET {path(29)} = :v", NAMES, {":v": nmap(2)}, 31), + ("j30_w1", nmap(30), f"SET {path(30)} = :v", NAMES, {":v": nmap(1)}, 31), + ("j1_w30", nmap(30), f"SET {path(1)} = :v", NAMES, {":v": nmap(30)}, 31), + ("j15_w16", nmap(30), f"SET {path(15)} = :v", NAMES, {":v": nmap(16)}, 31), + ("top_w31", None, "SET #m = :v", {"#m": "m"}, {":v": nmap(31)}, 31), + ("new_j1_w30", {"M": {}}, f"SET {path(1)} = :v", NAMES, {":v": nmap(30)}, 31), + ("list_j30_w1", nlist(30), "SET #m" + "[0]" * 30 + " = :v", {"#m": "m"}, {":v": nlist(1)}, 31), + ("if_not_exists_kept", nmap(30), f"SET {path(30)} = if_not_exists({path(30)}, :v)", NAMES, + {":v": nmap(2)}, 30), + ("at_limit_scalar", nmap(31), f"SET {path(31)} = :v", NAMES, {":v": {"S": "y"}}, 31), +] + +REFUSED = [ + ("j29_w3", nmap(30), f"SET {path(29)} = :v", NAMES, {":v": nmap(3)}, 30), + ("j30_w2", nmap(30), f"SET {path(30)} = :v", NAMES, {":v": nmap(2)}, 30), + ("j1_w31", nmap(30), f"SET {path(1)} = :v", NAMES, {":v": nmap(31)}, 30), + ("j15_w17", nmap(30), f"SET {path(15)} = :v", NAMES, {":v": nmap(17)}, 30), + ("new_j1_w31", {"M": {}}, f"SET {path(1)} = :v", NAMES, {":v": nmap(31)}, 0), + ("list_j30_w2", nlist(30), "SET #m" + "[0]" * 30 + " = :v", {"#m": "m"}, {":v": nlist(2)}, 30), + ("list_append", nmap(29, {"L": [{"S": "a"}]}), f"SET {path(29)} = list_append({path(29)}, :v)", NAMES, + {":v": {"L": [nmap(2)]}}, 30), + ("if_not_exists_default", nmap(29, {"M": {}}), f"SET {path(30)} = if_not_exists(#m.#z, :v)", + {**NAMES, "#z": "z"}, {":v": nmap(2)}, 29), +] + + +@pytest.mark.parametrize(("case", "base", "expr", "names", "values", "after"), ACCEPTED, + ids=[c[0] for c in ACCEPTED]) +def test_update_up_to_the_limit_is_applied(dynamodb_client, table, case, base, expr, names, values, after): + key = _put(dynamodb_client, table, f"ok-{case}", base) + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=expr, + ExpressionAttributeNames=names, ExpressionAttributeValues=values, + ) + assert _stored_wrappers(dynamodb_client, table, key) == after + + +@pytest.mark.parametrize(("case", "base", "expr", "names", "values", "after"), REFUSED, + ids=[c[0] for c in REFUSED]) +def test_update_past_the_limit_is_refused(dynamodb_client, table, case, base, expr, names, values, after): + key = _put(dynamodb_client, table, f"no-{case}", base) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=expr, + ExpressionAttributeNames=names, ExpressionAttributeValues=values, + ) + err = exc_info.value.response["Error"] + assert (err["Code"], err["Message"]) == ("ValidationException", MESSAGE) + assert _stored_wrappers(dynamodb_client, table, key) == after + + +def test_an_add_that_would_create_maps_past_the_limit_is_refused(dynamodb_client, table): + # The service refuses this path for its 33 elements (measured 2026-10-26 + # at 01:26 UTC) and ExtendDB for the depth of the maps ADD would create, + # so only the code and the stored item are compared. + key = _put(dynamodb_client, table, "add-deep", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=f"ADD {path(29)}.#a.#b.#c :one", + ExpressionAttributeNames={**NAMES, "#a": "a", "#b": "b", "#c": "c"}, + ExpressionAttributeValues={":one": {"N": "1"}}, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_a_failing_condition_is_reported_before_the_depth(dynamodb_client, table): + key = _put(dynamodb_client, table, "condfail", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=f"SET {path(30)} = :v", + ConditionExpression="#x = :no", + ExpressionAttributeNames={**NAMES, "#x": "x"}, + ExpressionAttributeValues={":v": nmap(2), ":no": {"S": "no"}}, + ) + assert exc_info.value.response["Error"]["Code"] == "ConditionalCheckFailedException" + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_an_item_at_the_limit_takes_an_update_of_another_attribute(dynamodb_client, table): + key = _put(dynamodb_client, table, "other-attr", nmap(31)) + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression="SET #x = :v", + ExpressionAttributeNames={"#x": "x"}, ExpressionAttributeValues={":v": {"S": "y"}}, + ) + assert _stored_wrappers(dynamodb_client, table, key) == 31 + + +def test_transact_update_past_the_limit_is_cancelled(dynamodb_client, table): + key = _put(dynamodb_client, table, "tx-no", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, "UpdateExpression": f"SET {path(30)} = :v", + "ExpressionAttributeNames": NAMES, "ExpressionAttributeValues": {":v": nmap(2)}, + }}]) + resp = exc_info.value.response + assert resp["Error"]["Code"] == "TransactionCanceledException" + reasons = [(r.get("Code"), r.get("Message")) for r in resp["CancellationReasons"]] + assert reasons == [("ValidationError", MESSAGE)] + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_transact_update_up_to_the_limit_is_applied(dynamodb_client, table): + key = _put(dynamodb_client, table, "tx-ok", nmap(30)) + dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, "UpdateExpression": f"SET {path(29)} = :v", + "ExpressionAttributeNames": NAMES, "ExpressionAttributeValues": {":v": nmap(2)}, + }}]) + assert _stored_wrappers(dynamodb_client, table, key) == 31 + + +# The tests below that expect the size answer run on SQLite and PostgreSQL. The +# MongoDB layer applies no item size limit on updates, and the Cassandra layer +# sizes every update by the updated item, both documented in +# docs/differences-from-dynamodb.md. The Cassandra refusal is worded "Item size +# has exceeded the maximum allowed size" (validate_item_size in +# crates/core/src/validation/mod.rs). With the backend unset (a run against the +# service), they run. +SIZED_UPDATES = pytest.mark.skipif( + os.environ.get("EXTENDDB_TEST_BACKEND", "") in {"mongodb", "cassandra"}, + reason="UpdateItem is sized on the finished item (Cassandra) or unbounded (MongoDB); " + "see docs/differences-from-dynamodb.md", +) +SIZE_MESSAGE = "Item size to update has exceeded the maximum allowed size" +GROW_NAMES = {**NAMES, "#g": "grow"} + + +def _sets(first: str, other: str, depth_path: str = path(30)) -> list[str]: + sets = [other, f"{depth_path} = :v"] + return sets[::-1] if first == "depth" else sets + + +def _put_large(client, table, sk, stored_bytes): + """An item under the size limit: m with 30 wrappers and a string of stored_bytes.""" + key = {"pk": {"S": "nest"}, "sk": {"S": sk}} + client.put_item(TableName=table, Item={ + **key, "x": {"S": "x"}, "m": nmap(30), "big": {"S": "a" * stored_bytes}, + }) + return key + + +def _assert_unchanged(client, table, key, added): + item = client.get_item(TableName=table, Key=key, ConsistentRead=True)["Item"] + assert added not in item + assert wrappers(item["m"]) == 30 + + +@SIZED_UPDATES +@pytest.mark.parametrize("first", ["size", "depth"]) +def test_a_statement_over_the_size_limit_is_refused_ahead_of_the_depth( + dynamodb_client, table, first, +): + key = _put(dynamodb_client, table, f"statement-{first}-first", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression="SET " + ", ".join(_sets(first, "#big = :big")), + ExpressionAttributeNames={**NAMES, "#big": "big"}, + ExpressionAttributeValues={":v": nmap(2), ":big": {"S": "a" * 409_500}}, + ) + err = exc_info.value.response["Error"] + assert (err["Code"], err["Message"]) == ("ValidationException", SIZE_MESSAGE) + _assert_unchanged(dynamodb_client, table, key, "big") + + +# (case, bytes stored, bytes the update adds, the SET action written first) +UPDATED_ITEM_OVER = [ + ("405000_8000_grow_first", 405_000, 8_000, "grow"), + ("405000_8000_depth_first", 405_000, 8_000, "depth"), + ("300000_120000_grow_first", 300_000, 120_000, "grow"), +] + + +@pytest.mark.parametrize(("case", "stored", "grow", "first"), UPDATED_ITEM_OVER, + ids=[c[0] for c in UPDATED_ITEM_OVER]) +def test_an_updated_item_over_the_size_limit_meets_the_depth_check_first( + dynamodb_client, table, case, stored, grow, first, +): + key = _put_large(dynamodb_client, table, f"updated-{case}", stored) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression="SET " + ", ".join(_sets(first, "#g = :g")), + ExpressionAttributeNames=GROW_NAMES, + ExpressionAttributeValues={":v": nmap(2), ":g": {"S": "b" * grow}}, + ) + err = exc_info.value.response["Error"] + assert (err["Code"], err["Message"]) == ("ValidationException", MESSAGE) + _assert_unchanged(dynamodb_client, table, key, "grow") + + +@SIZED_UPDATES +def test_the_same_update_through_a_shallow_path_is_refused_for_its_size(dynamodb_client, table): + # The control for the test above: a SET at 28 keys keeps m at 30 wrappers, + # so only the size of the updated item is past a limit. + key = _put_large(dynamodb_client, table, "updated-shallow", 405_000) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, + UpdateExpression="SET " + ", ".join(_sets("grow", "#g = :g", path(28))), + ExpressionAttributeNames=GROW_NAMES, + ExpressionAttributeValues={":v": nmap(2), ":g": {"S": "b" * 8_000}}, + ) + err = exc_info.value.response["Error"] + assert (err["Code"], err["Message"]) == ("ValidationException", SIZE_MESSAGE) + _assert_unchanged(dynamodb_client, table, key, "grow") + + +@pytest.mark.parametrize("first", ["grow", "depth"]) +def test_transact_update_of_an_item_over_the_size_limit_meets_the_depth_check_first( + dynamodb_client, table, first, +): + key = _put_large(dynamodb_client, table, f"tx-updated-{first}-first", 405_000) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, + "UpdateExpression": "SET " + ", ".join(_sets(first, "#g = :g")), + "ExpressionAttributeNames": GROW_NAMES, + "ExpressionAttributeValues": {":v": nmap(2), ":g": {"S": "b" * 8_000}}, + }}]) + resp = exc_info.value.response + assert resp["Error"]["Code"] == "TransactionCanceledException" + reasons = [(r.get("Code"), r.get("Message")) for r in resp["CancellationReasons"]] + assert reasons == [("ValidationError", MESSAGE)] + _assert_unchanged(dynamodb_client, table, key, "grow")