Skip to main content

dynoxide/actions/
batch_write_item.rs

1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::types::{self, AttributeValue, Item};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Default, Deserialize)]
9pub struct BatchWriteItemRequest {
10    #[serde(rename = "RequestItems")]
11    pub request_items: HashMap<String, Vec<WriteRequest>>,
12    #[serde(rename = "ReturnConsumedCapacity", default)]
13    pub return_consumed_capacity: Option<String>,
14    #[serde(rename = "ReturnItemCollectionMetrics", default)]
15    pub return_item_collection_metrics: Option<String>,
16}
17
18#[derive(Debug, Default, Deserialize)]
19pub struct WriteRequest {
20    #[serde(rename = "PutRequest", default)]
21    pub put_request: Option<PutRequest>,
22    #[serde(rename = "DeleteRequest", default)]
23    pub delete_request: Option<DeleteRequest>,
24}
25
26#[derive(Debug, Default, Deserialize)]
27pub struct PutRequest {
28    #[serde(rename = "Item")]
29    pub item: Item,
30}
31
32#[derive(Debug, Default, Deserialize)]
33pub struct DeleteRequest {
34    #[serde(rename = "Key")]
35    pub key: HashMap<String, AttributeValue>,
36}
37
38#[derive(Debug, Default, Serialize)]
39pub struct BatchWriteItemResponse {
40    #[serde(rename = "UnprocessedItems")]
41    pub unprocessed_items: HashMap<String, serde_json::Value>,
42    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
43    pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
44    #[serde(
45        rename = "ItemCollectionMetrics",
46        skip_serializing_if = "Option::is_none"
47    )]
48    pub item_collection_metrics: Option<HashMap<String, Vec<crate::types::ItemCollectionMetrics>>>,
49}
50
51/// Fold one item's per-index units into the running per-table totals. A batch
52/// reports the sum across its items, and an index no item in the batch touched
53/// stays absent rather than appearing as a zero.
54fn accumulate_index_units(
55    totals: &mut HashMap<String, HashMap<String, f64>>,
56    table_name: &str,
57    units: &HashMap<String, f64>,
58) {
59    if units.is_empty() {
60        return;
61    }
62    let table_entry = totals.entry(table_name.to_string()).or_default();
63    for (index_name, units) in units {
64        *table_entry.entry(index_name.clone()).or_insert(0.0) += units;
65    }
66}
67
68/// The request-level checks, in the order DynamoDB reports them: an empty
69/// `RequestItems`; then the member constraints together (a table's request list
70/// that is empty or pushes the request past 25, ReturnConsumedCapacity and
71/// ReturnItemCollectionMetrics); then a table name that breaks its constraints,
72/// alone.
73///
74/// Also run on the request-parsing path for a body nested too deep to decode,
75/// so a member error there is still reported ahead of the nesting error.
76pub(crate) fn check_request(request: &BatchWriteItemRequest) -> Result<()> {
77    // Validate RequestItems is not empty.
78    // AWS routes the empty-map case through a separate parameter-required path
79    // rather than the standard "N validation errors detected" envelope.
80    //
81    // Do not align this with BatchGetItem, which now answers the standard
82    // envelope. The two have diverged: as of the 2026-09-02 sweep 31 of the 33
83    // regions observed still return this sentence for BatchWriteItem, against
84    // 1 of 33 for BatchGetItem. Matching them would break the common case here.
85    if request.request_items.is_empty() {
86        return Err(DynoxideError::ValidationException(
87            "The requestItems parameter is required for BatchWriteItem".to_string(),
88        ));
89    }
90
91    // The request's member constraints, reported together before anything is
92    // read or written: a table's request list that is empty or pushes the
93    // request past 25, then ReturnConsumedCapacity and
94    // ReturnItemCollectionMetrics outside their enums (captured us-east-1,
95    // eu-west-2 and ten more regions, 2026-10-08 and 2026-10-09).
96    let mut member_errors = Vec::new();
97    let total_requests: usize = request.request_items.values().map(|v| v.len()).sum();
98    if total_requests > 25 || request.request_items.values().any(|wrs| wrs.is_empty()) {
99        member_errors.push(format!(
100            "Value '{}' at 'requestItems' failed to satisfy constraint: Map value must satisfy constraint: [Member must have length less than or equal to 25, Member must have length greater than or equal to 1]",
101            request_items_echo(&request.request_items)
102        ));
103    }
104    if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
105        request.return_consumed_capacity.as_deref(),
106    ) {
107        member_errors.push(msg);
108    }
109    if let Some(msg) = crate::validation::return_item_collection_metrics_rejection(
110        request.return_item_collection_metrics.as_deref(),
111    ) {
112        member_errors.push(msg);
113    }
114    if let Some(msg) = crate::validation::format_validation_errors(&member_errors) {
115        return Err(DynoxideError::ValidationException(msg));
116    }
117
118    // A table name that breaks its constraints comes after those, and alone:
119    // it is a constraint on the RequestItems map's keys, reported once for the
120    // map with the map echoed.
121    if !request
122        .request_items
123        .keys()
124        .all(|name| crate::validation::member_table_name_is_valid(name))
125    {
126        return Err(DynoxideError::ValidationException(
127            crate::validation::envelope_message(&format!(
128                "Value '{}' at 'requestItems' failed to satisfy constraint: {}",
129                request_items_echo(&request.request_items),
130                crate::validation::REQUEST_ITEMS_KEY_CONSTRAINT
131            )),
132        ));
133    }
134
135    Ok(())
136}
137
138pub async fn execute<S: StorageBackend>(
139    storage: &S,
140    mut request: BatchWriteItemRequest,
141) -> Result<BatchWriteItemResponse> {
142    const MAX_REQUEST_SIZE: usize = 16 * 1024 * 1024; // 16MB
143
144    check_request(&request)?;
145
146    // --- Pre-table validations ---
147    // DynamoDB validates attribute values, item size, and empty write requests
148    // BEFORE checking table existence.
149
150    // An empty attribute name in an item or a key is refused bare, before
151    // anything else here, a missing table included (captured in ten regions).
152    let empty_name = request.request_items.values().flatten().any(|wr| {
153        wr.put_request
154            .as_ref()
155            .is_some_and(|put| crate::empty_names::map_has_empty_name(&put.item))
156            || wr
157                .delete_request
158                .as_ref()
159                .is_some_and(|delete| crate::empty_names::map_has_empty_name(&delete.key))
160    });
161    if empty_name {
162        return Err(crate::empty_names::bare());
163    }
164    for write_requests in request.request_items.values() {
165        for wr in write_requests {
166            if wr.put_request.is_none() && wr.delete_request.is_none() {
167                return Err(DynoxideError::ValidationException(
168                    "Supplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes".to_string(),
169                ));
170            }
171            if let Some(ref put_req) = wr.put_request {
172                // Validate attribute values (empty strings, empty sets, invalid numbers)
173                crate::validation::validate_item_attribute_values(&put_req.item)?;
174
175                // Validate item size before table lookup
176                let size = types::item_size(&put_req.item);
177                if size > types::MAX_ITEM_SIZE {
178                    return Err(DynoxideError::ValidationException(
179                        "Item size has exceeded the maximum allowed size".to_string(),
180                    ));
181                }
182            }
183            if let Some(ref del_req) = wr.delete_request {
184                crate::validation::validate_item_attribute_values(&del_req.key)?;
185            }
186        }
187    }
188
189    // Validate aggregate request size
190    let total_size: usize = request
191        .request_items
192        .values()
193        .flat_map(|wrs| wrs.iter())
194        .map(|wr| {
195            if let Some(ref put_req) = wr.put_request {
196                types::item_size(&put_req.item)
197            } else if let Some(ref del_req) = wr.delete_request {
198                types::item_size(&del_req.key)
199            } else {
200                0
201            }
202        })
203        .sum();
204    if total_size > MAX_REQUEST_SIZE {
205        return Err(DynoxideError::ValidationException(
206            "Item collection too large: aggregate size of items in BatchWriteItem exceeds 16MB limit".to_string(),
207        ));
208    }
209
210    // Validate: no duplicate keys across all operations
211    {
212        let mut seen_keys: std::collections::HashSet<(String, String, String)> =
213            std::collections::HashSet::new();
214        for (table_name, write_requests) in &request.request_items {
215            let meta = helpers::require_table_for_item_op(storage, table_name).await?;
216            let key_schema = helpers::parse_key_schema(&meta)?;
217            for wr in write_requests {
218                // Validate keys BEFORE extract_key_strings: that helper returns
219                // InternalServerError (HTTP 500) for a missing partition or sort
220                // key, but a key-less request is client input and must surface as
221                // a 400 ValidationException. Mirrors put_item.rs, which validates
222                // before extracting.
223                let key_item = if let Some(ref put) = wr.put_request {
224                    require_key_attributes(&put.item, &key_schema)?;
225                    helpers::validate_item_keys_for_batch(&put.item, &key_schema, &meta)?;
226                    &put.item
227                } else if let Some(ref del) = wr.delete_request {
228                    helpers::validate_key_only(&del.key, &key_schema)?;
229                    &del.key
230                } else {
231                    continue;
232                };
233                let (pk, sk) = helpers::extract_key_strings(key_item, &key_schema)?;
234                let key = (table_name.clone(), pk, sk);
235                if !seen_keys.insert(key) {
236                    return Err(DynoxideError::ValidationException(
237                        "Provided list of item keys contains duplicates".to_string(),
238                    ));
239                }
240            }
241        }
242    }
243
244    // Track per-table secondary index capacity and affected partition keys for
245    // deferred metrics
246    let mut table_gsi_units: HashMap<String, HashMap<String, f64>> = HashMap::new();
247    let mut table_lsi_units: HashMap<String, HashMap<String, f64>> = HashMap::new();
248    // Vector replication is billed in bytes, so it accumulates on its own map
249    // and never joins the unit totals.
250    let mut table_vector_bytes: HashMap<String, HashMap<String, f64>> = HashMap::new();
251    // Track per-table WCU (table-level, excludes GSI)
252    let mut table_wcu: HashMap<String, f64> = HashMap::new();
253    // Collect unique (table, pk_str, pk_attr, pk_value) for deferred metrics computation
254    let mut affected_partitions: Vec<(String, String, String, AttributeValue)> = Vec::new();
255
256    for (table_name, write_requests) in &mut request.request_items {
257        let meta = helpers::require_table_for_item_op(storage, table_name).await?;
258        let key_schema = helpers::parse_key_schema(&meta)?;
259        // Parse the index definitions once per table: the maintain helpers'
260        // meta-accepting forms deserialise the JSON on every call, which for
261        // a 25-item batch against one table would repeat the work per item.
262        let gsi_defs = super::gsi::parse_gsi_defs(&meta)?;
263        let lsi_defs = super::lsi::parse_lsi_defs(&meta)?;
264        let vector_defs = super::vector_index::parse_vector_defs(&meta)?;
265        let attr_defs = super::vector_index::parse_attr_defs(&meta)?;
266
267        for wr in write_requests {
268            if let Some(ref mut put_req) = wr.put_request {
269                // Validate keys
270                helpers::validate_item_keys_for_batch(&put_req.item, &key_schema, &meta)?;
271
272                // Validate attribute values (empty strings, empty sets)
273                crate::validation::validate_item_attribute_values(&put_req.item)?;
274
275                // Normalize sets (deduplication)
276                crate::validation::normalize_item_sets(&mut put_req.item);
277
278                // Validate item size, by the number rule, as DynamoDB does
279                if types::item_size(&put_req.item) > types::MAX_ITEM_SIZE {
280                    return Err(DynoxideError::ValidationException(
281                        "Item size has exceeded the maximum allowed size".to_string(),
282                    ));
283                }
284                // What the row records and the write is charged: the billed
285                // size, with a vector attribute at 4 bytes per dimension.
286                let size = types::billed_item_size(&put_req.item, &vector_defs);
287
288                // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
289                let (pk, sk) = helpers::extract_key_strings(&put_req.item, &key_schema)?;
290                let item_json = serde_json::to_string(&put_req.item)
291                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
292                let hash_prefix = put_req
293                    .item
294                    .get(&key_schema.partition_key)
295                    .map(crate::storage::compute_hash_prefix)
296                    .unwrap_or_default();
297                // Base write + index fan-out + stream are one atomic unit per
298                // item: a mid-fan-out failure rolls this item's write back.
299                // BatchWriteItem items are independent, so this is one
300                // transaction per write request.
301                let (old_size, gsi_units, lsi_units, vector_bytes) =
302                    helpers::with_write_transaction(storage, async {
303                        let old_json = storage
304                            .put_item_with_hash(
305                                table_name,
306                                &pk,
307                                &sk,
308                                &item_json,
309                                size,
310                                &hash_prefix,
311                            )
312                            .await?;
313                        let old_item: Option<Item> =
314                            old_json.and_then(|j| serde_json::from_str(&j).ok());
315                        let target = super::gsi::IndexWrite {
316                            table_name,
317                            pk: &pk,
318                            sk: &sk,
319                            pk_attr: &key_schema.partition_key,
320                            sk_attr: key_schema.sort_key.as_deref(),
321                            old_item: old_item.as_ref(),
322                            capacity_mode: request.return_consumed_capacity.as_deref(),
323                        };
324                        let gsi_units = super::gsi::maintain_gsis_after_write_with_defs(
325                            storage,
326                            &gsi_defs,
327                            &target,
328                            &put_req.item,
329                        )
330                        .await?;
331                        let lsi_units = super::lsi::maintain_lsis_after_write_with_defs(
332                            storage,
333                            &lsi_defs,
334                            &target,
335                            &put_req.item,
336                        )
337                        .await?;
338                        let vector_bytes =
339                            super::vector_index::maintain_vector_indexes_after_write_with_defs(
340                                storage,
341                                &vector_defs,
342                                &attr_defs,
343                                &target,
344                                &put_req.item,
345                                false,
346                            )
347                            .await?;
348                        crate::streams::record_stream_event(
349                            storage,
350                            &meta,
351                            old_item.as_ref(),
352                            Some(&put_req.item),
353                        )
354                        .await?;
355                        Ok((
356                            old_item
357                                .as_ref()
358                                .map(|old| types::billed_item_size(old, &vector_defs)),
359                            gsi_units,
360                            lsi_units,
361                            vector_bytes,
362                        ))
363                    })
364                    .await?;
365
366                // Accumulate WCU on the larger of the displaced and new images
367                *table_wcu.entry(table_name.clone()).or_insert(0.0) +=
368                    types::table_write_capacity_units(old_size, Some(size));
369
370                // Accumulate secondary index units per table
371                accumulate_index_units(&mut table_gsi_units, table_name, &gsi_units);
372                accumulate_index_units(&mut table_lsi_units, table_name, &lsi_units);
373                accumulate_index_units(&mut table_vector_bytes, table_name, &vector_bytes);
374
375                // Track affected partition for deferred metrics
376                if let Some(pk_val) = put_req.item.get(&key_schema.partition_key) {
377                    affected_partitions.push((
378                        table_name.clone(),
379                        pk.clone(),
380                        key_schema.partition_key.clone(),
381                        pk_val.clone(),
382                    ));
383                }
384            } else if let Some(ref del_req) = wr.delete_request {
385                helpers::validate_key_only(&del_req.key, &key_schema)?;
386                // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
387                let (pk, sk) = helpers::extract_key_strings(&del_req.key, &key_schema)?;
388
389                // Base delete + index fan-out + stream are one atomic unit per item.
390                let (old_item, gsi_units, lsi_units, vector_bytes) =
391                    helpers::with_write_transaction(storage, async {
392                        let old_json = storage.delete_item(table_name, &pk, &sk).await?;
393                        let old_item: Option<Item> =
394                            old_json.as_ref().and_then(|j| serde_json::from_str(j).ok());
395                        let target = super::gsi::IndexWrite {
396                            table_name,
397                            pk: &pk,
398                            sk: &sk,
399                            pk_attr: &key_schema.partition_key,
400                            sk_attr: key_schema.sort_key.as_deref(),
401                            old_item: old_item.as_ref(),
402                            capacity_mode: request.return_consumed_capacity.as_deref(),
403                        };
404                        let gsi_units = super::gsi::maintain_gsis_after_delete_with_defs(
405                            storage, &gsi_defs, &target,
406                        )
407                        .await?;
408                        let lsi_units = super::lsi::maintain_lsis_after_delete_with_defs(
409                            storage, &lsi_defs, &target,
410                        )
411                        .await?;
412                        let vector_bytes =
413                            super::vector_index::maintain_vector_indexes_after_delete_with_defs(
414                                storage,
415                                &vector_defs,
416                                &attr_defs,
417                                &target,
418                            )
419                            .await?;
420                        if old_item.is_some() {
421                            crate::streams::record_stream_event(
422                                storage,
423                                &meta,
424                                old_item.as_ref(),
425                                None,
426                            )
427                            .await?;
428                        }
429                        Ok((old_item, gsi_units, lsi_units, vector_bytes))
430                    })
431                    .await?;
432
433                // Accumulate WCU: based on old item size if it existed, else 1 WCU
434                *table_wcu.entry(table_name.clone()).or_insert(0.0) +=
435                    types::table_write_capacity_units(
436                        old_item
437                            .as_ref()
438                            .map(|old| types::billed_item_size(old, &vector_defs)),
439                        None,
440                    );
441
442                // Accumulate secondary index units per table
443                accumulate_index_units(&mut table_gsi_units, table_name, &gsi_units);
444                accumulate_index_units(&mut table_lsi_units, table_name, &lsi_units);
445                accumulate_index_units(&mut table_vector_bytes, table_name, &vector_bytes);
446
447                // Track affected partition for deferred metrics
448                if let Some(pk_val) = del_req.key.get(&key_schema.partition_key) {
449                    affected_partitions.push((
450                        table_name.clone(),
451                        pk.clone(),
452                        key_schema.partition_key.clone(),
453                        pk_val.clone(),
454                    ));
455                }
456            } else {
457                return Err(DynoxideError::ValidationException(
458                    "WriteRequest must contain either PutRequest or DeleteRequest".to_string(),
459                ));
460            }
461        }
462    }
463
464    // Build consumed capacity per table using pre-tracked WCU
465    let consumed_capacity = if matches!(
466        request.return_consumed_capacity.as_deref(),
467        Some("TOTAL") | Some("INDEXES")
468    ) {
469        let mut caps = Vec::new();
470        for table_name in request.request_items.keys() {
471            let total_wcu = table_wcu.get(table_name).copied().unwrap_or(0.0);
472            let gsi_units = table_gsi_units.get(table_name).cloned().unwrap_or_default();
473            let lsi_units = table_lsi_units.get(table_name).cloned().unwrap_or_default();
474            let vector_bytes = table_vector_bytes
475                .get(table_name)
476                .cloned()
477                .unwrap_or_default();
478            if let Some(cc) = crate::types::consumed_capacity_with_vector_indexes(
479                table_name,
480                total_wcu,
481                &gsi_units,
482                &lsi_units,
483                &vector_bytes,
484                &request.return_consumed_capacity,
485            ) {
486                caps.push(cc);
487            }
488        }
489        Some(caps)
490    } else {
491        None
492    };
493
494    // Item collection metrics are computed once per unique (table, pk), deferred from the write loop
495    let mut all_item_collection_metrics: HashMap<String, Vec<crate::types::ItemCollectionMetrics>> =
496        HashMap::new();
497    if matches!(
498        request.return_item_collection_metrics.as_deref(),
499        Some("SIZE")
500    ) {
501        // Deduplicate by (table, pk) to avoid redundant queries
502        let mut seen = std::collections::HashSet::new();
503        for (tbl, pk_str, pk_attr, pk_val) in &affected_partitions {
504            let key = (tbl.as_str(), pk_str.as_str());
505            if !seen.insert(key) {
506                continue;
507            }
508            let meta = helpers::require_table(storage, tbl).await?;
509            if let Some(icm) = helpers::build_item_collection_metrics(
510                storage,
511                &meta,
512                tbl,
513                pk_str,
514                pk_attr,
515                pk_val,
516                &request.return_item_collection_metrics,
517            )
518            .await?
519            {
520                all_item_collection_metrics
521                    .entry(tbl.clone())
522                    .or_default()
523                    .push(icm);
524            }
525        }
526    }
527    let item_collection_metrics = if all_item_collection_metrics.is_empty() {
528        None
529    } else {
530        Some(all_item_collection_metrics)
531    };
532
533    Ok(BatchWriteItemResponse {
534        unprocessed_items: HashMap::new(),
535        consumed_capacity,
536        item_collection_metrics,
537    })
538}
539
540/// A put whose item lacks a key attribute is refused with the schema message,
541/// where PutItem names the missing key (captured us-east-1 and eu-west-2,
542/// 2026-10-09).
543fn require_key_attributes(item: &Item, schema: &helpers::KeySchema) -> Result<()> {
544    let missing = !item.contains_key(&schema.partition_key)
545        || schema
546            .sort_key
547            .as_ref()
548            .is_some_and(|sk| !item.contains_key(sk));
549    if missing {
550        return Err(DynoxideError::ValidationException(
551            "The provided key element does not match the schema".to_string(),
552        ));
553    }
554    Ok(())
555}
556
557/// The most of a member a constraint message echoes, in bytes, before the echo
558/// is cut. A cut echo is this many bytes (less any partial character at the
559/// cut) followed by the three-character `...`.
560///
561/// DynamoDB echoes the refused member in its Java model's `toString`, which
562/// names all sixteen of a value's fields and so runs to about twenty times the
563/// request's own size: a rejected request near the 16 MiB body limit would
564/// build an echo hundreds of megabytes long. Dynoxide stops the echo here and
565/// ends it with `...`. The limit is Dynoxide's own, well above the echo of any
566/// ordinary request (26 statements of 8,192 characters echo to about 220 KB);
567/// what DynamoDB does with an echo this size has not been captured.
568pub(crate) const ECHO_LIMIT: usize = 1024 * 1024;
569
570/// A string that takes writes up to [`ECHO_LIMIT`] bytes and then refuses
571/// them, so a formatter writing into it stops as soon as it is full rather than
572/// building the whole echo first.
573pub(crate) struct BoundedEcho {
574    text: String,
575    full: bool,
576}
577
578impl BoundedEcho {
579    pub(crate) fn new() -> Self {
580        BoundedEcho {
581            text: String::new(),
582            full: false,
583        }
584    }
585
586    /// The echo, ending `...` if it reached the limit.
587    pub(crate) fn finish(mut self) -> String {
588        if self.full {
589            self.text.push_str("...");
590        }
591        self.text
592    }
593}
594
595impl std::fmt::Write for BoundedEcho {
596    fn write_str(&mut self, s: &str) -> std::fmt::Result {
597        if self.full {
598            return Err(std::fmt::Error);
599        }
600        let room = ECHO_LIMIT - self.text.len();
601        if s.len() <= room {
602            self.text.push_str(s);
603            return Ok(());
604        }
605        let mut cut = room;
606        while !s.is_char_boundary(cut) {
607            cut -= 1;
608        }
609        self.text.push_str(&s[..cut]);
610        self.full = true;
611        Err(std::fmt::Error)
612    }
613}
614
615/// Cut an echo already built to [`ECHO_LIMIT`], for an echo that is no larger
616/// than the request it comes from.
617pub(crate) fn bound_echo(echo: String) -> String {
618    let mut out = BoundedEcho::new();
619    let _ = std::fmt::Write::write_str(&mut out, &echo);
620    out.finish()
621}
622
623/// The `RequestItems` map as DynamoDB echoes it in a constraint message: the
624/// request object's Java `toString`, which names every field of every value.
625/// Eleven of twelve regions printed it this way on 2026-10-09; the other, and
626/// eu-west-2 on other days, print each attribute value as an object hash, so no
627/// echo matches every region byte for byte. Tables are in order of name, where
628/// DynamoDB's own order is its map's; attributes follow the Java map's order.
629/// Bounded by [`ECHO_LIMIT`].
630fn request_items_echo(request_items: &HashMap<String, Vec<WriteRequest>>) -> String {
631    let mut out = BoundedEcho::new();
632    let _ = write_request_items(&mut out, request_items);
633    out.finish()
634}
635
636fn write_request_items(
637    out: &mut dyn std::fmt::Write,
638    request_items: &HashMap<String, Vec<WriteRequest>>,
639) -> std::fmt::Result {
640    let mut tables: Vec<(&String, &Vec<WriteRequest>)> = request_items.iter().collect();
641    tables.sort_by(|a, b| a.0.cmp(b.0));
642    out.write_str("{")?;
643    for (i, (table_name, requests)) in tables.into_iter().enumerate() {
644        if i > 0 {
645            out.write_str(", ")?;
646        }
647        write!(out, "{table_name}=[")?;
648        for (j, request) in requests.iter().enumerate() {
649            if j > 0 {
650                out.write_str(", ")?;
651            }
652            write_write_request(out, request)?;
653        }
654        out.write_str("]")?;
655    }
656    out.write_str("}")
657}
658
659fn write_write_request(out: &mut dyn std::fmt::Write, request: &WriteRequest) -> std::fmt::Result {
660    out.write_str("WriteRequest(putRequest=")?;
661    match request.put_request {
662        Some(ref put) => {
663            out.write_str("PutRequest(item=")?;
664            write_java_item(out, &put.item)?;
665            out.write_str(", workloadProfileName=null)")?;
666        }
667        None => out.write_str("null")?,
668    }
669    out.write_str(", deleteRequest=")?;
670    match request.delete_request {
671        Some(ref delete) => {
672            out.write_str("DeleteRequest(key=")?;
673            write_java_item(out, &delete.key)?;
674            out.write_str(", workloadProfileName=null)")?;
675        }
676        None => out.write_str("null")?,
677    }
678    out.write_str(")")
679}
680
681/// A map's names in the order the Java model prints them: the iteration order
682/// of a hash map sized to its entries (`new HashMap<>(n)`), so 1 name gives
683/// two buckets, 2 or 3 give four, 4 to 6 give eight, and so on up, doubling
684/// whenever more than three quarters full (captured us-east-1 and eu-west-2,
685/// 2026-10-10, for an item's attributes, a nested map and
686/// ExpressionAttributeNames, of 1 to 49 names). Names sharing a bucket keep the
687/// order they were sent in on DynamoDB, which a decoded map no longer holds;
688/// they are taken in order of name.
689fn java_map_order<'a, I: Iterator<Item = &'a String>>(names: I) -> Vec<&'a str> {
690    let mut names: Vec<&str> = names.map(String::as_str).collect();
691    names.sort_unstable();
692    crate::validation::java_hash_order(&names, true)
693}
694
695/// An attribute map, `{name=AttributeValue(...), ...}`, in the Java model's
696/// order.
697pub(crate) fn write_java_item(
698    out: &mut dyn std::fmt::Write,
699    item: &HashMap<String, AttributeValue>,
700) -> std::fmt::Result {
701    out.write_str("{")?;
702    for (i, name) in java_map_order(item.keys()).into_iter().enumerate() {
703        if i > 0 {
704            out.write_str(", ")?;
705        }
706        write!(out, "{name}=")?;
707        write_java_attribute_value(out, &item[name])?;
708    }
709    out.write_str("}")
710}
711
712/// `ExpressionAttributeNames` as the Java model prints it, `{#n=a}`, or `null`.
713pub(crate) fn write_java_names(
714    out: &mut dyn std::fmt::Write,
715    names: Option<&HashMap<String, String>>,
716) -> std::fmt::Result {
717    let Some(names) = names else {
718        return out.write_str("null");
719    };
720    out.write_str("{")?;
721    for (i, name) in java_map_order(names.keys()).into_iter().enumerate() {
722        if i > 0 {
723            out.write_str(", ")?;
724        }
725        write!(out, "{name}={}", names[name])?;
726    }
727    out.write_str("}")
728}
729
730/// An optional attribute map, such as `ExpressionAttributeValues`, or `null`.
731pub(crate) fn write_java_values(
732    out: &mut dyn std::fmt::Write,
733    values: Option<&HashMap<String, AttributeValue>>,
734) -> std::fmt::Result {
735    match values {
736        Some(values) => write_java_item(out, values),
737        None => out.write_str("null"),
738    }
739}
740
741/// An optional string member as the Java model prints it: the text, or `null`.
742pub(crate) fn write_java_text(
743    out: &mut dyn std::fmt::Write,
744    text: Option<&str>,
745) -> std::fmt::Result {
746    out.write_str(text.unwrap_or("null"))
747}
748
749/// One attribute value with all sixteen of the Java model's fields, the one in
750/// use filled in, binary shown as Java prints a `ByteBuffer` (every type
751/// captured us-east-1, eu-west-1 and us-west-2, 2026-10-10).
752pub(crate) fn write_java_attribute_value(
753    out: &mut dyn std::fmt::Write,
754    value: &AttributeValue,
755) -> std::fmt::Result {
756    const FIELDS: [&str; 16] = [
757        "s",
758        "n",
759        "b",
760        "iNT",
761        "dECIMAL",
762        "dOUBLE",
763        "sS",
764        "nS",
765        "bS",
766        "iNTSET",
767        "dECIMALSET",
768        "dOUBLESET",
769        "m",
770        "l",
771        "nULL",
772        "bOOL",
773    ];
774    let slot = match value {
775        AttributeValue::S(_) => 0,
776        AttributeValue::N(_) => 1,
777        AttributeValue::B(_) => 2,
778        AttributeValue::SS(_) => 6,
779        AttributeValue::NS(_) => 7,
780        AttributeValue::BS(_) => 8,
781        AttributeValue::M(_) => 12,
782        AttributeValue::L(_) => 13,
783        AttributeValue::NULL(_) => 14,
784        AttributeValue::BOOL(_) => 15,
785    };
786    out.write_str("AttributeValue(")?;
787    for (i, name) in FIELDS.iter().enumerate() {
788        if i > 0 {
789            out.write_str(", ")?;
790        }
791        write!(out, "{name}=")?;
792        if i == slot {
793            write_java_shown(out, value)?;
794        } else {
795            out.write_str("null")?;
796        }
797    }
798    out.write_str(")")
799}
800
801fn write_java_shown(out: &mut dyn std::fmt::Write, value: &AttributeValue) -> std::fmt::Result {
802    let byte_buffer = |out: &mut dyn std::fmt::Write, bytes: &[u8]| {
803        write!(
804            out,
805            "java.nio.HeapByteBuffer[pos=0 lim={len} cap={len}]",
806            len = bytes.len()
807        )
808    };
809    match value {
810        AttributeValue::S(s) => out.write_str(s),
811        AttributeValue::N(n) => out.write_str(n),
812        AttributeValue::B(b) => byte_buffer(out, b),
813        AttributeValue::SS(set) | AttributeValue::NS(set) => {
814            out.write_str("[")?;
815            for (i, member) in set.iter().enumerate() {
816                if i > 0 {
817                    out.write_str(", ")?;
818                }
819                out.write_str(member)?;
820            }
821            out.write_str("]")
822        }
823        AttributeValue::BS(set) => {
824            out.write_str("[")?;
825            for (i, member) in set.iter().enumerate() {
826                if i > 0 {
827                    out.write_str(", ")?;
828                }
829                byte_buffer(out, member)?;
830            }
831            out.write_str("]")
832        }
833        AttributeValue::M(map) => write_java_item(out, map),
834        AttributeValue::L(list) => {
835            out.write_str("[")?;
836            for (i, member) in list.iter().enumerate() {
837                if i > 0 {
838                    out.write_str(", ")?;
839                }
840                write_java_attribute_value(out, member)?;
841            }
842            out.write_str("]")
843        }
844        AttributeValue::NULL(null) => write!(out, "{null}"),
845        AttributeValue::BOOL(b) => write!(out, "{b}"),
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::{BoundedEcho, ECHO_LIMIT, bound_echo};
852    use crate::actions::{batch_write_item, create_table};
853    use crate::storage::Storage;
854    use crate::storage_backend::StorageBackend;
855    use std::fmt::Write;
856
857    /// An echo that fits is returned untouched, with no `...`, right up to the
858    /// limit itself.
859    #[test]
860    fn an_echo_of_exactly_the_limit_is_untouched() {
861        let echo = "a".repeat(ECHO_LIMIT);
862        assert_eq!(bound_echo(echo.clone()), echo);
863        assert_eq!(bound_echo("short".to_string()), "short");
864    }
865
866    /// One byte over the limit is cut at the limit and ends `...`.
867    #[test]
868    fn an_echo_one_byte_over_the_limit_is_cut_and_marked() {
869        let cut = bound_echo("a".repeat(ECHO_LIMIT + 1));
870        assert_eq!(cut.len(), ECHO_LIMIT + 3);
871        assert!(cut.ends_with("a..."));
872    }
873
874    /// A character that straddles the limit is left out whole rather than
875    /// split, for two- and four-byte characters and for an echo written in
876    /// many small pieces as well as one large one.
877    #[test]
878    fn an_echo_is_never_cut_inside_a_character() {
879        for (filler, ch) in [(ECHO_LIMIT - 1, "é"), (ECHO_LIMIT - 2, "😀")] {
880            let echo = format!("{}{ch}tail", "a".repeat(filler));
881            let cut = bound_echo(echo);
882            assert_eq!(cut, format!("{}...", "a".repeat(filler)), "cut at {ch}");
883
884            let mut pieces = BoundedEcho::new();
885            for _ in 0..filler {
886                let _ = pieces.write_str("a");
887            }
888            assert!(pieces.write_str(ch).is_err(), "{ch} did not fill the echo");
889            assert!(pieces.write_str("more").is_err(), "a full echo took more");
890            assert_eq!(pieces.finish(), format!("{}...", "a".repeat(filler)));
891        }
892    }
893
894    /// Each batch put is atomic with its own GSI fan-out: a mid-fan-out failure
895    /// rolls that item's base write back rather than leaving a torn index.
896    #[test]
897    fn batch_put_rolls_back_base_write_when_gsi_fan_out_fails() {
898        let storage = Storage::memory().unwrap();
899
900        let create = serde_json::from_value(serde_json::json!({
901            "TableName": "Orders",
902            "KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
903            "AttributeDefinitions": [
904                {"AttributeName": "UserId", "AttributeType": "S"},
905                {"AttributeName": "Status", "AttributeType": "S"},
906                {"AttributeName": "Priority", "AttributeType": "S"}
907            ],
908            "GlobalSecondaryIndexes": [
909                {"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
910                {"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
911            ]
912        }))
913        .unwrap();
914        pollster::block_on(create_table::execute(&storage, create)).unwrap();
915
916        // Break the second GSI's fan-out by dropping its physical table.
917        storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
918
919        let batch = serde_json::from_value(serde_json::json!({
920            "RequestItems": {
921                "Orders": [
922                    {"PutRequest": {"Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}}}}
923                ]
924            }
925        }))
926        .unwrap();
927        let res = pollster::block_on(batch_write_item::execute(&storage, batch));
928        assert!(
929            res.is_err(),
930            "a mid-fan-out failure must surface as an error"
931        );
932
933        // The base write must roll back: no item landed.
934        let count =
935            pollster::block_on(<Storage as StorageBackend>::count_items(&storage, "Orders"))
936                .unwrap();
937        assert_eq!(
938            count, 0,
939            "batch put base write must roll back when fan-out fails"
940        );
941    }
942}