diff --git a/tests/rust/src/authorization.rs b/tests/rust/src/authorization.rs index 4f32a2a8..c30eccac 100755 --- a/tests/rust/src/authorization.rs +++ b/tests/rust/src/authorization.rs @@ -99,8 +99,6 @@ async fn unknown_access_key_rejected() { err_code(&err).is_some(), "Expected auth error, got: {err:?}" ); - - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -165,6 +163,4 @@ async fn valid_credentials_put_and_get() { .unwrap(); let item = resp.item().expect("Item should exist"); assert_eq!(item.get("data").unwrap().as_s().unwrap(), "auth_test"); - - c.delete_table().table_name(&table).send().await.ok(); } diff --git a/tests/rust/src/backup_restore.rs b/tests/rust/src/backup_restore.rs index 65b2f24d..38953c28 100755 --- a/tests/rust/src/backup_restore.rs +++ b/tests/rust/src/backup_restore.rs @@ -73,7 +73,6 @@ async fn create_backup_happy_case() { assert!(!details.backup_arn().is_empty()); assert_eq!(details.backup_status().as_str(), "AVAILABLE"); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -108,7 +107,6 @@ async fn describe_backup() { let desc = resp.backup_description().unwrap(); assert_eq!(desc.backup_details().unwrap().backup_arn(), arn.as_str()); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -135,7 +133,6 @@ async fn list_backups() { let resp = c.list_backups().table_name(&table).send().await.unwrap(); assert!(resp.backup_summaries().len() >= 2); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -147,7 +144,6 @@ async fn list_backups_empty() { let resp = c.list_backups().table_name(&table).send().await.unwrap(); assert!(resp.backup_summaries().is_empty()); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -166,7 +162,6 @@ async fn delete_backup() { "DELETED" ); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -209,8 +204,6 @@ async fn restore_table_from_backup() { let scan = c.scan().table_name(&restored).send().await.unwrap(); assert_eq!(scan.count(), 5); - c.delete_table().table_name(&restored).send().await.ok(); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -227,7 +220,6 @@ async fn describe_continuous_backups() { .unwrap(); assert!(resp.continuous_backups_description().is_some()); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -264,7 +256,6 @@ async fn enable_point_in_time_recovery() { "ENABLED" ); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -284,5 +275,4 @@ async fn restore_table_to_point_in_time() { .await; assert!(err.is_err(), "RestoreTableToPointInTime should return an error (not yet supported)"); - c.delete_table().table_name(&table).send().await.ok(); } diff --git a/tests/rust/src/capacity_throttling.rs b/tests/rust/src/capacity_throttling.rs index 305db909..bf92cd1b 100755 --- a/tests/rust/src/capacity_throttling.rs +++ b/tests/rust/src/capacity_throttling.rs @@ -93,7 +93,6 @@ async fn get_item_consumed_capacity_total() { assert_eq!(cap.table_name().unwrap(), table.as_str()); assert!(cap.capacity_units().unwrap() > 0.0); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -116,7 +115,6 @@ async fn put_item_consumed_capacity_total() { assert_eq!(cap.table_name().unwrap(), table.as_str()); assert!(cap.capacity_units().unwrap() > 0.0); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -145,7 +143,6 @@ async fn scan_consumed_capacity_total() { let cap = resp.consumed_capacity().unwrap(); assert!(cap.capacity_units().unwrap() > 0.0); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -171,7 +168,6 @@ async fn no_consumed_capacity_by_default() { assert!(resp.consumed_capacity().is_none()); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -213,7 +209,6 @@ async fn per_partition_write_throttling() { "Expected some writes throttled. Succeeded: {succeeded}, Throttled: {throttled}" ); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -255,7 +250,6 @@ async fn provisioned_table_write_throttling() { "Expected some writes throttled (1 WCU). Succeeded: {succeeded}, Throttled: {throttled}" ); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] @@ -305,5 +299,4 @@ async fn provisioned_table_read_throttling() { "Expected some reads throttled (1 RCU). Succeeded: {succeeded}, Throttled: {throttled}" ); - c.delete_table().table_name(&table).send().await.ok(); } diff --git a/tests/rust/src/gsi_more.rs b/tests/rust/src/gsi_more.rs index 359b783d..989ccb1d 100755 --- a/tests/rust/src/gsi_more.rs +++ b/tests/rust/src/gsi_more.rs @@ -93,13 +93,6 @@ async fn create_table_with_gsi_keys_only_projection() { !result.contains_key("extra"), "KEYS_ONLY projection should not include non-key attributes" ); - - c.delete_table() - .table_name(&table_name) - .send() - .await - .unwrap(); - wait_for_deleted(c, &table_name).await; } #[tokio::test] @@ -187,13 +180,6 @@ async fn create_table_with_gsi_include_projection() { !result.contains_key("excluded_attr"), "INCLUDE projection should not include non-projected attributes" ); - - c.delete_table() - .table_name(&table_name) - .send() - .await - .unwrap(); - wait_for_deleted(c, &table_name).await; } #[tokio::test] diff --git a/tests/rust/src/lsi.rs b/tests/rust/src/lsi.rs index 3638096f..9514de22 100644 --- a/tests/rust/src/lsi.rs +++ b/tests/rust/src/lsi.rs @@ -5,6 +5,9 @@ //! //! Verifies that ExclusiveStartKey pagination works correctly when multiple //! items share the same index sort key value. +//! +//! Tables are shared at module level to avoid redundant CreateTable calls. +//! Each test uses a unique partition key to isolate its data. use crate::test_base::*; use aws_sdk_dynamodb::types::{ @@ -12,12 +15,40 @@ use aws_sdk_dynamodb::types::{ LocalSecondaryIndex, Projection, ProjectionType, ScalarAttributeType, }; use std::collections::HashMap; +use tokio::sync::OnceCell as AsyncOnceCell; const LSI_NAME: &str = "TaskTypeLSI"; const LSI_SK_ATTR: &str = "taskType"; +// ---- Module-level shared tables (created once, reused by all tests) ---- + +static LSI_TABLE: AsyncOnceCell = AsyncOnceCell::const_new(); +static GSI_HASH_ONLY_TABLE: AsyncOnceCell = AsyncOnceCell::const_new(); + +/// Get or create the shared LSI table. +async fn lsi_table() -> &'static str { + LSI_TABLE + .get_or_init(|| async { + let name = format!("LSI_{}", ts()); + create_lsi_table_impl(&name).await; + name + }) + .await +} + +/// Get or create the shared hash-only GSI table. +async fn gsi_hash_only_table() -> &'static str { + GSI_HASH_ONLY_TABLE + .get_or_init(|| async { + let name = format!("GSIHashOnly_{}", ts()); + create_hash_only_gsi_table_impl(&name).await; + name + }) + .await +} + /// Create a table with an LSI for pagination tests. -async fn create_lsi_table(name: &str) { +async fn create_lsi_table_impl(name: &str) { let c = client(); let attr_defs = vec![ AttributeDefinition::builder() @@ -94,7 +125,68 @@ async fn create_lsi_table(name: &str) { wait_for_active(&c, name).await; } -/// Seed items that share the same LSI sort key value. +/// Create a table with a hash-only GSI for pagination tests. +async fn create_hash_only_gsi_table_impl(name: &str) { + let c = client(); + let attr_defs = vec![ + AttributeDefinition::builder() + .attribute_name("instanceId") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + AttributeDefinition::builder() + .attribute_name("nodeStatus") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ]; + + let key_schema = vec![KeySchemaElement::builder() + .attribute_name("instanceId") + .key_type(KeyType::Hash) + .build() + .unwrap()]; + + let gsi = aws_sdk_dynamodb::types::GlobalSecondaryIndex::builder() + .index_name("StatusGSI") + .key_schema( + KeySchemaElement::builder() + .attribute_name("nodeStatus") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .projection( + Projection::builder() + .projection_type(ProjectionType::All) + .build(), + ) + .build() + .unwrap(); + + match c + .create_table() + .table_name(name) + .billing_mode(BillingMode::PayPerRequest) + .set_key_schema(Some(key_schema)) + .set_attribute_definitions(Some(attr_defs)) + .global_secondary_indexes(gsi) + .send() + .await + { + Ok(_) => {} + Err(e) => { + if err_code(&e) != Some("ResourceInUseException") { + panic!("Failed to create GSI table {name}: {e:?}"); + } + } + } + wait_for_active(&c, name).await; +} + +// ---- Helpers ---- + +/// Seed items that share the same LSI sort key value, using a unique PK. async fn seed_lsi_items(table: &str, pk: &str, count: usize) { let c = client(); for i in 1..=count { @@ -112,12 +204,30 @@ async fn seed_lsi_items(table: &str, pk: &str, count: usize) { } } +/// Seed items for hash-only GSI tests, using a unique prefix for isolation. +async fn seed_gsi_items(table: &str, prefix: &str, count: usize) { + let c = client(); + for i in 1..=count { + let mut item = HashMap::new(); + item.insert("instanceId".into(), s(&format!("{prefix}-{i}"))); + item.insert("nodeStatus".into(), s(prefix)); + item.insert("data".into(), s(&format!("payload-{i}"))); + c.put_item() + .table_name(table) + .set_item(Some(item)) + .send() + .await + .unwrap(); + } +} + +// ---- LSI tests (share LSI_TABLE) ---- + #[tokio::test] async fn lsi_pagination_duplicate_sort_keys() { - let table = format!("LSIPagination_{}", ts()); + let table = lsi_table().await; let pk = format!("lsi_pag_{}", ts()); - create_lsi_table(&table).await; - seed_lsi_items(&table, &pk, 5).await; + seed_lsi_items(table, &pk, 5).await; let c = client(); let mut all_items: Vec> = Vec::new(); @@ -126,7 +236,7 @@ async fn lsi_pagination_duplicate_sort_keys() { loop { let mut req = c .query() - .table_name(&table) + .table_name(table) .index_name(LSI_NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", HASH_KEY_S) @@ -165,10 +275,9 @@ async fn lsi_pagination_duplicate_sort_keys() { #[tokio::test] async fn lsi_pagination_reverse_order() { - let table = format!("LSIPagRev_{}", ts()); + let table = lsi_table().await; let pk = format!("lsi_rev_{}", ts()); - create_lsi_table(&table).await; - seed_lsi_items(&table, &pk, 5).await; + seed_lsi_items(table, &pk, 5).await; let c = client(); let mut all_items: Vec> = Vec::new(); @@ -177,7 +286,7 @@ async fn lsi_pagination_reverse_order() { loop { let mut req = c .query() - .table_name(&table) + .table_name(table) .index_name(LSI_NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", HASH_KEY_S) @@ -214,17 +323,16 @@ async fn lsi_pagination_reverse_order() { #[tokio::test] async fn lsi_page_two_returns_items() { - let table = format!("LSIPagTwo_{}", ts()); + let table = lsi_table().await; let pk = format!("lsi_p2_{}", ts()); - create_lsi_table(&table).await; - seed_lsi_items(&table, &pk, 5).await; + seed_lsi_items(table, &pk, 5).await; let c = client(); // Page 1 let resp1 = c .query() - .table_name(&table) + .table_name(table) .index_name(LSI_NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", HASH_KEY_S) @@ -243,7 +351,7 @@ async fn lsi_page_two_returns_items() { // Page 2 — core regression test for issue #145 let resp2 = c .query() - .table_name(&table) + .table_name(table) .index_name(LSI_NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", HASH_KEY_S) @@ -263,16 +371,15 @@ async fn lsi_page_two_returns_items() { #[tokio::test] async fn lsi_last_evaluated_key_contains_all_keys() { - let table = format!("LSILek_{}", ts()); + let table = lsi_table().await; let pk = format!("lsi_lek_{}", ts()); - create_lsi_table(&table).await; - seed_lsi_items(&table, &pk, 5).await; + seed_lsi_items(table, &pk, 5).await; let c = client(); let resp = c .query() - .table_name(&table) + .table_name(table) .index_name(LSI_NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", HASH_KEY_S) @@ -300,91 +407,13 @@ async fn lsi_last_evaluated_key_contains_all_keys() { ); } -// --------------------------------------------------------------------------- -// Hash-only GSI pagination tests -// --------------------------------------------------------------------------- - -/// Create a table with a hash-only GSI for pagination tests. -async fn create_hash_only_gsi_table(name: &str) { - let c = client(); - let attr_defs = vec![ - AttributeDefinition::builder() - .attribute_name("instanceId") - .attribute_type(ScalarAttributeType::S) - .build() - .unwrap(), - AttributeDefinition::builder() - .attribute_name("nodeStatus") - .attribute_type(ScalarAttributeType::S) - .build() - .unwrap(), - ]; - - let key_schema = vec![KeySchemaElement::builder() - .attribute_name("instanceId") - .key_type(KeyType::Hash) - .build() - .unwrap()]; - - let gsi = aws_sdk_dynamodb::types::GlobalSecondaryIndex::builder() - .index_name("StatusGSI") - .key_schema( - KeySchemaElement::builder() - .attribute_name("nodeStatus") - .key_type(KeyType::Hash) - .build() - .unwrap(), - ) - .projection( - Projection::builder() - .projection_type(ProjectionType::All) - .build(), - ) - .build() - .unwrap(); - - match c - .create_table() - .table_name(name) - .billing_mode(BillingMode::PayPerRequest) - .set_key_schema(Some(key_schema)) - .set_attribute_definitions(Some(attr_defs)) - .global_secondary_indexes(gsi) - .send() - .await - { - Ok(_) => {} - Err(e) => { - if err_code(&e) != Some("ResourceInUseException") { - panic!("Failed to create GSI table {name}: {e:?}"); - } - } - } - wait_for_active(&c, name).await; -} - -/// Seed items that share the same GSI hash key. -async fn seed_gsi_items(table: &str, count: usize) { - let c = client(); - for i in 1..=count { - let mut item = HashMap::new(); - item.insert("instanceId".into(), s(&format!("node-{i}"))); - item.insert("nodeStatus".into(), s("ACTIVE")); - item.insert("data".into(), s(&format!("payload-{i}"))); - c.put_item() - .table_name(table) - .set_item(Some(item)) - .send() - .await - .unwrap(); - } -} +// ---- Hash-only GSI pagination tests (share GSI_HASH_ONLY_TABLE) ---- #[tokio::test] async fn gsi_hash_only_pagination() { - let table = format!("GSIHashPag_{}", ts()); - create_hash_only_gsi_table(&table).await; - seed_gsi_items(&table, 10).await; + let table = gsi_hash_only_table().await; + let prefix = format!("pag_{}", ts()); + seed_gsi_items(table, &prefix, 10).await; // Allow GSI propagation tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -396,10 +425,10 @@ async fn gsi_hash_only_pagination() { loop { let mut req = c .query() - .table_name(&table) + .table_name(table) .index_name("StatusGSI") .key_condition_expression("nodeStatus = :s") - .expression_attribute_values(":s", s("ACTIVE")) + .expression_attribute_values(":s", s(&prefix)) .limit(3); if let Some(ref lek) = exclusive_start_key { @@ -429,16 +458,16 @@ async fn gsi_hash_only_pagination() { }) .collect(); ids.sort(); - let mut expected: Vec = (1..=10).map(|i| format!("node-{i}")).collect(); + let mut expected: Vec = (1..=10).map(|i| format!("{prefix}-{i}")).collect(); expected.sort(); assert_eq!(ids, expected); } #[tokio::test] async fn gsi_hash_only_page_two_returns_items() { - let table = format!("GSIHashP2_{}", ts()); - create_hash_only_gsi_table(&table).await; - seed_gsi_items(&table, 10).await; + let table = gsi_hash_only_table().await; + let prefix = format!("p2_{}", ts()); + seed_gsi_items(table, &prefix, 10).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -446,10 +475,10 @@ async fn gsi_hash_only_page_two_returns_items() { let resp1 = c .query() - .table_name(&table) + .table_name(table) .index_name("StatusGSI") .key_condition_expression("nodeStatus = :s") - .expression_attribute_values(":s", s("ACTIVE")) + .expression_attribute_values(":s", s(&prefix)) .limit(3) .send() .await @@ -463,10 +492,10 @@ async fn gsi_hash_only_page_two_returns_items() { let resp2 = c .query() - .table_name(&table) + .table_name(table) .index_name("StatusGSI") .key_condition_expression("nodeStatus = :s") - .expression_attribute_values(":s", s("ACTIVE")) + .expression_attribute_values(":s", s(&prefix)) .set_exclusive_start_key(Some(lek)) .limit(3) .send() @@ -482,9 +511,9 @@ async fn gsi_hash_only_page_two_returns_items() { #[tokio::test] async fn gsi_hash_only_no_duplicates() { - let table = format!("GSIHashNoDup_{}", ts()); - create_hash_only_gsi_table(&table).await; - seed_gsi_items(&table, 10).await; + let table = gsi_hash_only_table().await; + let prefix = format!("nodup_{}", ts()); + seed_gsi_items(table, &prefix, 10).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -495,10 +524,10 @@ async fn gsi_hash_only_no_duplicates() { loop { let mut req = c .query() - .table_name(&table) + .table_name(table) .index_name("StatusGSI") .key_condition_expression("nodeStatus = :s") - .expression_attribute_values(":s", s("ACTIVE")) + .expression_attribute_values(":s", s(&prefix)) .limit(2); if let Some(ref lek) = exclusive_start_key { diff --git a/tests/rust/src/query_more.rs b/tests/rust/src/query_more.rs index c2ff5e88..85bd5d76 100755 --- a/tests/rust/src/query_more.rs +++ b/tests/rust/src/query_more.rs @@ -173,7 +173,6 @@ async fn scan_with_select_count() { assert_eq!(resp.count(), 4); assert!(resp.items().is_empty()); - c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] diff --git a/tests/rust/src/test_base.rs b/tests/rust/src/test_base.rs index cab50e07..ebecf1f7 100755 --- a/tests/rust/src/test_base.rs +++ b/tests/rust/src/test_base.rs @@ -222,6 +222,20 @@ pub async fn tables() -> &'static TestTables { .await } +// ========== Table creation helpers ========== +// +// Tables created during tests are NOT deleted by Rust code. Cleanup is handled +// by `devtools/run-tests` which bulk-deletes all test tables after the test +// binary exits (see the "Post-test table cleanup" section in that script). +// +// This avoids per-test `wait_for_deleted` polling loops, which on real DynamoDB +// can take up to 60 seconds per table — a significant overhead when many tests +// each create their own table. On ExtendDB the tables vanish with the database +// anyway (CI uses a fresh PostgreSQL container per run). +// +// The only tests that issue DeleteTable are those in `table_operations.rs` which +// specifically verify deletion behavior. + async fn create_table( c: &Client, name: &str, @@ -359,8 +373,18 @@ async fn create_table_with_gsi( } /// Poll DescribeTable until ACTIVE (up to 60s). +/// +/// Uses 100ms polling for local ExtendDB (tables are instantly active) +/// and 1s polling for real DynamoDB (async table creation). pub async fn wait_for_active(c: &Client, name: &str) { - for _ in 0..60 { + let interval = if is_real_dynamodb() { + Duration::from_secs(1) + } else { + Duration::from_millis(100) + }; + let max_attempts = if is_real_dynamodb() { 60 } else { 100 }; + + for _ in 0..max_attempts { if let Ok(resp) = c.describe_table().table_name(name).send().await { if let Some(table) = resp.table() { if table.table_status() == Some(&TableStatus::Active) { @@ -368,14 +392,23 @@ pub async fn wait_for_active(c: &Client, name: &str) { } } } - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(interval).await; } - panic!("Table {name} did not become ACTIVE within 60s"); + panic!("Table {name} did not become ACTIVE within timeout"); } /// Poll DescribeTable until the table no longer exists (up to 60s). +/// +/// Uses adaptive polling interval like `wait_for_active`. pub async fn wait_for_deleted(c: &Client, name: &str) { - for _ in 0..60 { + let interval = if is_real_dynamodb() { + Duration::from_secs(1) + } else { + Duration::from_millis(100) + }; + let max_attempts = if is_real_dynamodb() { 60 } else { 100 }; + + for _ in 0..max_attempts { match c.describe_table().table_name(name).send().await { Err(e) => { if err_code(&e) == Some("ResourceNotFoundException") { @@ -384,9 +417,9 @@ pub async fn wait_for_deleted(c: &Client, name: &str) { } Ok(_) => {} } - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(interval).await; } - panic!("Table {name} was not deleted within 60s"); + panic!("Table {name} was not deleted within timeout"); } // ========== Item helpers ==========