diff --git a/Cargo.lock b/Cargo.lock index 00356967..e0ba802e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1448,6 +1448,7 @@ dependencies = [ "dashmap", "extenddb-auth", "extenddb-core", + "extenddb-engine", "extenddb-storage", "futures", "mongodb", @@ -1476,6 +1477,7 @@ dependencies = [ "crc32fast", "extenddb-auth", "extenddb-core", + "extenddb-engine", "extenddb-storage", "futures", "libc", diff --git a/crates/core/src/error/messages.rs b/crates/core/src/error/messages.rs index e5aedc05..8db9ee01 100755 --- a/crates/core/src/error/messages.rs +++ b/crates/core/src/error/messages.rs @@ -44,6 +44,14 @@ pub enum ErrorMessageKey { /// Measured on the service 2026-10-24 for a second `DeleteTable` and for /// `UpdateTable` during the window. TableBeingDeleted, + /// `DescribeTimeToLive` refused because the table is `DELETING`. Measured + /// on the service 2026-10-25 on three tables, every call inside the window. + /// `UpdateTimeToLive` has no such refusal: it runs its ordinary rules. + DescribeTtlWhileDeleting, + /// `DescribeTimeToLive` refused because the table is `CREATING`: the + /// `DELETING` sentence with the state substituted. Measured on the service + /// 2026-10-26, five calls, each while `DescribeTable` read `CREATING`. + DescribeTtlWhileCreating, MissingKeySchema, KeySchemaTooMany, KeySchemaFirstNotHash, @@ -92,6 +100,16 @@ pub fn error_message(key: ErrorMessageKey, args: &[&str]) -> String { args[0] ) } + ErrorMessageKey::DescribeTtlWhileDeleting => { + "Cannot describe time to live while table is in DELETING state: \ + Current table state is DELETING" + .to_owned() + } + ErrorMessageKey::DescribeTtlWhileCreating => { + "Cannot describe time to live while table is in CREATING state: \ + Current table state is CREATING" + .to_owned() + } ErrorMessageKey::MissingKeySchema => { "1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: \ Member must not be null" diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74083fbf..c960fa4c 100755 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,6 +33,7 @@ mod scan; mod search_vectors; pub mod stream_capture; mod streams; +mod table_lifecycle; mod tagging; mod transact_get_items; mod transact_write_helpers; diff --git a/crates/engine/src/table_lifecycle.rs b/crates/engine/src/table_lifecycle.rs new file mode 100644 index 00000000..839df511 --- /dev/null +++ b/crates/engine/src/table_lifecycle.rs @@ -0,0 +1,64 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The catalog's lifecycle status for a table, for handlers that decide on it. +//! +//! `TableKeyInfo` carries no status, and the key-info read reports a `DELETING` +//! row as not found on MongoDB and Cassandra, so a handler that has to tell a +//! `DELETING` table from a missing one cannot get the answer from it. The +//! description carries the status on every backend. The callers (the two TTL +//! handlers and the tag path) are control-plane calls, so the read is not on a +//! data-plane path. +//! +//! The read is `describe_table`: four catalog queries on PostgreSQL and SQLite +//! (`tables`, `indexes`, `vector_indexes`, `table_restores`) and two on MongoDB +//! and Cassandra (`tables`, `indexes`). `DescribeTimeToLive`, +//! `UpdateTimeToLive`, `TagResource`, `UntagResource` and `ListTagsOfResource` +//! each make it once per call. The tag path keeps the key-info read after it +//! for every status but `DELETING`, so a tag call on an `ACTIVE` table sends +//! seven queries before its tag statements on PostgreSQL and SQLite, where it +//! sent three, and four on MongoDB and Cassandra, where it sent two. +//! `DescribeTimeToLive` on an `ACTIVE` table sends five and three, where it +//! sent one. `UpdateTimeToLive` sends the four or two more, and on a +//! `DELETING` table it skips the TTL read after the write and the index +//! statements. + +use extenddb_core::error::DynamoDbError; +use extenddb_core::types::{DescribeTableInput, TableStatus}; +use extenddb_storage::error::StorageError; + +use crate::OperationContext; + +/// Read `table_name`'s current status from the catalog. +/// +/// # Errors +/// +/// Returns [`StorageError::TableNotFound`] when no catalog row exists, and +/// whatever the backend raised otherwise. The caller maps it, so each operation +/// keeps the message it already answers for a missing table. +pub(crate) async fn read_table_status( + table_name: &str, + ctx: &OperationContext, +) -> Result { + let desc = ctx + .storage + .describe_table( + &ctx.account_id, + DescribeTableInput { + table_name: table_name.to_owned(), + }, + ) + .await?; + Ok(desc.table_status) +} + +/// The service's refusal of a change to a table that is being deleted, worded +/// once in [`extenddb_core::error::ErrorMessageKey::TableBeingDeleted`] and +/// classified the way the control-plane mappers classify +/// [`StorageError::TableBeingDeleted`]. +pub(crate) fn being_deleted(table_name: &str) -> DynamoDbError { + DynamoDbError::ResourceInUseException(extenddb_core::error::error_message( + extenddb_core::error::ErrorMessageKey::TableBeingDeleted, + &[table_name], + )) +} diff --git a/crates/engine/src/tagging.rs b/crates/engine/src/tagging.rs index 432d75dd..c0520f8b 100755 --- a/crates/engine/src/tagging.rs +++ b/crates/engine/src/tagging.rs @@ -5,7 +5,8 @@ use extenddb_core::error::DynamoDbError; use extenddb_core::types::{ - ListTagsOfResourceInput, ListTagsOfResourceOutput, TagResourceInput, UntagResourceInput, + ListTagsOfResourceInput, ListTagsOfResourceOutput, TableStatus, TagResourceInput, + UntagResourceInput, }; use serde_json::Value; @@ -34,6 +35,15 @@ fn tag_subresource_not_table_or_stream(resource: &str) -> bool { !matches!(segs.next(), None | Some("") | Some("stream")) } +/// True for a stream ARN, `table//stream/