Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/core/src/expression/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ use super::ast::{CompareOp, Expr, PathElement};
pub struct ExpressionMaps {
pub names: HashMap<String, String>,
pub values: HashMap<String, AttributeValue>,
/// Set by the engine when the update arrived as legacy `AttributeUpdates`
/// and was desugared into an update expression. The service sizes that
/// form against the item limit differently from an expression.
pub legacy_attribute_updates: bool,
/// Pre-parsed `BigDecimal` values for numeric placeholders.
/// Populated eagerly by `new()` and refreshed by `pre_parse_numerics()`.
parsed_numerics: HashMap<String, bigdecimal::BigDecimal>,
Expand All @@ -40,6 +44,7 @@ impl ExpressionMaps {
let mut maps = Self {
names,
values,
legacy_attribute_updates: false,
parsed_numerics: HashMap::new(),
};
maps.pre_parse_numerics();
Expand Down
114 changes: 90 additions & 24 deletions crates/core/src/types/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,35 +388,75 @@ pub fn attribute_value_size(value: &AttributeValue) -> usize {

/// Calculate the `DynamoDB` size of a number in bytes.
///
/// `DynamoDB` number sizing: approximately 1 byte per 2 significant digits + 1 byte.
/// Zero is 1 byte. Negative numbers add 1 byte. Max 21 bytes.
/// Measured against the service (us-east-1, 2026-10-19) by accept/refuse pairs
/// either side of the 409,600-byte item limit:
///
/// ```text
/// 1 + ceil(integer significant digits / 2) + ceil(fraction significant digits / 2)
/// + 1 if negative
/// ```
///
/// The two halves are rounded up separately, so digits that straddle the
/// decimal point cost one byte more than the same digits without a point:
/// `1.5` is 3 bytes where `15` is 2, and `3.14159` is 5 where `123456` is 4.
/// Leading zeros of the integer half and trailing zeros of the fraction half
/// are not significant; trailing zeros of the integer half are significant
/// when a fraction follows them (`100` is 2, `100.5` is 4) and a zero between
/// two significant digits always is (`1010` is 3). The exponent is not
/// counted (`1E125` is 2). Zero is 1 byte however it is spelled, and is never
/// negative (`-0` is 1).
fn dynamodb_number_size(n: &str) -> usize {
let s = n.trim_start_matches('-');
let is_zero = s.chars().all(|c| c == '0' || c == '.');
if is_zero {
let (negative, body) = match n.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, n.strip_prefix('+').unwrap_or(n)),
};
let (mantissa, exponent) = match body.find(['e', 'E']) {
Some(pos) => (&body[..pos], body[pos + 1..].parse::<i64>().unwrap_or(0)),
None => (body, 0),
};
let (int_text, frac_text) = match mantissa.find('.') {
Some(pos) => (&mantissa[..pos], &mantissa[pos + 1..]),
None => (mantissa, ""),
};
let digits: String = int_text.chars().chain(frac_text.chars()).collect();
if !digits.bytes().any(|b| (b'1'..=b'9').contains(&b)) {
return 1;
}

let significant = if let Some(dot_pos) = s.find('.') {
let (int_part, frac_part) = s.split_at(dot_pos);
let frac = &frac_part[1..];
let int_trimmed = int_part.trim_start_matches('0');
let frac_trimmed = frac.trim_end_matches('0');
if int_trimmed.is_empty() {
let frac_sig = frac.trim_start_matches('0');
frac_sig.trim_end_matches('0').len()
} else {
format!("{int_trimmed}{frac_trimmed}").len()
}
// Where the decimal point sits in `digits` once the exponent is applied.
let point = i64::try_from(int_text.len())
.unwrap_or(i64::MAX)
.saturating_add(exponent);
let len = i64::try_from(digits.len()).unwrap_or(i64::MAX);
let (int_part, frac_part): (String, String) = if point <= 0 {
(
String::new(),
"0".repeat(usize::try_from(-point).unwrap_or(0)) + &digits,
)
} else if point >= len {
(
digits.clone() + &"0".repeat(usize::try_from(point - len).unwrap_or(0)),
String::new(),
)
} else {
let trimmed = s.trim_start_matches('0').trim_end_matches('0');
if trimmed.is_empty() { 1 } else { trimmed.len() }
let split = usize::try_from(point).unwrap_or(0);
(digits[..split].to_owned(), digits[split..].to_owned())
};

let mut size = significant.div_ceil(2) + 1;
if n.starts_with('-') {
size += 1;
}
let int_part = int_part.trim_start_matches('0');
let frac_part = frac_part.trim_end_matches('0');
// Trailing zeros of the integer half fall away only when no fraction follows.
let int_significant = if frac_part.is_empty() {
int_part.trim_end_matches('0').len()
} else {
int_part.len()
};
// Leading zeros of the fraction half fall away only when no integer precedes.
let frac_significant = if int_part.is_empty() {
frac_part.trim_start_matches('0').len()
} else {
frac_part.len()
};
let size =
1 + int_significant.div_ceil(2) + frac_significant.div_ceil(2) + usize::from(negative);
size.min(21)
}

Expand All @@ -428,6 +468,32 @@ mod tests {
AttributeValue::S(v.to_owned())
}

#[test]
fn number_size_matches_the_service_for_measured_literals() {
// Each figure measured against the service (us-east-1, 2026-10-19) by an
// accept/refuse pair either side of the 409,600-byte item limit.
for (literal, bytes) in [
("0", 1),
("-0", 1),
("1", 2),
("15", 2),
("100", 2),
("1E125", 2),
("0.15", 2),
("1.5", 3),
("1.2", 3),
("1.200", 3),
("1010", 3),
("-42", 3),
("1.234", 4),
("100.5", 4),
("123456", 4),
("3.14159", 5),
] {
assert_eq!(dynamodb_number_size(literal), bytes, "literal {literal}");
}
}

#[test]
fn scalar_sizes_unchanged() {
assert_eq!(attribute_value_size(&s("hello")), 5);
Expand Down
136 changes: 136 additions & 0 deletions crates/core/src/types/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,88 @@ impl CancellationReason {
item: None,
}
}

/// Internal reason for a wrong-typed key found while preparing a
/// transaction member: a table key element that does not match the
/// schema, or a secondary-index key (on a written item or as a literal
/// operand) that does not match its attribute definition. Never reaches a
/// client: [`Self::resolve_key_faults`] rewrites it to `ValidationError`.
#[must_use]
pub fn key_type_mismatch(msg: impl Into<String>) -> Self {
Self {
code: KEY_TYPE_MISMATCH_CODE.to_owned(),
message: Some(msg.into()),
item: None,
}
}

/// Internal reason for a secondary-index key left empty by a transaction
/// member. Never reaches a client: [`Self::resolve_key_faults`] turns it
/// into a top-level `ValidationException` message.
#[must_use]
pub fn index_key_empty_abort(msg: impl Into<String>) -> Self {
Self {
code: INDEX_KEY_EMPTY_ABORT_CODE.to_owned(),
message: Some(msg.into()),
item: None,
}
}

/// Whether this reason is one of the internal key faults that
/// [`Self::resolve_key_faults`] gives precedence to. A backend that runs
/// members out of request order can stop once the earliest such member
/// in request order is known: a wrong-typed key there outranks every later
/// member, and an empty index key there outranks every later member that
/// is not itself a wrong-typed key.
#[must_use]
pub fn is_key_fault(&self) -> bool {
self.code == KEY_TYPE_MISMATCH_CODE || self.code == INDEX_KEY_EMPTY_ABORT_CODE
}

/// Apply the precedence `DynamoDB` gives key faults across the members of
/// a `TransactWriteItems` request, measured on the service. A wrong-typed
/// key (table or secondary index) on any member cancels the transaction
/// with the first such member's `ValidationError` in position and `None`
/// in every other position, whatever those members' own faults were.
/// Otherwise an empty secondary-index key on any member aborts the whole
/// request with a top-level `ValidationException`, outranking failed
/// conditions and unapplicable statements on the other members. Otherwise
/// the reasons are returned as they are.
///
/// # Errors
///
/// Returns `Err(message)` when the request must be answered with a
/// top-level `ValidationException` carrying `message`.
pub fn resolve_key_faults(reasons: Vec<Self>) -> Result<Vec<Self>, String> {
let first_type_mismatch = reasons
.iter()
.position(|r| r.code == KEY_TYPE_MISMATCH_CODE);
let mut reasons = reasons;
for (i, reason) in reasons.iter_mut().enumerate() {
match first_type_mismatch {
Some(first) if i == first => reason.code = "ValidationError".to_owned(),
Some(_) => *reason = Self::none(),
None if reason.code == INDEX_KEY_EMPTY_ABORT_CODE => {
if let Some(message) = reason.message.take() {
return Err(message);
}
*reason = Self::none();
}
None => {}
}
}
Ok(reasons)
}
}

/// Internal cancellation code for a wrong-typed key. See
/// [`CancellationReason::key_type_mismatch`].
const KEY_TYPE_MISMATCH_CODE: &str = "ExtendDbKeyTypeMismatch";

/// Internal cancellation code for an empty secondary-index key. See
/// [`CancellationReason::index_key_empty_abort`].
const INDEX_KEY_EMPTY_ABORT_CODE: &str = "ExtendDbIndexKeyEmptyAbort";

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -337,4 +417,60 @@ mod tests {
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["Item"]["pk"]["S"], "val");
}

fn codes(reasons: &[CancellationReason]) -> Vec<&str> {
reasons.iter().map(|r| r.code.as_str()).collect()
}

#[test]
fn no_key_fault_leaves_the_reasons_untouched() {
let reasons = vec![
CancellationReason::condition_check_failed_with_item(None),
CancellationReason::validation_error("cannot apply"),
CancellationReason::none(),
];
let out = CancellationReason::resolve_key_faults(reasons).unwrap();
assert_eq!(
codes(&out),
["ConditionalCheckFailed", "ValidationError", "None"]
);
}

#[test]
fn an_empty_index_key_becomes_a_top_level_message_over_other_member_faults() {
let reasons = vec![
CancellationReason::condition_check_failed_with_item(None),
CancellationReason::index_key_empty_abort("empty key"),
CancellationReason::validation_error("cannot apply"),
];
assert_eq!(
CancellationReason::resolve_key_faults(reasons).unwrap_err(),
"empty key"
);
}

#[test]
fn a_type_mismatch_masks_every_other_member_including_an_empty_key() {
let reasons = vec![
CancellationReason::condition_check_failed_with_item(None),
CancellationReason::index_key_empty_abort("empty key"),
CancellationReason::key_type_mismatch("type"),
CancellationReason::validation_error("cannot apply"),
];
let out = CancellationReason::resolve_key_faults(reasons).unwrap();
assert_eq!(codes(&out), ["None", "None", "ValidationError", "None"]);
assert_eq!(out[2].message.as_deref(), Some("type"));
}

#[test]
fn only_the_first_type_mismatch_by_position_is_reported() {
let reasons = vec![
CancellationReason::none(),
CancellationReason::key_type_mismatch("second"),
CancellationReason::key_type_mismatch("third"),
];
let out = CancellationReason::resolve_key_faults(reasons).unwrap();
assert_eq!(codes(&out), ["None", "ValidationError", "None"]);
assert_eq!(out[1].message.as_deref(), Some("second"));
}
}
Loading
Loading