diff --git a/crates/core/src/error/mod.rs b/crates/core/src/error/mod.rs index 845988b37..071d15278 100755 --- a/crates/core/src/error/mod.rs +++ b/crates/core/src/error/mod.rs @@ -40,8 +40,10 @@ pub enum DynamoDbError { #[error("{0}")] TableInUseException(String), /// HTTP 400. `CreateBackup` on a table that does not exist or is not yet - /// `ACTIVE`. Measured on DynamoDB: `Table not found: `. Distinct - /// from `ResourceNotFoundException`, which the data plane returns. + /// `ACTIVE`, and `DescribeContinuousBackups`, `UpdateContinuousBackups` + /// and `RestoreTableToPointInTime` on a table that does not exist. + /// Measured on DynamoDB: `Table not found: `. Distinct from + /// `ResourceNotFoundException`, which the data plane returns. #[error("{0}")] TableNotFoundException(String), /// A per-table or per-account limit was exceeded. @@ -89,6 +91,21 @@ pub enum DynamoDbError { /// Returned when a shard iterator has expired (older than 15 minutes). #[error("{0}")] ExpiredIteratorException(String), + /// SP-ERR-002: HTTP 400. Returned when continuous backups (point-in-time + /// recovery) cannot be enabled. The service models this exception on + /// `UpdateContinuousBackups`; ExtendDB returns it there for + /// `PointInTimeRecoveryEnabled: true`, because no storage backend + /// implements point-in-time recovery. + #[error("{0}")] + ContinuousBackupsUnavailableException(String), + /// SP-ERR-002: HTTP 400. Returned by `RestoreTableToPointInTime` when + /// point-in-time recovery is not enabled for the source table, which in + /// ExtendDB is always: no storage backend implements point-in-time + /// recovery. The service models this exception on the restore operation, + /// distinct from `ContinuousBackupsUnavailableException`, which it models + /// on `UpdateContinuousBackups`. + #[error("{0}")] + PointInTimeRecoveryUnavailableException(String), #[error("{0}")] ServiceUnavailable(String), /// SP-ERR-002: Concurrent modification of the same item in a transaction. @@ -146,6 +163,8 @@ impl DynamoDbError { | Self::AccessDeniedException(_) | Self::ExpiredTokenException(_) | Self::ExpiredIteratorException(_) + | Self::ContinuousBackupsUnavailableException(_) + | Self::PointInTimeRecoveryUnavailableException(_) | Self::TransactionConflictException(_) | Self::ProvisionedThroughputExceededException(_) | Self::ThrottlingException(_) @@ -196,6 +215,12 @@ impl DynamoDbError { Self::UnrecognizedClientException(_) => "UnrecognizedClientException", Self::ExpiredTokenException(_) => "ExpiredTokenException", Self::ExpiredIteratorException(_) => "ExpiredIteratorException", + Self::ContinuousBackupsUnavailableException(_) => { + "ContinuousBackupsUnavailableException" + } + Self::PointInTimeRecoveryUnavailableException(_) => { + "PointInTimeRecoveryUnavailableException" + } Self::ServiceUnavailable(_) => "ServiceUnavailable", Self::TransactionConflictException(_) => "TransactionConflictException", Self::ProvisionedThroughputExceededException(_) => { @@ -265,6 +290,8 @@ impl DynamoDbError { | Self::UnrecognizedClientException(m) | Self::ExpiredTokenException(m) | Self::ExpiredIteratorException(m) + | Self::ContinuousBackupsUnavailableException(m) + | Self::PointInTimeRecoveryUnavailableException(m) | Self::ServiceUnavailable(m) | Self::TransactionConflictException(m) | Self::ProvisionedThroughputExceededException(m) @@ -379,6 +406,15 @@ mod tests { (DynamoDbError::ValidationException(String::new()), 400), (DynamoDbError::ExpiredTokenException(String::new()), 400), (DynamoDbError::ExpiredIteratorException(String::new()), 400), + ( + DynamoDbError::ContinuousBackupsUnavailableException(String::new()), + 400, + ), + ( + DynamoDbError::PointInTimeRecoveryUnavailableException(String::new()), + 400, + ), + (DynamoDbError::TableNotFoundException(String::new()), 400), (DynamoDbError::InvalidSignatureException(String::new()), 400), ]; for (err, expected) in cases { diff --git a/crates/engine/src/backup.rs b/crates/engine/src/backup.rs index 67114a013..2e3780e02 100755 --- a/crates/engine/src/backup.rs +++ b/crates/engine/src/backup.rs @@ -1191,64 +1191,6 @@ fn restore_storage_err_to_dynamo(e: StorageError) -> DynamoDbError { } } -/// Handle `DescribeContinuousBackups`. -pub(crate) async fn handle_describe_continuous_backups( - body: Value, - ctx: &OperationContext, -) -> Result { - let table_name = required_str(&body, "TableName", "tableName")?; - - let desc = ctx - .storage - .describe_continuous_backups(&ctx.account_id, table_name) - .await - .map_err(storage_err_to_dynamo)?; - - serialize_output(&json!({ "ContinuousBackupsDescription": desc })) -} - -/// Handle `UpdateContinuousBackups`. -pub(crate) async fn handle_update_continuous_backups( - body: Value, - ctx: &OperationContext, -) -> Result { - let table_name = required_str(&body, "TableName", "tableName")?; - - let pitr_enabled = body - .get("PointInTimeRecoverySpecification") - .and_then(|v| v.get("PointInTimeRecoveryEnabled")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - let desc = ctx - .storage - .update_continuous_backups(&ctx.account_id, table_name, pitr_enabled) - .await - .map_err(storage_err_to_dynamo)?; - - serialize_output(&json!({ "ContinuousBackupsDescription": desc })) -} - -/// Handle `RestoreTableToPointInTime`. -/// -/// Point-in-time recovery is not yet implemented. The previous implementation -/// faked a restore by snapshotting the current table state (ignoring -/// `RestoreDateTime`), which violates tenet 1 (fidelity over features). -/// Until real PITR is implemented, return an error. There is no storage -/// method behind this; it was removed from `BackupEngine` as unreachable. -pub(crate) async fn handle_restore_table_to_point_in_time( - _body: Value, - _ctx: &OperationContext, -) -> Result { - // TODO(fidelity): Implement real PITR using PostgreSQL temporal/history - // table approach — item_history table capturing every mutation, DISTINCT ON - // query to reconstruct state at time T, 35-day retention via background - // pruning. - Err(DynamoDbError::ValidationException( - "Point-in-time recovery restore is not yet supported".to_owned(), - )) -} - /// Convert storage errors to `DynamoDB` errors. fn storage_err_to_dynamo(e: StorageError) -> DynamoDbError { match e { @@ -1286,5 +1228,11 @@ fn storage_err_to_dynamo(e: StorageError) -> DynamoDbError { } mod overrides; +mod pitr; #[cfg(test)] mod tests; + +pub(crate) use pitr::{ + handle_describe_continuous_backups, handle_restore_table_to_point_in_time, + handle_update_continuous_backups, +}; diff --git a/crates/engine/src/backup/pitr.rs b/crates/engine/src/backup/pitr.rs new file mode 100644 index 000000000..cf8c45de0 --- /dev/null +++ b/crates/engine/src/backup/pitr.rs @@ -0,0 +1,326 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `DescribeContinuousBackups`, `UpdateContinuousBackups` and +//! `RestoreTableToPointInTime`. +//! +//! No storage backend implements point-in-time recovery, so the engine answers +//! the three operations itself and consults storage only to look the table up. +//! The checks run in the service's order (measured on DynamoDB, two samples of +//! each request): +//! +//! - DescribeContinuousBackups: the `TableName` member and its constraints, +//! then the table. +//! - UpdateContinuousBackups: the `TableName` member and its constraints, +//! then `PointInTimeRecoverySpecification` and its +//! `PointInTimeRecoveryEnabled`, then the table, then the enable request. +//! - RestoreTableToPointInTime: the source member (`SourceTableName` or +//! `SourceTableArn`) and `TargetTableName`, then the source ARN's kind or +//! the `SourceTableName` constraints, then the source table, then the time +//! members, then the recovery check. The target table is never looked up: +//! the recovery check, which the service makes before any check of the +//! target, refuses every restore. +//! +//! A table that does not exist is `TableNotFoundException` with the service's +//! text, `Table not found: `, on all three. A table in any status +//! resolves, CREATING and DELETING included. + +use extenddb_core::error::DynamoDbError; +use extenddb_core::types::{ + ContinuousBackupsDescription, DescribeTableInput, PointInTimeRecoveryDescription, +}; +use extenddb_storage::error::StorageError; +use serde_json::{Value, json}; + +use super::storage_err_to_dynamo; +use crate::{OperationContext, serialize_output}; + +/// The service's text for a request without the table name member it routes +/// on: `TableName` on the two continuous-backups operations, and +/// `SourceTableName` (or `SourceTableArn`) or `TargetTableName` on the +/// restore. +const TABLE_NAME_REQUIRED: &str = + "The parameter 'TableName' is required but was not present in the request"; + +/// The one message every point-in-time recovery refusal carries. +const PITR_UNSUPPORTED_MESSAGE: &str = + "Point-in-time recovery is not supported by this storage backend"; + +/// The service's text for a restore that names its source both ways. +const SOURCE_NAMED_TWICE: &str = + "Invalid Request: Must provide exactly one of: sourceTableArn, sourceTableName"; + +/// The service's text for a restore that gives neither time member, or +/// `UseLatestRestorableTime` false without a `RestoreDateTime`. +const NO_RESTORE_TIME: &str = "Invalid Request: Either one of RestoreDateTime or \ + UseLatestRestorableTime must be specified, but not both"; + +/// The service's text for a restore that gives `RestoreDateTime` and +/// `UseLatestRestorableTime` true. +const TWO_RESTORE_TIMES: &str = "Invalid Request: Both RestoreDateTime and \ + UseLatestRestorableTime cannot be set for point-in-time restore request. Please \ + specify either RestoreDateTime time or UseLatestRestorableTime"; + +/// The model's pattern constraint on a table name, in the service's words. +const NAME_PATTERN_CONSTRAINT: &str = + "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"; + +/// The member's string value, or the service's missing-table-name refusal. +fn table_name_member<'a>(body: &'a Value, member: &str) -> Result<&'a str, DynamoDbError> { + body.get(member) + .and_then(Value::as_str) + .ok_or_else(|| DynamoDbError::ValidationException(TABLE_NAME_REQUIRED.to_owned())) +} + +/// `TableName` on the two continuous-backups operations, checked against the +/// model's constraints with the deployment's length bounds, as `CreateBackup` +/// checks its member ([`super::validate_name_member`]). An empty name fails +/// the pattern as well as the length, since the pattern needs one character: +/// the service lists both constraints for it (measured: "2 validation errors +/// detected: Value '' at 'tableName' ..."), where `validate_name_member` +/// lists only the length. A name that starts with `arn:` is left to the +/// lookup unchecked, so a table ARN keeps the answer it had before this +/// check (`TableNotFoundException` naming the ARN). +fn check_table_name(name: &str, (min, max): (usize, usize)) -> Result<(), DynamoDbError> { + if name.starts_with("arn:") { + return Ok(()); + } + if name.is_empty() { + let at = "Value '' at 'tableName' failed to satisfy constraint:"; + return Err(DynamoDbError::ValidationException(if min > 0 { + format!( + "2 validation errors detected: {at} Member must have length greater than \ + or equal to {min}; {at} {NAME_PATTERN_CONSTRAINT}" + ) + } else { + format!("1 validation error detected: {at} {NAME_PATTERN_CONSTRAINT}") + })); + } + super::validate_name_member(name, "tableName", (min, max)) +} + +/// `SourceTableName` on the restore, checked before the source table is +/// looked up. A length outside the bounds is the service's "TableName must +/// be at least characters long and at most characters long" +/// (measured with an empty name, where the bounds are the model's 3 and +/// 255; here they are the deployment's), and a name that fails the pattern +/// is the model's constraint text at `sourceTableName` (measured with a +/// table ARN as the name). +fn check_source_table_name(name: &str, (min, max): (usize, usize)) -> Result<(), DynamoDbError> { + let length = name.chars().count(); + if length < min || length > max { + return Err(DynamoDbError::ValidationException(format!( + "TableName must be at least {min} characters long and at most {max} characters long" + ))); + } + super::validate_name_member(name, "sourceTableName", (min, max)) +} + +/// The service's refusal for a missing member, by its model path. +fn member_null(path: &str) -> DynamoDbError { + DynamoDbError::ValidationException(format!( + "1 validation error detected: Value null at '{path}' \ + failed to satisfy constraint: Member must not be null" + )) +} + +/// The typed refusal for enabling point-in-time recovery. +/// +/// `ContinuousBackupsUnavailableException` is the exception the service models +/// on `UpdateContinuousBackups`, so every SDK surfaces it as a typed error a +/// caller can match on, unlike a generic `ValidationException` whose meaning +/// lives only in the message text. +fn pitr_unsupported_error() -> DynamoDbError { + DynamoDbError::ContinuousBackupsUnavailableException(PITR_UNSUPPORTED_MESSAGE.to_owned()) +} + +/// The typed refusal for `RestoreTableToPointInTime`. +/// +/// The service models `PointInTimeRecoveryUnavailableException` on this +/// operation and returns it whenever recovery is not enabled on the source +/// table, which for ExtendDB is always. The message is the service's +/// ("Point in time recovery is not enabled for table ''"). +fn pitr_restore_unavailable_error(table_name: &str) -> DynamoDbError { + DynamoDbError::PointInTimeRecoveryUnavailableException(format!( + "Point in time recovery is not enabled for table '{table_name}'" + )) +} + +/// The continuous-backups description every table reports. +/// +/// `ContinuousBackupsStatus` is always `ENABLED`, matching the service, which +/// reports `ENABLED` unconditionally. `PointInTimeRecoveryStatus` is always +/// `DISABLED` because no storage backend implements point-in-time recovery, +/// and the restorable-time fields are omitted, as the service omits them for +/// a table whose recovery is disabled. Owned by the engine rather than the +/// backends: the answer does not depend on stored state, so no backend is +/// consulted and all of them report identically. +fn default_continuous_backups_description() -> ContinuousBackupsDescription { + ContinuousBackupsDescription { + continuous_backups_status: "ENABLED".to_owned(), + point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { + point_in_time_recovery_status: "DISABLED".to_owned(), + earliest_restorable_date_time: None, + latest_restorable_date_time: None, + }), + } +} + +/// Verify a table exists in the caller's account, in any lifecycle status. +/// +/// Through `describe_table` rather than `table_key_info`, which only +/// resolves ACTIVE tables. A missing table is `TableNotFoundException` with +/// the service's text, built from the request's name rather than the +/// backend's payload, which differs by backend. +async fn require_table_exists( + ctx: &OperationContext, + table_name: &str, +) -> Result<(), DynamoDbError> { + match ctx + .storage + .describe_table( + &ctx.account_id, + DescribeTableInput { + table_name: table_name.to_owned(), + }, + ) + .await + { + Ok(_) => Ok(()), + Err(StorageError::TableNotFound(_)) => Err(DynamoDbError::TableNotFoundException(format!( + "Table not found: {table_name}" + ))), + Err(other) => Err(storage_err_to_dynamo(other)), + } +} + +/// `PointInTimeRecoverySpecification.PointInTimeRecoveryEnabled`, which the +/// model requires: a request without the specification, or with one that +/// does not hold the flag, is refused with the service's text for the +/// missing member. The JSON strings `"true"` and `"false"` are taken as the +/// booleans (the service answers 200 for `"false"`, measured); any other +/// value that is not a boolean is refused as a missing flag. +fn point_in_time_recovery_enabled(body: &Value) -> Result { + let spec = body + .get("PointInTimeRecoverySpecification") + .filter(|v| !v.is_null()) + .ok_or_else(|| member_null("pointInTimeRecoverySpecification"))?; + match spec.get("PointInTimeRecoveryEnabled") { + Some(Value::Bool(flag)) => Ok(*flag), + Some(Value::String(s)) if s == "true" => Ok(true), + Some(Value::String(s)) if s == "false" => Ok(false), + _ => Err(member_null( + "pointInTimeRecoverySpecification.pointInTimeRecoveryEnabled", + )), + } +} + +/// The restore's source table name, from `SourceTableName`, checked against +/// `bounds` ([`check_source_table_name`]), or from `SourceTableArn`, which +/// must name a table: an index, stream or backup ARN is refused with the +/// service's text. Exactly one of the two members may be given. +fn restore_source_table_name( + body: &Value, + bounds: (usize, usize), +) -> Result { + let name = body.get("SourceTableName").and_then(Value::as_str); + let arn = body.get("SourceTableArn").and_then(Value::as_str); + match (name, arn) { + (Some(name), None) => { + check_source_table_name(name, bounds)?; + Ok(name.to_owned()) + } + (None, Some(arn)) => crate::import_export::extract_table_name_from_arn(arn), + (Some(_), Some(_)) => Err(DynamoDbError::ValidationException( + SOURCE_NAMED_TWICE.to_owned(), + )), + (None, None) => Err(DynamoDbError::ValidationException( + TABLE_NAME_REQUIRED.to_owned(), + )), + } +} + +/// Exactly one of `RestoreDateTime` and `UseLatestRestorableTime` true. +fn check_restore_time_members(body: &Value) -> Result<(), DynamoDbError> { + let has_date = body.get("RestoreDateTime").is_some_and(|v| !v.is_null()); + let use_latest = body + .get("UseLatestRestorableTime") + .and_then(Value::as_bool) + .unwrap_or(false); + match (has_date, use_latest) { + (true, true) => Err(DynamoDbError::ValidationException( + TWO_RESTORE_TIMES.to_owned(), + )), + (false, false) => Err(DynamoDbError::ValidationException( + NO_RESTORE_TIME.to_owned(), + )), + _ => Ok(()), + } +} + +/// Handle `DescribeContinuousBackups`. +/// +/// Continuous backups report `ENABLED` (the service reports this +/// unconditionally) with `PointInTimeRecoveryStatus: DISABLED` and no +/// restorable-time window. +pub(crate) async fn handle_describe_continuous_backups( + body: Value, + ctx: &OperationContext, +) -> Result { + let table_name = table_name_member(&body, "TableName")?; + check_table_name(table_name, super::table_name_bounds(&ctx.limits))?; + require_table_exists(ctx, table_name).await?; + serialize_output( + &json!({ "ContinuousBackupsDescription": default_continuous_backups_description() }), + ) +} + +/// Handle `UpdateContinuousBackups`. +/// +/// Enabling point-in-time recovery is refused with +/// `ContinuousBackupsUnavailableException`, because no storage backend +/// implements it and reporting it as enabled would promise a restore that can +/// never happen. Disabling it returns the description +/// `DescribeContinuousBackups` reports. The specification is checked before +/// the table, as the service checks it. +pub(crate) async fn handle_update_continuous_backups( + body: Value, + ctx: &OperationContext, +) -> Result { + let table_name = table_name_member(&body, "TableName")?; + check_table_name(table_name, super::table_name_bounds(&ctx.limits))?; + let pitr_enabled = point_in_time_recovery_enabled(&body)?; + require_table_exists(ctx, table_name).await?; + if pitr_enabled { + return Err(pitr_unsupported_error()); + } + serialize_output( + &json!({ "ContinuousBackupsDescription": default_continuous_backups_description() }), + ) +} + +/// Handle `RestoreTableToPointInTime`. +/// +/// An existing source table is refused with +/// `PointInTimeRecoveryUnavailableException`, the exception the service models +/// on this operation (`ContinuousBackupsUnavailableException` belongs to +/// `UpdateContinuousBackups`). A previous implementation faked a restore by +/// snapshotting the current table state and ignoring `RestoreDateTime`, which +/// violates tenet 1 (fidelity over features): a restore that silently returns +/// the wrong data is worse than a refusal the caller can act on. +pub(crate) async fn handle_restore_table_to_point_in_time( + body: Value, + ctx: &OperationContext, +) -> Result { + // A missing target is reported before the source lookup (measured with a + // missing source table and no TargetTableName). + table_name_member(&body, "TargetTableName")?; + let source_table_name = + restore_source_table_name(&body, super::table_name_bounds(&ctx.limits))?; + require_table_exists(ctx, &source_table_name).await?; + check_restore_time_members(&body)?; + Err(pitr_restore_unavailable_error(&source_table_name)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/backup/pitr/tests.rs b/crates/engine/src/backup/pitr/tests.rs new file mode 100644 index 000000000..2d6f1aad6 --- /dev/null +++ b/crates/engine/src/backup/pitr/tests.rs @@ -0,0 +1,330 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +use super::{ + NO_RESTORE_TIME, SOURCE_NAMED_TWICE, TABLE_NAME_REQUIRED, TWO_RESTORE_TIMES, + check_restore_time_members, default_continuous_backups_description, + pitr_restore_unavailable_error, pitr_unsupported_error, point_in_time_recovery_enabled, + restore_source_table_name, table_name_member, +}; +use super::{check_source_table_name, check_table_name}; +use extenddb_core::error::DynamoDbError; +use serde_json::json; + +const P: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/"; + +/// The model's table name length bounds, the deployment default. +const BOUNDS: (usize, usize) = (3, 255); + +fn validation_message(result: Result) -> String { + match result { + Err(DynamoDbError::ValidationException(m)) => m, + other => panic!("expected ValidationException, got {other:?}"), + } +} + +/// The description reports continuous backups ENABLED (the service reports +/// this unconditionally) with point-in-time recovery DISABLED, and omits +/// both restorable-time fields on the wire: a disabled recovery has no +/// window, so serializing the members as null would diverge from the +/// service, which drops them entirely. +#[test] +fn the_continuous_backups_description_is_enabled_with_recovery_disabled() { + let desc = default_continuous_backups_description(); + let wire = serde_json::to_value(&desc).expect("serializes"); + assert_eq!(wire["ContinuousBackupsStatus"], "ENABLED"); + let pitr = &wire["PointInTimeRecoveryDescription"]; + assert_eq!(pitr["PointInTimeRecoveryStatus"], "DISABLED"); + assert_eq!( + pitr.as_object().map(serde_json::Map::len), + Some(1), + "a disabled recovery reports its status and nothing else: {pitr}" + ); +} + +/// The enable refusal is the typed exception the service models on +/// `UpdateContinuousBackups`, on HTTP 400, under the standard `DynamoDB` +/// wire prefix, with a message naming the reason. +#[test] +fn the_enable_refusal_is_the_typed_continuous_backups_exception() { + let err = pitr_unsupported_error(); + assert_eq!(err.status_code(), 400); + assert_eq!( + err.full_error_type(), + "com.amazonaws.dynamodb.v20120810#ContinuousBackupsUnavailableException" + ); + assert_eq!( + err.message(), + "Point-in-time recovery is not supported by this storage backend" + ); +} + +/// The restore refusal is the typed exception the service models on +/// `RestoreTableToPointInTime`, distinct from the enable refusal, with the +/// service's message. +#[test] +fn the_restore_refusal_is_the_typed_point_in_time_recovery_exception() { + let err = pitr_restore_unavailable_error("Music"); + assert_eq!(err.status_code(), 400); + assert_eq!( + err.full_error_type(), + "com.amazonaws.dynamodb.v20120810#PointInTimeRecoveryUnavailableException" + ); + assert_eq!( + err.message(), + "Point in time recovery is not enabled for table 'Music'" + ); +} + +/// A missing table name member is the service's routing text, not the model +/// validation text other backup operations give. +#[test] +fn a_missing_table_name_is_the_services_required_parameter_text() { + assert_eq!( + TABLE_NAME_REQUIRED, + "The parameter 'TableName' is required but was not present in the request" + ); + for body in [ + json!({}), + json!({ "TableName": null }), + json!({ "TableName": 7 }), + ] { + assert_eq!( + validation_message(table_name_member(&body, "TableName")), + TABLE_NAME_REQUIRED, + "{body}" + ); + } + assert_eq!( + table_name_member(&json!({ "TableName": "Music" }), "TableName").unwrap(), + "Music" + ); +} + +/// The specification and its flag are required; the service names the +/// missing one by its model path. +#[test] +fn the_recovery_specification_and_its_flag_are_required() { + for body in [ + json!({ "TableName": "t" }), + json!({ "TableName": "t", "PointInTimeRecoverySpecification": null }), + ] { + assert_eq!( + validation_message(point_in_time_recovery_enabled(&body)), + "1 validation error detected: Value null at 'pointInTimeRecoverySpecification' \ + failed to satisfy constraint: Member must not be null", + "{body}" + ); + } + for spec in [json!({}), json!({ "PointInTimeRecoveryEnabled": null })] { + let body = json!({ "TableName": "t", "PointInTimeRecoverySpecification": spec }); + assert_eq!( + validation_message(point_in_time_recovery_enabled(&body)), + "1 validation error detected: Value null at \ + 'pointInTimeRecoverySpecification.pointInTimeRecoveryEnabled' \ + failed to satisfy constraint: Member must not be null", + "{body}" + ); + } + for flag in [true, false] { + let body = json!({ + "TableName": "t", + "PointInTimeRecoverySpecification": { "PointInTimeRecoveryEnabled": flag }, + }); + assert_eq!(point_in_time_recovery_enabled(&body).unwrap(), flag); + } +} + +/// The flag given as the JSON string `"true"` or `"false"` is taken as the +/// boolean: the service answers 200 for `"false"` (M335 probe 54). Any other +/// value that is not a boolean is refused as a missing flag. +#[test] +fn the_recovery_flag_as_a_true_or_false_string_is_the_boolean() { + for (flag, expected) in [("true", true), ("false", false)] { + let body = json!({ + "TableName": "t", + "PointInTimeRecoverySpecification": { "PointInTimeRecoveryEnabled": flag }, + }); + assert_eq!( + point_in_time_recovery_enabled(&body).unwrap(), + expected, + "{flag}" + ); + } + for flag in [ + json!("TRUE"), + json!("yes"), + json!(""), + json!(1), + json!([true]), + ] { + let body = json!({ + "TableName": "t", + "PointInTimeRecoverySpecification": { "PointInTimeRecoveryEnabled": flag }, + }); + assert_eq!( + validation_message(point_in_time_recovery_enabled(&body)), + "1 validation error detected: Value null at \ + 'pointInTimeRecoverySpecification.pointInTimeRecoveryEnabled' \ + failed to satisfy constraint: Member must not be null", + "{body}" + ); + } +} + +/// `TableName` on the two continuous-backups operations takes the model's +/// constraints before the table is looked up: an empty name fails both, in +/// the service's text (M335 probes 56 and 57). A name that starts with +/// `arn:` is left to the lookup. +#[test] +fn the_table_name_takes_the_models_constraints_before_the_lookup() { + assert_eq!( + validation_message(check_table_name("", BOUNDS)), + "2 validation errors detected: Value '' at 'tableName' failed to satisfy \ + constraint: Member must have length greater than or equal to 3; Value '' at \ + 'tableName' failed to satisfy constraint: Member must satisfy regular expression \ + pattern: [a-zA-Z0-9_.-]+" + ); + assert_eq!( + validation_message(check_table_name("ab", BOUNDS)), + "1 validation error detected: Value 'ab' at 'tableName' failed to satisfy \ + constraint: Member must have length greater than or equal to 3" + ); + assert_eq!( + validation_message(check_table_name("a b", BOUNDS)), + "1 validation error detected: Value 'a b' at 'tableName' failed to satisfy \ + constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + ); + // The deployment's bounds, as CreateTable and CreateBackup take them. + assert_eq!( + validation_message(check_table_name("abcd", (5, 10))), + "1 validation error detected: Value 'abcd' at 'tableName' failed to satisfy \ + constraint: Member must have length greater than or equal to 5" + ); + assert_eq!( + validation_message(check_table_name("", (0, 10))), + "1 validation error detected: Value '' at 'tableName' failed to satisfy \ + constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + ); + for ok in [ + "Music", + "a.b-c_D", + &format!("{P}Music"), + &format!("{P}Music/index/i"), + ] { + assert!(check_table_name(ok, BOUNDS).is_ok(), "{ok}"); + } +} + +/// `SourceTableName` on the restore: a length outside the bounds is the +/// service's "TableName must be at least ..." text (M335 probe 58, an empty +/// name), and a name that fails the pattern is the model's constraint text at +/// `sourceTableName` (probe 46, a table ARN as the name). Both come after +/// the member checks and before the lookup. +#[test] +fn the_restore_source_name_is_checked_for_length_then_pattern() { + let length = "TableName must be at least 3 characters long and at most 255 characters long"; + for name in [String::new(), "ab".to_owned(), "x".repeat(256)] { + assert_eq!( + validation_message(check_source_table_name(&name, BOUNDS)), + length, + "{name:?}" + ); + assert_eq!( + validation_message(restore_source_table_name( + &json!({ "SourceTableName": name }), + BOUNDS + )), + length, + "{name:?}" + ); + } + assert_eq!( + validation_message(check_source_table_name("ab", (5, 10))), + "TableName must be at least 5 characters long and at most 10 characters long" + ); + let arn = format!("{P}Music"); + assert_eq!( + validation_message(restore_source_table_name( + &json!({ "SourceTableName": arn }), + BOUNDS + )), + format!( + "1 validation error detected: Value '{arn}' at 'sourceTableName' failed to \ + satisfy constraint: Member must satisfy regular expression pattern: \ + [a-zA-Z0-9_.-]+" + ) + ); + // Giving both members is reported before either is checked. + assert_eq!( + validation_message(restore_source_table_name( + &json!({ "SourceTableName": "", "SourceTableArn": arn }), + BOUNDS + )), + SOURCE_NAMED_TWICE + ); +} + +/// The source is named by exactly one member; a table ARN names it as the +/// name does, and an ARN of an index is refused with the service's text. +#[test] +fn the_restore_source_is_one_member_and_an_arn_must_name_a_table() { + assert_eq!( + restore_source_table_name(&json!({ "SourceTableName": "Music" }), BOUNDS).unwrap(), + "Music" + ); + assert_eq!( + restore_source_table_name(&json!({ "SourceTableArn": format!("{P}Music") }), BOUNDS) + .unwrap(), + "Music" + ); + assert_eq!( + validation_message(restore_source_table_name( + &json!({ "SourceTableArn": format!("{P}Music/index/byArtist") }), + BOUNDS + )), + "Invalid Request: Table ARN is invalid." + ); + for other in ["Music", "Albums"] { + assert_eq!( + validation_message(restore_source_table_name( + &json!({ + "SourceTableName": "Music", + "SourceTableArn": format!("{P}{other}"), + }), + BOUNDS + )), + SOURCE_NAMED_TWICE + ); + } + assert_eq!( + validation_message(restore_source_table_name(&json!({}), BOUNDS)), + TABLE_NAME_REQUIRED + ); +} + +/// Exactly one restore time: `RestoreDateTime`, or `UseLatestRestorableTime` +/// true. False counts as not given. +#[test] +fn the_restore_takes_exactly_one_time_member() { + let date = 1_700_000_000.0; + for body in [json!({}), json!({ "UseLatestRestorableTime": false })] { + assert_eq!( + validation_message(check_restore_time_members(&body)), + NO_RESTORE_TIME, + "{body}" + ); + } + assert_eq!( + validation_message(check_restore_time_members( + &json!({ "RestoreDateTime": date, "UseLatestRestorableTime": true }) + )), + TWO_RESTORE_TIMES + ); + for body in [ + json!({ "UseLatestRestorableTime": true }), + json!({ "RestoreDateTime": date }), + ] { + assert!(check_restore_time_members(&body).is_ok(), "{body}"); + } +} diff --git a/crates/engine/src/backup/tests.rs b/crates/engine/src/backup/tests.rs index f7bd86281..dd13791e7 100644 --- a/crates/engine/src/backup/tests.rs +++ b/crates/engine/src/backup/tests.rs @@ -11,8 +11,8 @@ use extenddb_core::error::DynamoDbError; use extenddb_core::limits::LimitsConfig; use extenddb_core::types::{ AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, BillingMode, - BillingModeSummary, ContinuousBackupsDescription, GsiDescription, GsiInput, KeySchemaElement, - KeyType, LsiInput, OnDemandThroughput, Projection, ProjectionType, ProvisionedThroughput, + BillingModeSummary, GsiDescription, GsiInput, KeySchemaElement, KeyType, LsiInput, + OnDemandThroughput, Projection, ProjectionType, ProvisionedThroughput, ProvisionedThroughputDescription, ScalarAttributeType, SourceTableDetails, StreamSpecification, StreamViewType, TableDescription, TableStatus, TimeToLiveDescription, TimeToLiveStatus, }; @@ -160,23 +160,6 @@ impl BackupEngine for Fake { } }) } - - fn describe_continuous_backups( - &self, - _: &str, - _: &str, - ) -> BoxedFuture<'_, Result> { - unused() - } - - fn update_continuous_backups( - &self, - _: &str, - _: &str, - _: bool, - ) -> BoxedFuture<'_, Result> { - unused() - } } /// What a backend reports for a CREATING target: deliberately the shape the diff --git a/crates/engine/src/import_export.rs b/crates/engine/src/import_export.rs index 7dd003761..1dce358ca 100755 --- a/crates/engine/src/import_export.rs +++ b/crates/engine/src/import_export.rs @@ -395,7 +395,11 @@ async fn wait_for_table_active( /// the service's message (measured live, `live-r2/arn-kinds-live.json`). An /// unparseable `TableArn` is refused earlier, before authorization, by /// `authz_resource::validate_operation_arns`. -fn extract_table_name_from_arn(arn: &str) -> Result { +/// +/// `RestoreTableToPointInTime` reads its `SourceTableArn` through this too, +/// with no check before authorization: an index ARN there is refused here +/// with the same message (measured on DynamoDB). +pub(crate) fn extract_table_name_from_arn(arn: &str) -> Result { match extenddb_core::arn::parse(arn) { Some(extenddb_core::arn::TableResource { table, sub: None, .. diff --git a/crates/storage-cassandra/migrations/catalog/V006__drop_continuous_backups.cql b/crates/storage-cassandra/migrations/catalog/V006__drop_continuous_backups.cql new file mode 100644 index 000000000..988a84a62 --- /dev/null +++ b/crates/storage-cassandra/migrations/catalog/V006__drop_continuous_backups.cql @@ -0,0 +1,15 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Drop the continuous_backups table (catalog version 0.0.5). +-- +-- Point-in-time recovery is reported as unsupported: DescribeContinuousBackups +-- always answers PointInTimeRecoveryStatus DISABLED, UpdateContinuousBackups +-- refuses to enable it, and RestoreTableToPointInTime refuses, all served by +-- the engine without consulting storage. The pitr_enabled flag this table +-- stored no longer drives any behavior, so the table is removed. DROP TABLE IF +-- EXISTS lets a replay of this file run again. +-- +-- NOTE: the migration runner splits files on the semicolon character, +-- including inside comments. Do not use one in a comment. +DROP TABLE IF EXISTS continuous_backups; +INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.5'); diff --git a/crates/storage-cassandra/src/backup_engine.rs b/crates/storage-cassandra/src/backup_engine.rs index 7c070934e..339885d55 100755 --- a/crates/storage-cassandra/src/backup_engine.rs +++ b/crates/storage-cassandra/src/backup_engine.rs @@ -63,10 +63,9 @@ use cdrs_tokio::query::BatchQueryBuilder; use cdrs_tokio::types::map::Map; use cdrs_tokio::types::{AsRust, IntoRustByName, rows::Row}; use extenddb_core::types::{ - AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, - ContinuousBackupsDescription, DeleteTableInput, GsiInput, KeySchemaElement, LsiInput, - PointInTimeRecoveryDescription, ProvisionedThroughput, SourceTableDetails, TableDescription, - TableKeyInfo, + AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, DeleteTableInput, + GsiInput, KeySchemaElement, LsiInput, ProvisionedThroughput, SourceTableDetails, + TableDescription, TableKeyInfo, }; use extenddb_storage::backup_definition::{ BACKUP_DEFINITION_VERSION, BackupTableDefinition, throughput_from_catalog, @@ -605,27 +604,6 @@ impl CassandraEngine { }) } - async fn table_exists_for_backup( - &self, - account_id: &str, - table_name: &str, - ) -> Result { - let query = format!( - "SELECT table_name FROM {}.tables WHERE account_id = ? AND table_name = ?", - self.catalog_keyspace() - ); - let rows = self - .session - .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) - .await - .map_err(|e| StorageError::Internal(format!("Query table: {e}")))? - .response_body() - .map_err(|e| StorageError::Internal(format!("Parse table response: {e}")))? - .into_rows() - .unwrap_or_default(); - Ok(!rows.is_empty()) - } - // ── Restore registration ──────────────────────────────────────────────── /// Register a restore target on its source backup, only while the backup @@ -1909,100 +1887,6 @@ impl BackupEngine for CassandraEngine { })? }) } - - fn describe_continuous_backups( - &self, - account_id: &str, - table_name: &str, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_owned(); - let table_name = table_name.to_owned(); - Box::pin(async move { - if !self - .table_exists_for_backup(&account_id, &table_name) - .await? - { - return Err(StorageError::TableNotFound(format!( - "Table not found: {table_name}" - ))); - } - let query = format!( - "SELECT pitr_enabled FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", - self.catalog_keyspace() - ); - let rows = self - .session - .query_with_values( - &query, - cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), - ) - .await - .map_err(|e| StorageError::Internal(format!("Query continuous backups: {e}")))? - .response_body() - .map_err(|e| StorageError::Internal(format!("Parse continuous backups: {e}")))? - .into_rows() - .unwrap_or_default(); - let pitr_enabled = rows - .first() - .and_then(|row| { - let value: Result = row.get_r_by_name("pitr_enabled"); - value.ok() - }) - .unwrap_or(false); - let now = - crate::cassandra_util::millis_to_seconds_f64(crate::cassandra_util::now_millis()); - Ok(ContinuousBackupsDescription { - continuous_backups_status: "ENABLED".to_owned(), - point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { - point_in_time_recovery_status: if pitr_enabled { - "ENABLED".to_owned() - } else { - "DISABLED".to_owned() - }, - earliest_restorable_date_time: pitr_enabled - .then_some(now - 35.0 * 24.0 * 3_600.0), - latest_restorable_date_time: pitr_enabled.then_some(now), - }), - }) - }) - } - - fn update_continuous_backups( - &self, - account_id: &str, - table_name: &str, - pitr_enabled: bool, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_owned(); - let table_name = table_name.to_owned(); - Box::pin(async move { - if !self - .table_exists_for_backup(&account_id, &table_name) - .await? - { - return Err(StorageError::TableNotFound(format!( - "Table not found: {table_name}" - ))); - } - let query = format!( - "INSERT INTO {}.continuous_backups (account_id, table_name, pitr_enabled) VALUES (?, ?, ?)", - self.catalog_keyspace() - ); - self.session - .query_with_values( - &query, - cdrs_tokio::query_values!( - account_id.as_str(), - table_name.as_str(), - pitr_enabled - ), - ) - .await - .map_err(|e| StorageError::Internal(format!("Update continuous backups: {e}")))?; - self.describe_continuous_backups(&account_id, &table_name) - .await - }) - } } #[cfg(test)] diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs index b1a5b426e..4a62507fc 100644 --- a/crates/storage-cassandra/src/delete_table.rs +++ b/crates/storage-cassandra/src/delete_table.rs @@ -164,19 +164,6 @@ impl CassandraEngine { .await .map_err(|e| StorageError::Internal(format!("Delete indexes: {e}")))?; - // Delete table-scoped continuous-backup configuration so recreating the - // same table name does not inherit stale PITR state. - let delete_continuous_backup_query = format!( - "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ? AND table_name = ?" - ); - self.session - .query_with_values( - &delete_continuous_backup_query, - cdrs_tokio::query_values!(account_id, input.table_name.as_str()), - ) - .await - .map_err(|e| StorageError::Internal(format!("Delete continuous backup state: {e}")))?; - // Delete tags for this table so they don't survive recreation under the same name. let delete_tags_query = format!("DELETE FROM {catalog_keyspace}.tags WHERE resource_arn = ?"); diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 7dbfe98c4..4f6a04e78 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -7,7 +7,7 @@ //! as the underlying database. It provides a DynamoDB-compatible API backed //! by Cassandra's distributed architecture. -pub const CATALOG_VERSION: &str = "0.0.4"; +pub const CATALOG_VERSION: &str = "0.0.5"; mod admin_store; mod authorization_store; diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs index 12423f34e..cb35b5af1 100644 --- a/crates/storage-cassandra/src/management_store/accounts.rs +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -136,14 +136,6 @@ impl CassandraCatalogStore { "delete_account_backup_index", ) .await?; - // continuous_backups uses plain INSERT — plain DELETE is safe - crate::cassandra_util::execute::( - self.session(), - &format!("DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ?"), - cdrs_tokio::query_values!(account_id), - "delete_account_continuous_backups", - ) - .await?; crate::cassandra_util::apply_lwt( self.session(), diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs index ddd6255ee..e253236cd 100644 --- a/crates/storage-cassandra/src/migrations.rs +++ b/crates/storage-cassandra/src/migrations.rs @@ -31,8 +31,9 @@ //! 3. Add the migration to the `CATALOG_MIGRATIONS` array below //! 4. Migrations are embedded at compile time via `include_str!()` //! 5. Name tables without a keyspace. The runner writes the keyspace it -//! migrates into each `CREATE TABLE`, `CREATE INDEX`, `ALTER TABLE` and -//! `INSERT INTO` statement, and rejects any other statement form. +//! migrates into each `CREATE TABLE`, `CREATE INDEX`, `ALTER TABLE`, +//! `DROP TABLE IF EXISTS` and `INSERT INTO` statement, and rejects any +//! other statement form. use cdrs_tokio::cluster::TcpConnectionManager; use cdrs_tokio::cluster::session::Session; @@ -87,6 +88,10 @@ pub(crate) const CATALOG_MIGRATIONS: &[(&str, &str)] = &[ "V005__backup_definitions_and_restore_registration.cql", include_str!("../migrations/catalog/V005__backup_definitions_and_restore_registration.cql"), ), + ( + "V006__drop_continuous_backups.cql", + include_str!("../migrations/catalog/V006__drop_continuous_backups.cql"), + ), ]; /// Embedded data migration files, applied in order. @@ -256,8 +261,9 @@ pub fn migration_statements(keyspace: &str, filename: &str, sql: &str) -> OpResu /// `statement` with the table it names written as `keyspace.table`. /// /// Recognises `CREATE TABLE [IF NOT EXISTS]`, -/// `CREATE INDEX [IF NOT EXISTS] [name] ON`, `ALTER TABLE [IF EXISTS]` and -/// `INSERT INTO`, each naming an unquoted table without a keyspace. Any other +/// `CREATE INDEX [IF NOT EXISTS] [name] ON`, `ALTER TABLE [IF EXISTS]`, +/// `DROP TABLE IF EXISTS` and `INSERT INTO`, each naming an unquoted table +/// without a keyspace. Any other /// statement is an error naming `filename`, so no statement runs in whatever /// keyspace the session has selected. fn qualify_statement(keyspace: &str, filename: &str, statement: &str) -> OpResult { @@ -324,6 +330,12 @@ fn table_name_offset(body: &str) -> Option { { return None; } + } else if is(first, "DROP") && is(second, "TABLE") { + // Only the form a replay can run again: the runner applies a file and + // records it as two separate writes, so a file can be applied twice. + if !(is(words.next(), "IF") && is(words.next(), "EXISTS")) { + return None; + } } else if !(is(first, "INSERT") && is(second, "INTO")) { return None; } @@ -441,6 +453,37 @@ mod tests { assert!(super::migration_version("005_x.cql").is_err()); } + /// The runner writes no catalog version of its own: the version a run + /// leaves is the one the last file writes, and `extenddb migrate` + /// compares it with `CATALOG_VERSION`. A file left out of + /// `CATALOG_MIGRATIONS`, or a bump without its file, leaves a stored + /// version every later run reports as pending and none converges. + #[test] + fn the_last_catalog_migration_writes_the_compiled_catalog_version() { + let (filename, sql) = super::CATALOG_MIGRATIONS + .last() + .expect("at least one catalog migration"); + let write = format!( + "INSERT INTO settings (key, value) VALUES ('catalog_version', '{}')", + crate::CATALOG_VERSION + ); + assert!( + sql.contains(&write), + "{filename} is the last catalog migration and does not write {}", + crate::CATALOG_VERSION + ); + let versions: Vec<_> = super::CATALOG_MIGRATIONS + .iter() + .filter(|(_, sql)| sql.contains("('catalog_version', '")) + .map(|(filename, _)| *filename) + .collect(); + assert_eq!( + versions.last(), + Some(filename), + "the last version write is not in the last file: {versions:?}" + ); + } + #[test] fn qualifies_each_statement_form_with_the_keyspace() { for (statement, qualified) in [ @@ -484,6 +527,14 @@ mod tests { "-- Seed settings\n// two comments\nINSERT INTO settings (key) VALUES ('a')", "-- Seed settings\n// two comments\nINSERT INTO ks_a.settings (key) VALUES ('a')", ), + ( + "DROP TABLE IF EXISTS continuous_backups", + "DROP TABLE IF EXISTS ks_a.continuous_backups", + ), + ( + "drop table if exists continuous_backups", + "drop table if exists ks_a.continuous_backups", + ), ] { let got = super::qualify_statement("ks_a", "V999__test.cql", statement).expect(statement); @@ -496,6 +547,8 @@ mod tests { for statement in [ "UPDATE settings SET value = 'x' WHERE key = 'y'", "DROP TABLE settings", + "DROP TABLE IF NOT EXISTS settings", + "DROP TABLE IF EXISTS other.settings", "CREATE TABLE other.settings (key text PRIMARY KEY)", "CREATE TABLE \"Settings\" (key text PRIMARY KEY)", "CREATE TABLE IF EXISTS settings (key text PRIMARY KEY)", diff --git a/crates/storage-cassandra/src/worker_store.rs b/crates/storage-cassandra/src/worker_store.rs index f89422c97..19d2c9ffc 100755 --- a/crates/storage-cassandra/src/worker_store.rs +++ b/crates/storage-cassandra/src/worker_store.rs @@ -140,19 +140,6 @@ impl CassandraEngine { ) .await?; - let continuous_backup_delete = format!( - "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ? AND table_name = ?" - ); - self.session - .query_with_values( - &continuous_backup_delete, - cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), - ) - .await - .map_err(|e| { - StorageError::Internal(format!("Failed to delete continuous backup state: {e}")) - })?; - // Delete table row — LWT to avoid mixing plain/LWT writes on a row // created with IF NOT EXISTS. See ADR-0021. let table_delete = format!( diff --git a/crates/storage-cassandra/tests/direct/backup_engine.rs b/crates/storage-cassandra/tests/direct/backup_engine.rs index 3f0ef92de..d468a1df2 100644 --- a/crates/storage-cassandra/tests/direct/backup_engine.rs +++ b/crates/storage-cassandra/tests/direct/backup_engine.rs @@ -596,33 +596,6 @@ async fn test_table_name_filter_spans_table_recreation() { .expect("delete backup"); } -#[tokio::test] -async fn test_continuous_backup_state() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let table = TestTable::new(&engine, "ContinuousBackupState", false).await; - - let initial = engine - .describe_continuous_backups(&table.key_info.account_id, &table.key_info.table_name) - .await - .expect("describe default continuous backup state"); - assert_eq!(initial.continuous_backups_status, "ENABLED"); - let initial_pitr = initial.point_in_time_recovery_description.unwrap(); - assert_eq!(initial_pitr.point_in_time_recovery_status, "DISABLED"); - assert!(initial_pitr.earliest_restorable_date_time.is_none()); - - let enabled = engine - .update_continuous_backups(&table.key_info.account_id, &table.key_info.table_name, true) - .await - .expect("enable PITR state"); - let enabled_pitr = enabled.point_in_time_recovery_description.unwrap(); - assert_eq!(enabled_pitr.point_in_time_recovery_status, "ENABLED"); - assert!(enabled_pitr.earliest_restorable_date_time.is_some()); - assert!(enabled_pitr.latest_restorable_date_time.is_some()); -} - /// (a) A table with a GSI (sparse, INCLUDE projection) and an LSI, more than /// one backup bucket of items: the restore reaches ACTIVE with every item, /// and a Query on each index returns exactly what it returns on the source. diff --git a/crates/storage-cassandra/tests/direct/keyspace_migrations.rs b/crates/storage-cassandra/tests/direct/keyspace_migrations.rs index eddd3f354..3bbdb13bb 100644 --- a/crates/storage-cassandra/tests/direct/keyspace_migrations.rs +++ b/crates/storage-cassandra/tests/direct/keyspace_migrations.rs @@ -21,9 +21,9 @@ use cdrs_tokio::cluster::connection_pool::ConnectionPoolConfigBuilder; use cdrs_tokio::cluster::session::{SessionBuilder, TcpSessionBuilder}; use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; use extenddb_storage::error::StorageError; -use extenddb_storage_cassandra::CassandraSession; use extenddb_storage_cassandra::cassandra_util::{get_column, query_rows}; use extenddb_storage_cassandra::migrations::{run_catalog_migrations, run_data_migrations}; +use extenddb_storage_cassandra::{CATALOG_VERSION, CassandraSession}; /// Connections per node, the `max_connections` default. const POOL_SIZE: usize = 10; @@ -37,9 +37,13 @@ struct Schema { indexes: &'static [&'static str], /// `(table, column)` pairs that `ALTER TABLE` statements add. added_columns: &'static [(&'static str, &'static str)], + /// Tables an earlier file creates and a later one drops, which the run + /// must not leave. + dropped_tables: &'static [&'static str], } -/// Catalog migrations V001 to V005. +/// Catalog migrations V001 to V006 (V006 drops `continuous_backups`, which +/// V002 created). const CATALOG: Schema = Schema { tables: &[ "access_keys", @@ -48,7 +52,6 @@ const CATALOG: Schema = Schema { "backups_by_account", "backups_by_arn", "backups_by_table", - "continuous_backups", "iam_group_members", "iam_groups", "iam_permissions_boundaries", @@ -78,6 +81,7 @@ const CATALOG: Schema = Schema { ("tables", "restore_source_table_arn"), ("tables", "ttl_backfill_cursor"), ], + dropped_tables: &["continuous_backups"], }; /// Data migrations V001 and V002. @@ -98,6 +102,7 @@ const DATA: Schema = Schema { ], indexes: &["idx_txn_started_at", "stream_shards_table_id_idx"], added_columns: &[], + dropped_tables: &[], }; /// A session built as the engine builds it, with `POOL_SIZE` connections per node. @@ -227,9 +232,45 @@ async fn problems_in( )); } } + for table in schema + .dropped_tables + .iter() + .filter(|t| tables.contains(**t)) + { + problems.push(format!( + "{keyspace}: holds {table}, which a later migration drops" + )); + } problems } +/// A line if the catalog in `keyspace` does not hold the compiled +/// `CATALOG_VERSION` under `settings.catalog_version`. The runner writes no +/// version of its own, so the stored one is what the last applied file wrote, +/// and `extenddb migrate` compares it with the compiled constant. +async fn catalog_version_problem( + session: &Arc, + keyspace: &str, +) -> Option { + let query = format!("SELECT value FROM {keyspace}.settings WHERE key = 'catalog_version'"); + let rows = query_rows::( + session, + &query, + cdrs_tokio::query_values!(), + "catalog version read", + ) + .await + .expect("catalog version read"); + let stored = rows + .first() + .map(|row| get_column::(row, "value", "catalog version read")) + .transpose() + .expect("catalog_version value"); + (stored.as_deref() != Some(CATALOG_VERSION)).then(|| { + format!("{keyspace}: catalog_version {stored:?}, the binary expects {CATALOG_VERSION}") + }) +} + /// Before every migration run the session selects another keyspace with /// `USE`, as an earlier runner on the same session did before #632: /// `extenddb init` and `extenddb migrate` run the catalog migrations and then @@ -277,6 +318,7 @@ async fn migrations_ignore_the_keyspace_another_caller_selected() { } problems.extend(problems_in(&session, catalog, &CATALOG, &DATA).await); problems.extend(problems_in(&session, account, &DATA, &CATALOG).await); + problems.extend(catalog_version_problem(&session, catalog).await); drop_keyspaces(&session, round).await; } for table in tables_in(&session, &decoy).await { @@ -324,6 +366,7 @@ async fn migrations_run_on_a_session_with_no_keyspace_and_leave_none_selected() let data_result = run_data_migrations(&session, &account).await; let mut problems = problems_in(&session, &catalog, &CATALOG, &DATA).await; problems.extend(problems_in(&session, &account, &DATA, &CATALOG).await); + problems.extend(catalog_version_problem(&session, &catalog).await); let selected = session.current_keyspace(); drop_keyspaces(&session, &keyspaces).await; diff --git a/crates/storage-mongodb/src/backup_engine.rs b/crates/storage-mongodb/src/backup_engine.rs index beedee97c..b33a83d6a 100644 --- a/crates/storage-mongodb/src/backup_engine.rs +++ b/crates/storage-mongodb/src/backup_engine.rs @@ -67,9 +67,8 @@ use serde::de::DeserializeOwned; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use extenddb_core::types::{ - AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, - ContinuousBackupsDescription, GsiInput, KeySchemaElement, LsiInput, - PointInTimeRecoveryDescription, Projection, ProvisionedThroughput, SourceTableDetails, + AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, GsiInput, + KeySchemaElement, LsiInput, Projection, ProvisionedThroughput, SourceTableDetails, StreamSpecification, TableDescription, TimeToLiveDescription, TimeToLiveStatus, }; use extenddb_storage::backup_definition::{BACKUP_DEFINITION_VERSION, BackupTableDefinition}; @@ -287,14 +286,6 @@ impl MongoEngine { } } -#[allow(clippy::cast_precision_loss)] -fn now_epoch_secs() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as f64 -} - #[allow(clippy::cast_precision_loss)] fn datetime_epoch_secs(dt: bson::DateTime) -> f64 { dt.timestamp_millis() as f64 / 1000.0 @@ -2319,94 +2310,6 @@ impl BackupEngine for MongoEngine { } }) } - - fn describe_continuous_backups( - &self, - account_id: &str, - table_name: &str, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_string(); - let table_name = table_name.to_string(); - Box::pin(async move { - let tables_coll = self.catalog_db.collection::("tables"); - let exists = tables_coll - .find_one(doc! { "_id": { "account_id": &account_id, "table_name": &table_name } }) - .await - .map_err(internal)?; - - if exists.is_none() { - return Err(StorageError::TableNotFound(table_name)); - } - - let cb_coll = self.catalog_db.collection::("continuous_backups"); - let pitr_doc = cb_coll - .find_one(doc! { "account_id": &account_id, "table_name": &table_name }) - .await - .map_err(internal)?; - - let pitr_enabled = pitr_doc - .as_ref() - .and_then(|d| d.get_bool("pitr_enabled").ok()) - .unwrap_or(false); - - let now_epoch = now_epoch_secs(); - - Ok(ContinuousBackupsDescription { - continuous_backups_status: "ENABLED".to_owned(), - point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { - point_in_time_recovery_status: if pitr_enabled { - "ENABLED".to_owned() - } else { - "DISABLED".to_owned() - }, - earliest_restorable_date_time: if pitr_enabled { - Some(now_epoch - 35.0 * 24.0 * 3600.0) - } else { - None - }, - latest_restorable_date_time: if pitr_enabled { Some(now_epoch) } else { None }, - }), - }) - }) - } - - fn update_continuous_backups( - &self, - account_id: &str, - table_name: &str, - pitr_enabled: bool, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_string(); - let table_name = table_name.to_string(); - Box::pin(async move { - let tables_coll = self.catalog_db.collection::("tables"); - let exists = tables_coll - .find_one(doc! { "_id": { "account_id": &account_id, "table_name": &table_name } }) - .await - .map_err(internal)?; - - if exists.is_none() { - return Err(StorageError::TableNotFound(table_name.clone())); - } - - let cb_coll = self.catalog_db.collection::("continuous_backups"); - cb_coll - .update_one( - doc! { "account_id": &account_id, "table_name": &table_name }, - doc! { "$set": { - "account_id": &account_id, - "table_name": &table_name, - "pitr_enabled": pitr_enabled, - }}, - ) - .upsert(true) - .await - .map_err(internal)?; - - self.describe_continuous_backups(&account_id, &table_name) - .await - }) - } } #[cfg(test)] diff --git a/crates/storage-mongodb/src/bootstrapper.rs b/crates/storage-mongodb/src/bootstrapper.rs index c37c3f36f..1a6bea585 100644 --- a/crates/storage-mongodb/src/bootstrapper.rs +++ b/crates/storage-mongodb/src/bootstrapper.rs @@ -16,6 +16,29 @@ use extenddb_storage::management_store::{OpError, OpResult}; pub struct MongoBootstrapper { client: mongodb::Client, connection_string: String, + /// The catalog database, `extenddb_catalog` (a test's own in the live + /// tests). + catalog: mongodb::Database, +} + +/// The catalog collection that stored each table's point-in-time recovery +/// flag. The engine answers the point-in-time recovery operations without +/// storage, so a fresh catalog does not create it and `extenddb migrate +/// --yes` drops it from a catalog an earlier build created. The catalog +/// version (`0.0.2`) does not change for this. +const STALE_CONTINUOUS_BACKUPS: &str = "continuous_backups"; + +impl MongoBootstrapper { + /// Whether the catalog still holds [`STALE_CONTINUOUS_BACKUPS`]. + async fn has_stale_continuous_backups(&self) -> OpResult { + let names = self + .catalog_db() + .list_collection_names() + .filter(doc! { "name": STALE_CONTINUOUS_BACKUPS }) + .await + .map_err(|e| OpError::Internal(format!("list collections: {e}")))?; + Ok(!names.is_empty()) + } } impl MongoBootstrapper { @@ -45,13 +68,14 @@ impl MongoBootstrapper { let client = crate::connect_guarded(&connection_string, None, false).await?; Ok(Self { + catalog: client.database("extenddb_catalog"), client, connection_string, }) } fn catalog_db(&self) -> mongodb::Database { - self.client.database("extenddb_catalog") + self.catalog.clone() } fn data_db(&self) -> mongodb::Database { @@ -207,7 +231,6 @@ impl Bootstrapper for MongoBootstrapper { "metrics", "login_attempts", "backups", - "continuous_backups", ]; for coll_name in collections { @@ -378,15 +401,36 @@ impl Bootstrapper for MongoBootstrapper { async fn run_data_migrations(&self) -> OpResult<()> { // Data database schema is minimal for MongoDB (just idempotency_tokens) - // Table collections are created on-demand + // Table collections are created on-demand. + // + // `extenddb migrate --yes` reaches this on every run, whatever the + // catalog version, so the one catalog step MongoDB has without a + // version of its own runs here: dropping the collection that held the + // point-in-time recovery flag, which nothing reads. `serve` never + // calls this. + if self.has_stale_continuous_backups().await? { + println!("--- Dropping the unused {STALE_CONTINUOUS_BACKUPS} collection..."); + self.catalog_db() + .collection::(STALE_CONTINUOUS_BACKUPS) + .drop() + .await + .map_err(|e| OpError::Internal(format!("drop {STALE_CONTINUOUS_BACKUPS}: {e}")))?; + } Ok(()) } async fn pending_data_migrations(&self) -> OpResult> { // MongoDB has no versioned data-database migrations — table // collections are created on-demand, and the idempotency_tokens - // collection is created in create_data_db. Nothing to apply. - Ok(Vec::new()) + // collection is created in create_data_db. The one pending step is + // the stale catalog collection [`Self::run_data_migrations`] drops. + let mut pending = Vec::new(); + if self.has_stale_continuous_backups().await? { + pending.push(format!( + "catalog: drop the {STALE_CONTINUOUS_BACKUPS} collection" + )); + } + Ok(pending) } async fn record_data_connection(&self) -> OpResult<()> { @@ -666,6 +710,10 @@ fn redact_connection_string(uri: &str) -> String { ) } +#[cfg(test)] +#[path = "bootstrapper_live_tests.rs"] +mod live_tests; + #[cfg(test)] mod tests { use super::redact_connection_string; diff --git a/crates/storage-mongodb/src/bootstrapper_live_tests.rs b/crates/storage-mongodb/src/bootstrapper_live_tests.rs new file mode 100644 index 000000000..76e3f0b82 --- /dev/null +++ b/crates/storage-mongodb/src/bootstrapper_live_tests.rs @@ -0,0 +1,74 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Live MongoDB test of the catalog step `extenddb migrate --yes` runs in +//! `run_data_migrations`: dropping the `continuous_backups` collection that a +//! catalog created by an earlier build holds. +//! +//! It reads the replica set URI from `EXTENDDB_TEST_MONGODB_URI` and skips +//! without it; with `EXTENDDB_TEST_MONGODB_REQUIRED` set a missing URI is a +//! failure. It works in a catalog database of its own (a random name, opened +//! with [`crate::catalog_database_named`] and dropped at the end), never in +//! `extenddb_catalog`. + +use extenddb_storage::bootstrapper::Bootstrapper; + +use super::{MongoBootstrapper, STALE_CONTINUOUS_BACKUPS}; + +/// The replica set URI, or `None` to skip. +fn uri() -> Option { + let uri = std::env::var("EXTENDDB_TEST_MONGODB_URI") + .ok() + .filter(|u| !u.trim().is_empty()); + assert!( + uri.is_some() || std::env::var_os("EXTENDDB_TEST_MONGODB_REQUIRED").is_none(), + "EXTENDDB_TEST_MONGODB_REQUIRED is set but EXTENDDB_TEST_MONGODB_URI is not; the \ + MongoDB bootstrapper test must run, not skip" + ); + uri +} + +/// A catalog that holds the collection is reported as pending, `migrate +/// --yes` drops it, and then nothing is pending and a second run drops +/// nothing. +#[tokio::test] +async fn live_migrate_drops_the_continuous_backups_collection() { + let Some(uri) = uri() else { + return; + }; + let client = mongodb::Client::with_uri_str(&uri).await.expect("client"); + let name = format!("t{}_cat", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let catalog = crate::catalog_database_named(&client, &name); + let bootstrapper = MongoBootstrapper { + client: client.clone(), + connection_string: uri, + catalog: catalog.clone(), + }; + catalog + .create_collection(STALE_CONTINUOUS_BACKUPS) + .await + .expect("create the collection an earlier build created"); + + let pending_before = bootstrapper.pending_data_migrations().await; + let first_run = bootstrapper.run_data_migrations().await; + let left = catalog.list_collection_names().await; + let pending_after = bootstrapper.pending_data_migrations().await; + let second_run = bootstrapper.run_data_migrations().await; + catalog.drop().await.expect("drop the test catalog"); + + assert_eq!( + pending_before.expect("pending_data_migrations before the run"), + ["catalog: drop the continuous_backups collection"] + ); + first_run.expect("run_data_migrations"); + let left = left.expect("list_collection_names"); + assert!( + !left.iter().any(|c| c == STALE_CONTINUOUS_BACKUPS), + "the run left {STALE_CONTINUOUS_BACKUPS}: {left:?}" + ); + assert_eq!( + pending_after.expect("pending_data_migrations after the run"), + Vec::::new() + ); + second_run.expect("a second run with nothing to drop"); +} diff --git a/crates/storage-postgres/migrations/004_drop_continuous_backups.sql b/crates/storage-postgres/migrations/004_drop_continuous_backups.sql new file mode 100644 index 000000000..c141447a1 --- /dev/null +++ b/crates/storage-postgres/migrations/004_drop_continuous_backups.sql @@ -0,0 +1,23 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Migration 004: drop the continuous_backups table (catalog version 0.0.5). +-- +-- Point-in-time recovery is reported as unsupported: DescribeContinuousBackups +-- always answers PointInTimeRecoveryStatus DISABLED, UpdateContinuousBackups +-- refuses to enable it, and RestoreTableToPointInTime refuses, all served by +-- the engine without consulting storage. The pitr_enabled flag this table +-- stored no longer drives any behavior, so the table is removed. +-- +-- Written to tolerate a replay, like 002 and 003: the runner applies a +-- migration and records it in `schema_history` as two separate commits, so a +-- crash in between leaves this file applied but unrecorded, and the next +-- migrate runs it again. DROP TABLE IF EXISTS and the version UPDATE are both +-- idempotent. + +BEGIN; + +DROP TABLE IF EXISTS continuous_backups; + +UPDATE settings SET value = '0.0.5' WHERE key = 'catalog_version'; + +COMMIT; diff --git a/crates/storage-postgres/src/backup_engine.rs b/crates/storage-postgres/src/backup_engine.rs index 3255dbb2e..6d93da862 100755 --- a/crates/storage-postgres/src/backup_engine.rs +++ b/crates/storage-postgres/src/backup_engine.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use std::sync::Arc; use extenddb_core::types::{ - BackupDescription, BackupDetails, BackupSummary, ContinuousBackupsDescription, GsiInput, Item, - KeySchemaElement, LsiInput, PointInTimeRecoveryDescription, Projection, RestoreSummary, - ScalarAttributeType, SourceTableDetails, StreamSpecification, TableDescription, + BackupDescription, BackupDetails, BackupSummary, GsiInput, Item, KeySchemaElement, LsiInput, + Projection, RestoreSummary, ScalarAttributeType, SourceTableDetails, StreamSpecification, + TableDescription, }; use extenddb_storage::backup_definition::{ BACKUP_DEFINITION_VERSION, BackupTableDefinition, COPY_BATCH_BYTES, COPY_BATCH_ITEMS, @@ -1653,107 +1653,6 @@ impl BackupEngine for PostgresEngine { })? }) } - - fn describe_continuous_backups( - &self, - account_id: &str, - table_name: &str, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_string(); - let table_name = table_name.to_string(); - Box::pin(async move { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM tables WHERE account_id = $1 AND table_name = $2)", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_one(&self.pool) - .await - .map_err(|e| StorageError::Internal(format!("Database error: {e}")))?; - - if !exists { - return Err(StorageError::TableNotFound(format!( - "Table not found: {table_name}" - ))); - } - - let pitr_row: Option<(bool,)> = sqlx::query_as( - "SELECT pitr_enabled FROM continuous_backups \ - WHERE account_id = $1 AND table_name = $2", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_optional(&self.pool) - .await - .map_err(|e| StorageError::Internal(format!("Database error: {e}")))?; - - let pitr_enabled = pitr_row.is_some_and(|r| r.0); - - #[allow(clippy::cast_precision_loss)] - let now_epoch = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as f64; - - Ok(ContinuousBackupsDescription { - continuous_backups_status: "ENABLED".to_owned(), - point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { - point_in_time_recovery_status: if pitr_enabled { - "ENABLED".to_owned() - } else { - "DISABLED".to_owned() - }, - earliest_restorable_date_time: if pitr_enabled { - Some(now_epoch - 35.0 * 24.0 * 3600.0) - } else { - None - }, - latest_restorable_date_time: if pitr_enabled { Some(now_epoch) } else { None }, - }), - }) - }) - } - - fn update_continuous_backups( - &self, - account_id: &str, - table_name: &str, - pitr_enabled: bool, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_string(); - let table_name = table_name.to_string(); - Box::pin(async move { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM tables WHERE account_id = $1 AND table_name = $2)", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_one(&self.pool) - .await - .map_err(|e| StorageError::Internal(format!("Database error: {e}")))?; - - if !exists { - return Err(StorageError::TableNotFound(format!( - "Table not found: {table_name}" - ))); - } - - sqlx::query( - "INSERT INTO continuous_backups (account_id, table_name, pitr_enabled) \ - VALUES ($1, $2, $3) \ - ON CONFLICT (account_id, table_name) DO UPDATE SET pitr_enabled = $3", - ) - .bind(&account_id) - .bind(&table_name) - .bind(pitr_enabled) - .execute(&self.pool) - .await - .map_err(|e| StorageError::Internal(format!("Database error: {e}")))?; - - self.describe_continuous_backups(&account_id, &table_name) - .await - }) - } } #[cfg(test)] diff --git a/crates/storage-postgres/src/lib.rs b/crates/storage-postgres/src/lib.rs index 9603d68ff..35bd1befc 100755 --- a/crates/storage-postgres/src/lib.rs +++ b/crates/storage-postgres/src/lib.rs @@ -182,7 +182,7 @@ use sqlx::postgres::PgPoolOptions; /// /// The tuple is the single source of truth. Use `CATALOG_VERSION.to_string()` /// wherever a string representation is needed. -pub const CATALOG_VERSION: CatalogVersion = CatalogVersion::new(0, 0, 4); +pub const CATALOG_VERSION: CatalogVersion = CatalogVersion::new(0, 0, 5); /// Minimum number of connections allowed per pool. /// diff --git a/crates/storage-postgres/src/migrations.rs b/crates/storage-postgres/src/migrations.rs index 952f7920a..32331f639 100755 --- a/crates/storage-postgres/src/migrations.rs +++ b/crates/storage-postgres/src/migrations.rs @@ -20,6 +20,10 @@ pub(crate) const CATALOG_MIGRATIONS: &[(&str, &str)] = &[ "003_backup_definitions.sql", include_str!("../../storage-postgres/migrations/003_backup_definitions.sql"), ), + ( + "004_drop_continuous_backups.sql", + include_str!("../../storage-postgres/migrations/004_drop_continuous_backups.sql"), + ), ]; /// The runner's convergence write, executed after the walk over @@ -539,10 +543,10 @@ mod tests { fn the_migration_count_and_the_catalog_version_agree() { assert_eq!( CATALOG_MIGRATIONS.len(), - 3, + 4, "a catalog migration was added or removed; update CATALOG_VERSION and this count" ); - assert_eq!(CATALOG_VERSION.to_string(), "0.0.4"); + assert_eq!(CATALOG_VERSION.to_string(), "0.0.5"); } /// The version the binary expects must be the version the schema writes. diff --git a/crates/storage-postgres/tests/catalog_converge.rs b/crates/storage-postgres/tests/catalog_converge.rs index feb761aef..b9b39cb45 100644 --- a/crates/storage-postgres/tests/catalog_converge.rs +++ b/crates/storage-postgres/tests/catalog_converge.rs @@ -8,9 +8,9 @@ //! (`PostgresEngine::prepare_catalog_for_serve`), waiting at most a few //! seconds for the table lock, and keeps the default; `extenddb migrate` //! reports and applies both steps. -//! - A catalog stamped 0.0.4 by a different `003_*` file: `serve` refuses it -//! naming this binary's 003; `extenddb migrate` reports that file as -//! pending and applies it. +//! - A catalog stamped with the current version whose ledger records a +//! different `003_*` file: `serve` refuses it naming this binary's 003; +//! `extenddb migrate` reports that file as pending and applies it. //! //! Requires `EXTENDDB_TEST_PG_CONNECTION_STRING` (a base URL //! `postgres://user:pass@host:port`, no database); without it every test @@ -108,8 +108,9 @@ impl Drop for DbGuard { impl Scratch { /// A catalog with 001 and 002 applied and recorded, then `third` - /// applied and recorded under `third_name`; and a data database with - /// every data migration applied and recorded. + /// applied and recorded under `third_name`, then this binary's 004, + /// which stamps the current version; and a data database with every + /// data migration applied and recorded. async fn new(third_name: &str, third: &str) -> Self { let base = base_conn().expect("caller checks base_conn() first"); let stem = format!("eddb_conv_{}", uuid::Uuid::new_v4().simple())[..24].to_owned(); @@ -144,6 +145,10 @@ impl Scratch { include_str!("../migrations/002_vector_indexes.sql"), ), (third_name, third), + ( + "004_drop_continuous_backups.sql", + include_str!("../migrations/004_drop_continuous_backups.sql"), + ), ] { sqlx::raw_sql(sql).execute(&catalog).await.expect(file); sqlx::query("INSERT INTO schema_history (filename) VALUES ($1)") @@ -305,7 +310,10 @@ async fn an_earlier_0_0_4_catalog_converges_at_serve_and_describe_table_works() return; } let s = Scratch::new("003_backup_definitions.sql", EARLIER_003).await; - assert_eq!(s.catalog_version().await, "0.0.4"); + assert_eq!( + s.catalog_version().await, + extenddb_storage_postgres::CATALOG_VERSION.to_string() + ); assert_eq!(s.column_default("source_table_arn").await, None); assert!( matches!(s.column_default("restore_date_time").await, Some(Some(_))), @@ -509,8 +517,9 @@ async fn migrate_converges_an_earlier_0_0_4_catalog() { s.cleanup().await; } -/// A catalog stamped 0.0.4 by another branch's `003_*` (recorded under its -/// own filename). The version matches, but this binary's 003 is unrecorded: +/// A catalog that ran another branch's `003_*` (recorded under its own +/// filename) and is stamped with the current version. The version matches, +/// but this binary's 003 is unrecorded: /// `serve` refuses to start, naming the file and `extenddb migrate --yes`, /// and changes nothing; `migrate` reports it and applies it; afterwards /// nothing is pending, serve starts, and DescribeTable works. @@ -525,7 +534,10 @@ async fn an_unrecorded_catalog_migration_is_pending_even_at_the_current_version( "UPDATE settings SET value = '0.0.4' WHERE key = 'catalog_version';", ) .await; - assert_eq!(s.catalog_version().await, "0.0.4"); + assert_eq!( + s.catalog_version().await, + extenddb_storage_postgres::CATALOG_VERSION.to_string() + ); let b = s.bootstrapper(); assert_eq!( b.read_catalog_version().await.expect("version").as_deref(), @@ -591,11 +603,14 @@ async fn serve_refuses_an_unreadable_catalog_version_with_the_shared_refusal() { eprintln!("SKIP serve_refuses_an_unreadable_catalog_version: no PostgreSQL"); return; } - let s = Scratch::new( - "003_tampered_version.sql", - "UPDATE settings SET value = 'garbage' WHERE key = 'catalog_version';", - ) - .await; + let tamper = "UPDATE settings SET value = 'garbage' WHERE key = 'catalog_version';"; + let s = Scratch::new("003_tampered_version.sql", tamper).await; + // The 004 that Scratch applies after the third file stamps the current + // version, so the tampered value is written again last. + sqlx::raw_sql(tamper) + .execute(&s.catalog) + .await + .expect("tamper the version"); assert_eq!(s.catalog_version().await, "garbage"); let expected = extenddb_storage::util::check_stored_catalog_version( Some("garbage"), diff --git a/crates/storage-sqlite/docs/design-decisions.md b/crates/storage-sqlite/docs/design-decisions.md index 2c121b786..169c8886c 100644 --- a/crates/storage-sqlite/docs/design-decisions.md +++ b/crates/storage-sqlite/docs/design-decisions.md @@ -124,13 +124,17 @@ only) — identical to Postgres, no change needed. `write_lock`) and a separate catalog pool (catalog + credential stores) so catalog reads don't contend with the data write lock. Only `:memory:` uses a single pinned connection (see D4). -- **Schema** is one authoritative migration mirroring Postgres `001_schema.sql` - semantics: accounts, tables, indexes, tags, settings (seeded `catalog_version` - = the compiled `CATALOG_VERSION`), stream_shards/records, seq_counters, - admin_users, full IAM (users/groups/roles/policies/boundaries/sessions), - access_keys, idempotency_tokens, metrics, login_attempts, backups/backup_items, - continuous_backups. Per-DynamoDB-table data tables (`_ddb_`) are created - dynamically by `create_table`, same as Postgres. +- **Schema** is one authoritative migration mirroring the Postgres migration + chain: accounts, tables, indexes, vector_indexes, tags, schema_history, + settings (seeded `catalog_version` = the compiled `CATALOG_VERSION`), + stream_shards/records, seq_counters, admin_users, full IAM + (users/groups/roles/policies/boundaries/sessions), access_keys, + idempotency_tokens, metrics, login_attempts, backups/backup_items, + backup_definitions, table_restores, gsi_pending. It drops the + `continuous_backups` table a database created before catalog 0.0.5 carried, + so a migrated database converges on the fresh shape. Per-DynamoDB-table data + tables (`_ddb_`) are created dynamically by `create_table`, same as + Postgres. - **Contract drift already identified to honour:** `ServerComponents` uses `credential_store` (not PR #109's `auth_provider`); `StorageConfig` requires `as_any`; `TableKeyInfo` now carries `base_key_schema`. diff --git a/crates/storage-sqlite/src/backup.rs b/crates/storage-sqlite/src/backup.rs index fca131e5c..599beb319 100644 --- a/crates/storage-sqlite/src/backup.rs +++ b/crates/storage-sqlite/src/backup.rs @@ -17,9 +17,8 @@ //! CREATING. use extenddb_core::types::{ - AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, - ContinuousBackupsDescription, GsiInput, Item, KeySchemaElement, LsiInput, - PointInTimeRecoveryDescription, Projection, SourceTableDetails, StreamSpecification, + AttributeDefinition, BackupDescription, BackupDetails, BackupSummary, GsiInput, Item, + KeySchemaElement, LsiInput, Projection, SourceTableDetails, StreamSpecification, TableDescription, TimeToLiveDescription, TimeToLiveStatus, }; use extenddb_storage::backup_definition::{ @@ -1311,95 +1310,6 @@ impl BackupEngine for SqliteEngine { })? }) } - - fn describe_continuous_backups( - &self, - account_id: &str, - table_name: &str, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_owned(); - let table_name = table_name.to_owned(); - Box::pin(async move { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM tables WHERE account_id = ? AND table_name = ?)", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_one(&self.pool) - .await - .map_err(|e| StorageError::Internal(e.to_string()))?; - if !exists { - return Err(StorageError::TableNotFound(table_name)); - } - - let pitr: Option<(bool,)> = sqlx::query_as( - "SELECT pitr_enabled FROM continuous_backups WHERE account_id = ? AND table_name = ?", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_optional(&self.pool) - .await - .map_err(|e| StorageError::Internal(e.to_string()))?; - let enabled = pitr.is_some_and(|r| r.0); - - #[allow(clippy::cast_precision_loss)] - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as f64; - - Ok(ContinuousBackupsDescription { - continuous_backups_status: "ENABLED".to_owned(), - point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { - point_in_time_recovery_status: if enabled { "ENABLED" } else { "DISABLED" } - .to_owned(), - earliest_restorable_date_time: enabled.then_some(now - 35.0 * 24.0 * 3600.0), - latest_restorable_date_time: enabled.then_some(now), - }), - }) - }) - } - - fn update_continuous_backups( - &self, - account_id: &str, - table_name: &str, - pitr_enabled: bool, - ) -> BoxFuture<'_, Result> { - let account_id = account_id.to_owned(); - let table_name = table_name.to_owned(); - Box::pin(async move { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM tables WHERE account_id = ? AND table_name = ?)", - ) - .bind(&account_id) - .bind(&table_name) - .fetch_one(&self.pool) - .await - .map_err(|e| StorageError::Internal(e.to_string()))?; - if !exists { - return Err(StorageError::TableNotFound(table_name)); - } - { - // D1: every writer holds the engine write lock. Scoped so the - // read-only describe below runs after release. - let _writer = self.write_lock.lock().await; - sqlx::query( - "INSERT INTO continuous_backups (account_id, table_name, pitr_enabled) \ - VALUES (?, ?, ?) \ - ON CONFLICT (account_id, table_name) DO UPDATE SET pitr_enabled = excluded.pitr_enabled", - ) - .bind(&account_id) - .bind(&table_name) - .bind(pitr_enabled) - .execute(&self.pool) - .await - .map_err(|e| StorageError::Internal(e.to_string()))?; - } - self.describe_continuous_backups(&account_id, &table_name) - .await - }) - } } #[cfg(test)] diff --git a/crates/storage-sqlite/src/bootstrapper.rs b/crates/storage-sqlite/src/bootstrapper.rs index 293a39c8d..100d4f96d 100644 --- a/crates/storage-sqlite/src/bootstrapper.rs +++ b/crates/storage-sqlite/src/bootstrapper.rs @@ -659,13 +659,14 @@ mod migrate_report_tests { .expect("0.0.3 schema"); // Stamped as an earlier 0.0.4 build left it: version current, // backup_items still without its cursor column. - sqlx::raw_sql( + sqlx::raw_sql(&format!( "INSERT INTO backups (backup_arn, backup_name, table_id, table_name, account_id, \ key_schema, attribute_definitions) VALUES ('a', 'b', 't', 't', '0', '[]', '[]'); \ INSERT INTO backup_items (backup_arn, pk, sk, item_data) VALUES \ - ('a', '', NULL, '{}'), ('a', '', NULL, '{}'); \ - UPDATE settings SET value = '0.0.4' WHERE key = 'catalog_version';", - ) + ('a', '', NULL, '{{}}'), ('a', '', NULL, '{{}}'); \ + UPDATE settings SET value = '{}' WHERE key = 'catalog_version';", + crate::schema::CATALOG_VERSION + )) .execute(&pool) .await .expect("rows"); diff --git a/crates/storage-sqlite/src/schema.rs b/crates/storage-sqlite/src/schema.rs index d04ee5b56..cb6b3eca4 100644 --- a/crates/storage-sqlite/src/schema.rs +++ b/crates/storage-sqlite/src/schema.rs @@ -27,7 +27,7 @@ use sqlx::SqlitePool; /// Compiled-in catalog version. Single source of truth for the SQLite backend; /// mirrors the PostgreSQL backend's `CATALOG_VERSION`. pub const CATALOG_VERSION: extenddb_core::version::CatalogVersion = - extenddb_core::version::CatalogVersion::new(0, 0, 4); + extenddb_core::version::CatalogVersion::new(0, 0, 5); /// Complete catalog schema, applied once on a fresh database. /// @@ -402,15 +402,10 @@ CREATE TABLE IF NOT EXISTS table_restores ( ); CREATE INDEX IF NOT EXISTS idx_table_restores_backup ON table_restores (source_backup_arn); --- Continuous backups / PITR status. -CREATE TABLE IF NOT EXISTS continuous_backups ( - account_id TEXT NOT NULL, - table_name TEXT NOT NULL, - pitr_enabled INTEGER NOT NULL DEFAULT 0, - earliest_restorable TEXT, - latest_restorable TEXT, - PRIMARY KEY (account_id, table_name) -); +-- Point-in-time recovery is not supported, so no state is stored for it. A +-- database created before catalog 0.0.5 carried a `continuous_backups` table; +-- it is dropped here so a migrated database converges on the fresh shape. +DROP TABLE IF EXISTS continuous_backups; -- Persistent queue for async GSI propagation. A row is inserted inside the -- base write transaction (zero crash window) and consumed by a background @@ -453,7 +448,7 @@ INSERT OR IGNORE INTO seq_counters (name, value) -- never advance, so a migration could add objects and still leave the server -- refusing to start on a version mismatch. Must stay in step with -- `CATALOG_VERSION` above; they are checked against each other in a test. -INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.4') +INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.5') ON CONFLICT(key) DO UPDATE SET value = excluded.value; INSERT OR IGNORE INTO settings (key, value) VALUES ('control_plane_delay_seconds', '0.25'); INSERT OR IGNORE INTO settings (key, value) VALUES ('index_propagation_delay_ms', '10'); @@ -467,8 +462,8 @@ pub async fn apply(pool: &SqlitePool) -> OpResult<()> { refuse_unwritable_catalog(pool).await?; // Catalog 0.0.4 originally created backup_items without a stable cursor // and table_restores without source_table_arn. Converge both before - // SCHEMA_SQL stamps 0.0.4, including databases that an earlier - // 0.0.4-in-progress binary already stamped. SQLite cannot add an INTEGER + // SCHEMA_SQL stamps the compiled version, including databases that an + // earlier 0.0.4-in-progress binary already stamped. SQLite cannot add an INTEGER // PRIMARY KEY with ALTER TABLE. converge_table_shapes(pool).await?; sqlx::raw_sql(SCHEMA_SQL) diff --git a/crates/storage-sqlite/src/store.rs b/crates/storage-sqlite/src/store.rs index 075d25b28..fcbc970ca 100644 --- a/crates/storage-sqlite/src/store.rs +++ b/crates/storage-sqlite/src/store.rs @@ -682,27 +682,6 @@ mod d1_write_lock_tests { .await; } - #[tokio::test] - async fn update_continuous_backups_waits_for_the_write_lock() { - use extenddb_storage::BackupEngine; - let engine = engine().await; - // The table must exist before the lock window: the pre-lock existence - // check returns TableNotFound otherwise, completing without reaching - // the write under test. - create_table(&engine, "d1-pitr-t").await; - let e = engine.clone(); - assert_serialized( - &engine, - async move { - BackupEngine::update_continuous_backups(&e, "000000000000", "d1-pitr-t", true) - .await - .expect("update continuous backups"); - }, - "update_continuous_backups", - ) - .await; - } - /// Build a catalog store over the engine's pool and, critically, the /// engine's OWN write lock, mirroring the server wiring in `lib.rs`. The /// cached key lets access-key creation encrypt without a settings lookup. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b7aec12ff..8c6fd57b8 100755 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -704,7 +704,11 @@ pub trait WorkerStore: Send + Sync { ) -> BoxFuture<'_, Result, StorageError>>; } -/// Backup and point-in-time recovery operations. +/// Backup operations. +/// +/// Point-in-time recovery is deliberately absent: no backend implements it, so +/// the engine answers `DescribeContinuousBackups`, `UpdateContinuousBackups`, +/// and `RestoreTableToPointInTime` itself without consulting storage. pub trait BackupEngine: Send + Sync { /// Create a backup of a table, snapshotting all items. fn create_backup( @@ -797,21 +801,6 @@ pub trait BackupEngine: Send + Sync { account_id: &str, request: RestoreRequest, ) -> BoxFuture<'_, Result>; - - /// Describe continuous backups / PITR status for a table. - fn describe_continuous_backups( - &self, - account_id: &str, - table_name: &str, - ) -> BoxFuture<'_, Result>; - - /// Update continuous backups (enable/disable PITR). - fn update_continuous_backups( - &self, - account_id: &str, - table_name: &str, - pitr_enabled: bool, - ) -> BoxFuture<'_, Result>; } /// Supertrait combining all `DynamoDB` operation traits. diff --git a/docs/design/13-storage-mongodb.md b/docs/design/13-storage-mongodb.md index 5498e6cc0..b0bf86ad1 100644 --- a/docs/design/13-storage-mongodb.md +++ b/docs/design/13-storage-mongodb.md @@ -251,16 +251,9 @@ Compound index on `(principal, attempted_at)`. Index on `(account_id, table_name)`. The physical backup collection lives in `extenddb_data` as `_backup_{backup_id}` — see §4.5. -### 3.17 `continuous_backups` -```json -{ - "account_id": "...", - "table_name": "...", - "pitr_enabled": false, - "earliest_restorable": null, - "latest_restorable": null -} -``` +### 3.17 `continuous_backups` (removed) + +Earlier builds stored each table's point-in-time recovery flag here. The engine answers the point-in-time recovery operations without storage, so a fresh catalog does not create this collection and `extenddb migrate --yes` drops it from a catalog an earlier build created; `extenddb migrate` without `--yes` reports the pending drop as `catalog: drop the continuous_backups collection`. The catalog version does not change for this. ### 3.18 `schema_history` ```json diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 6bd1a1607..95d235cff 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -87,7 +87,7 @@ adaptation when switching between ExtendDB and the real service. | Vector index update propagation | Eventually consistent, the same model as a GSI | Matches DynamoDB. Maintenance is queued on the same propagation queue as async GSIs, so a search immediately after a write may not see it. Governed by the same `index_propagation_delay_ms` setting; unlike a GSI there is no per-index override. A value of 0 applies maintenance inline in the write's own transaction, which is stricter than the service and exists so a test can assert steady state without waiting. Inline additionally requires the table's propagation queue to be empty: while rows queued during an index build are still draining, a new write queues behind them instead of overtaking them, so a brief search-after-write window exists even at 0 until the queue drains. That window is ordering, not data loss; the write is applied, in order, by the queue worker. That zero behaves differently while an index is still building, and the two backends differ: **PostgreSQL** applies inline only to an index that is already `ACTIVE`, and defers a write to a building index whatever the delay says, because the write must not reach the index ahead of the backfill's older snapshot of the same item; **SQLite** does not check the status on this path, so with a zero delay a write during a backfill is applied inline and bypasses the hold that keeps the two ordered. The SQLite behaviour is tracked as a defect (F-20), not intended, and it is reachable only with the zero delay, which is a test setting. | | `SearchVectors` score at extreme magnitudes (PostgreSQL backend only) | Returns the true distance as a number for any vector of finite components | The score is bounded to a finite value instead, and the bound is not a measured service answer. pgvector accumulates distances in single precision, so magnitudes far below `f32::MAX` overflow inside the extension: Euclidean above about 9.2e18, dot product above about 1.8e19, and cosine at both ends, above about 1.8e19 and below about 3.7e-23, which is where a component's single-precision square rounds to zero. A non-finite score cannot be serialised as JSON at all, so the result is bounded in SQL: `1e308` for an overflowed distance, `-1e308` for an overflowed negated inner product, which the score contract negates so a client sees `1e308` in `Score`, and 1.0 for a cosine that comes back NaN. Ranking is unaffected at the overflow end, because each bound sits at the end its metric overflows towards, so the farthest row stays farthest and the most similar stays most similar; two rows that both overflow tie, and the tie breaks on the base key. At the underflow end cosine loses resolution rather than being bounded, and which side is tiny decides how much. For a tiny **query** vector, its norm underflows while the inner product usually does not, so the quotient is an infinity that pgvector clamps and the reported distance collapses to one of 0, 1 or 2 following the sign of the inner product, with the 1.0 substitute firing only when the vectors are exactly orthogonal and the quotient is therefore 0/0 (measured). For a tiny **stored** vector it is worse: the stored norm is computed in single precision and reaches zero, so the zero-vector guard fires and that row reports 1.0 at every angle, parallel included. A corpus of tiny embeddings therefore loses ranking altogether rather than losing resolution. For a tiny query vector, by contrast, ranking still separates nearer-than-orthogonal from farther and loses resolution only within each half. The SQLite backend owns its own arithmetic, computes in double precision, and reports the true value, which is why this row is scoped to PostgreSQL. | | Vector index deletion window | `UpdateTable` Delete leaves the index in `DELETING` long enough to observe, then removes it | No observable `DELETING` window on either backend: the catalog row is removed inside the `UpdateTable` transaction, so a `DescribeTable` immediately afterwards already omits the index. The index's data table is dropped after that commit, in a separate transaction, and the two backends handle a failure there differently. **PostgreSQL** treats it as best effort, because the data table lives in a different database entirely: a failure is logged and skipped rather than failing the request. **SQLite** propagates it, so a failed drop returns an error from an `UpdateTable` whose catalog change has already committed, which is the more surprising outcome of the two: the index is gone from the catalog and the caller saw a failure. So an operator debugging a leftover `_ddb_vec_*` table should look for that warning rather than assume the delete was incomplete. | -| Backing up and restoring a table that has vector indexes | Restores the table with its vector indexes intact: the configuration survives, items keep their vector attributes, and `SearchVectors` works as soon as the table is `ACTIVE` (measured) | Not supported. On PostgreSQL and SQLite, the two backends with vector indexes, `CreateBackup` of such a table is refused with `ValidationException` "table has vector index(es); restoring a table with vector indexes is not supported by this storage backend", and no backup is created, because a restore cannot carry the index data across. A backup of a vector table taken before catalog 0.0.4 is refused at restore on PostgreSQL with the same message naming the backup; on SQLite such a backup restores without its vector indexes. A backup of a table with no vector indexes restores normally. | +| Backing up and restoring a table that has vector indexes | Restores the table with its vector indexes intact: the configuration survives, items keep their vector attributes, and `SearchVectors` works as soon as the table is `ACTIVE` (measured) | Not supported. On PostgreSQL and SQLite, the two backends with vector indexes, `CreateBackup` of such a table is refused with `ValidationException` "table has vector index(es); restoring a table with vector indexes is not supported by this storage backend", and no backup is created, because a restore cannot carry the index data across. A backup of a vector table taken before catalog 0.0.4 is refused at restore on PostgreSQL with the same message naming the backup; on SQLite such a backup restores without its vector indexes. A backup of a table with no vector indexes restores normally. | | `SearchVectors` endpoint | Served only on `search-dynamodb..amazonaws.com`. The standard `dynamodb..amazonaws.com` endpoint answers the same request with HTTP 400 `UnknownOperationException` ("This operation is not supported by this endpoint"); every control-plane and item operation stays on the standard endpoint. Signing is unchanged either way (service name `dynamodb`, target prefix `DynamoDB_20120810`) | Served on the same endpoint as every other operation, so a client is pointed at one ExtendDB endpoint for all of them. Two consequences worth knowing: an SDK that resolves a separate search hostname from its endpoint ruleset needs its endpoint overridden to reach ExtendDB, and ExtendDB does **not** reproduce the service's refusal, so a test asserting `UnknownOperationException` for vector search on the base endpoint passes against Amazon DynamoDB and fails here. | | `SearchVectors` result order for equal distances | Measured unstable: three identical searches returned tied rows in three different orders, and a top-k that truncates a tie group keeps an arbitrary subset of it | Deterministic on both backends, by different means. **PostgreSQL** sorts explicitly on the base table's full primary key after the score, so the order is a property of the query. **SQLite** issues no `ORDER BY` and resolves ties by scan order through a stable top-k, so its order is a property of the plan rather than something the query guarantees. Either way a client re-issuing an identical search sees the same order, which is stricter than the service rather than divergent in outcome. Do not rely on the two backends agreeing on which subset of a truncated tie group they keep. | | Vector indexes on the MongoDB backend | Vector indexes and `SearchVectors` are available | Not supported at all. That backend provides no vector search implementation, so the engine's capability gate refuses `CreateTable` and `UpdateTable` carrying `VectorIndexes` and every `SearchVectors` request, with the same capability message a PostgreSQL deployment without pgvector returns. Every other vector row in this document describes the PostgreSQL and SQLite backends. | @@ -102,7 +102,7 @@ Behaviour stated as measured, and every number below, was observed with release | Area | DynamoDB | ExtendDB | |------|----------|------| -| What a restore carries across | Key schema, attribute definitions, items, secondary indexes, billing mode and provisioned throughput, on-demand throughput (table and GSI level), SSE settings. Table class is not carried: a restore is `STANDARD` whatever the source's class (measured). Stream settings, TTL, tags, auto scaling, IAM policies, and CloudWatch settings must be reapplied by hand | The same set on all four backends, with one gap: the table's on-demand throughput is carried, a GSI's is not (ExtendDB records no per-index on-demand maximum, and `OnDemandThroughput` is not a member of its GSI definition). Table class is not restored: a `STANDARD_INFREQUENT_ACCESS` source restores as `STANDARD`, as on the service. Streams, TTL, tags, and deletion protection are not restored. A backup written before catalog 0.0.4 (PostgreSQL, SQLite, Cassandra) has no recorded table definition and restores with its keys, items, and billing mode but no secondary indexes. A provisioned one restores with 5/5 throughput on PostgreSQL and SQLite and with its recorded throughput on Cassandra | +| What a restore carries across | Key schema, attribute definitions, items, secondary indexes, billing mode and provisioned throughput, on-demand throughput (table and GSI level), SSE settings. Table class is not carried: a restore is `STANDARD` whatever the source's class (measured). Stream settings, TTL, tags, auto scaling, IAM policies, and CloudWatch settings must be reapplied by hand | The same set on all four backends, with one gap: the table's on-demand throughput is carried, a GSI's is not (ExtendDB records no per-index on-demand maximum, and `OnDemandThroughput` is not a member of its GSI definition). Table class is not restored: a `STANDARD_INFREQUENT_ACCESS` source restores as `STANDARD`, as on the service. Streams, TTL, tags, and deletion protection are not restored. A backup written before catalog 0.0.4 (PostgreSQL, SQLite, Cassandra) has no recorded table definition and restores with its keys, items, and billing mode but no secondary indexes. A provisioned one restores with 5/5 throughput on PostgreSQL and SQLite and with its recorded throughput on Cassandra | | How `RestoreTableFromBackup` runs | Returns at once with the target `CREATING`; the service copies in the background | The same on all four backends: the response carries the target `CREATING`, and the items are copied by a background task in the server, so a client that times out or disconnects does not stop it. Once the request has been authenticated and authorized, the rest of it, from reading the backup to registering the restore, runs in a task of its own, so a client that disconnects after that does not cancel it either: the restore runs and the client finds the target with `DescribeTable`. A client that disconnects while the request is still being authenticated or authorized cancels it, and nothing is created *(from the code)*. Measured with a client whose 1 ms read timeout closed its connection before any response arrived: on all four backends the restore was registered and finished. Measured with a 60,000-item backup of a table with no secondary indexes: response in 0.032 s (PostgreSQL), 0.012 s (SQLite), 0.053 s (MongoDB), 0.151 s (Cassandra); `ACTIVE` after 1.5 s, 2.1 s, 3.6 s, and 11.2 s. On Cassandra the response waits for the target table's schema to be created: 0.75 to 1.3 s with five restores running at once *(earlier build)*, which is longer than a client read timeout of 1 s. A restore runs inside the server process; if the process stops, see the abandoned-restore row | | `RestoreSummary` | The restore response and `DescribeTable` while `CREATING` report `SourceBackupArn`, `SourceTableArn`, `RestoreDateTime` (the backup's `BackupCreationDateTime`, not the time the restore ran), and `RestoreInProgress=true`. Once the table is `ACTIVE`, `DescribeTable` omits `RestoreSummary` | The same on all four backends. No response that describes a table (`CreateTable`, `UpdateTable`, `DeleteTable`, `DescribeTable`, `RestoreTableFromBackup`) carries `RestoreSummary` unless the table is `CREATING`; what the service's `UpdateTable` returns for a restored table was not measured | | Reads on a restore target | A `GetItem` or `Scan` on the target while `CREATING` is `ResourceNotFoundException`; the first read after `DescribeTable` reports `ACTIVE` succeeds (measured) | The same on all four backends. The server's key-info cache stores a negative entry (5 s TTL by default) for a name the catalog does not have, and does not store one for a name the catalog has in a state other than `ACTIVE`: on a not-found answer it asks the catalog once more with `DescribeTable`, and authorization records the name as missing in the key-info snapshot it hands the engine, so the engine does not look it up again. A `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, or `Scan` on the target while it is `CREATING` therefore costs exactly one `table_key_info` and one `describe_table` catalog load, uncached, per request (on SQLite five SQL statements: one for the key info, four for `DescribeTable`; on PostgreSQL `describe_table` is two queries), plus one `describe_table` on the first such request for the throttle registry; the first read after `ACTIVE` loads once more and is then cached. Measured on SQLite with a `GetItem` loop on a `CREATING` restore target of 100,000 items: 40 requests refused, 40 key-info and 41 `DescribeTable` loads in the server's statement log; with the hand-off to the engine removed, 80 and 81. In an earlier run of the fidelity test the first `GetItem` after `ACTIVE` answered in 3 ms after 72 refused reads. | @@ -117,8 +117,8 @@ Behaviour stated as measured, and every number below, was observed with release | Multi-part base table keys (preview) | Not supported | A backup of a table created with `enable_multipart_keys` is refused at restore with `ValidationException`; the item paths address such tables by their first HASH and RANGE attribute only *(from the code and storage-level tests; not checked on a running server)* | | `DeleteBackup` and `DeleteTable` during a restore | `DeleteTable` on the `CREATING` target is `ResourceInUseException`. `DeleteBackup` during a restore was not measured | While the target is `CREATING`, `DeleteTable` on it is `ResourceInUseException` on all four backends, as on the service. `DeleteBackup` on its source backup is `BackupInUseException` on all four: that refusal is ExtendDB's contract, not measured against the service. Both succeed once the target is `ACTIVE`. How the restore and `DeleteBackup` are ordered differs *(from the code)*. PostgreSQL and SQLite register the restore in the transaction that creates the target, holding the backup row, so a concurrent `DeleteBackup` is ordered strictly before (the restore then finds no backup) or after (it finds the restore). MongoDB has no cross-collection transaction: a restore claims the backup document, inserts the target's catalog document, and releases the claim; `DeleteBackup` claims the backup the same way. Cassandra uses two lightweight transactions on different partitions: the target's row is inserted first (`IF NOT EXISTS`, which reserves the name), then the restore is registered on the backup's row (`IF backup_status = 'AVAILABLE'`), which `DeleteBackup` claims on the same partition; a concurrent `DescribeTable` can therefore see a `CREATING` target for a restore that then fails with `BackupNotFoundException`, after which the target is removed. On MongoDB, if `DeleteBackup` returns an internal error or its request is cancelled after the backup's data drop started, the backup stays `AVAILABLE` and in use (`DeleteBackup` and `RestoreTableFromBackup` return `BackupInUseException`) until the heartbeat sweep finishes the delete, about 45 to 80 s later; it is not left restorable without its data. A `DeleteBackup` that stopped while a restore from the same backup is `CREATING` is finished by the sweep after that restore becomes `ACTIVE`. So a `DeleteBackup` that returned an error can still take effect later: besides the case above, one that failed after claiming the backup (for example because it found a restore from it `CREATING`) and whose claim could not then be released, neither in the request nor by the claim guard's release on drop, is finished by the sweep (once no restore from the backup is `CREATING`), although the client was told it failed (not reproduced) *(all from the code and storage-level tests)*. MongoDB reads the catalog from the primary with read concern `local` regardless of the connection string, which the claims rely on; the connection string must use `readPreference=primary` (any other read preference is refused at startup). The write concern follows the connection string and must be `majority` (the server default): a connection string with `w=1` is refused at startup, because a claim acknowledged under `w=1` just before a primary failover can be rolled back | | Concurrent restores of one backup | A second `RestoreTableFromBackup` of a backup while a restore from it is still `CREATING` is `BackupInUseException` (measured) | The same on all four backends: the second restore is refused with `BackupInUseException` and creates nothing; the check runs inside the transaction that registers the restore (PostgreSQL, with the backup row locked `FOR UPDATE`; SQLite), inside the claim on the backup document followed by a check for a `CREATING` target of the backup (MongoDB), or inside the lightweight transaction on the backup row (Cassandra), so two simultaneous requests cannot both pass. Once the first target is `ACTIVE` the backup restores again. A restore of a backup whose `DeleteBackup` is in progress answers differently by backend (the service's answer was not measured): `BackupInUseException` "Backup is being deleted" on MongoDB, where the backup stays `AVAILABLE` under a delete claim until its data is dropped; `BackupNotFoundException` on Cassandra, where the delete marks it `DELETING` first; and on PostgreSQL and SQLite the restore waits for the delete's transaction and then finds no backup (`BackupNotFoundException`). Earlier builds accepted concurrent restores of one backup on PostgreSQL, SQLite, and Cassandra, and on MongoDB after a 2 s retry | -| Restore interrupted by the server stopping | Managed by the service | The copy runs in the server process, so a stopped process leaves its target `CREATING`. Each backend removes such a target without operator action, logs the removal with the table name (at warn; at info on Cassandra), and then lets `DeleteBackup` and a new restore into the name succeed. Measured after `SIGKILL` mid-copy and an immediate restart *(earlier build)*: **PostgreSQL** removes it on a control-plane pass once the target is older than 60 s and its restore lock is free (removed 60 s after the kill). **SQLite** removes it when the next server starts (0.1 s after the restart). **MongoDB** and **Cassandra** remove it once its owner's heartbeat is more than 60 s old, by a sweep that runs at startup and every 15 s, so about 45 to 80 s after the owner stopped (the heartbeat's last refresh can be up to one 15 s interval before the stop; measured: 60 s on MongoDB, 62 s on Cassandra). Until then `DeleteTable` and `DeleteBackup` are refused as during a running restore. Not covered *(from the code)*: on MongoDB, a target whose base copy had finished is not swept but is finished by the index backfill worker after a restart; if that backfill fails on every attempt, the worker retries it every 5 s with a warning and never gives up, so the target stays `CREATING` (and `DeleteTable` and `DeleteBackup` stay refused) until the cause is fixed or the target's catalog document is removed by hand (no failing case was found to reproduce this). On Cassandra, a target left `CREATING` by a build before catalog 0.0.4 carries no heartbeat and is not swept. The MongoDB and Cassandra heartbeats compare wall clocks across servers (MongoDB uses the database server's clock for refreshes), so a server whose clock is far behind can have a live restore swept | -| Point-in-time recovery | 35-day window | Not supported. `RestoreTableToPointInTime` is refused with `ValidationException` ("Point-in-time recovery restore is not yet supported"). `UpdateContinuousBackups` is not refused: enabling point-in-time recovery succeeds, and `DescribeContinuousBackups` then reports it `ENABLED` with an `EarliestRestorableDateTime` 35 days back, although nothing is recorded and no restore to any of those times is possible. Same on all four backends | +| Restore interrupted by the server stopping | Managed by the service | The copy runs in the server process, so a stopped process leaves its target `CREATING`. Each backend removes such a target without operator action, logs the removal with the table name (at warn; at info on Cassandra), and then lets `DeleteBackup` and a new restore into the name succeed. Measured after `SIGKILL` mid-copy and an immediate restart *(earlier build)*: **PostgreSQL** removes it on a control-plane pass once the target is older than 60 s and its restore lock is free (removed 60 s after the kill). **SQLite** removes it when the next server starts (0.1 s after the restart). **MongoDB** and **Cassandra** remove it once its owner's heartbeat is more than 60 s old, by a sweep that runs at startup and every 15 s, so about 45 to 80 s after the owner stopped (the heartbeat's last refresh can be up to one 15 s interval before the stop; measured: 60 s on MongoDB, 62 s on Cassandra). Until then `DeleteTable` and `DeleteBackup` are refused as during a running restore. Not covered *(from the code)*: on MongoDB, a target whose base copy had finished is not swept but is finished by the index backfill worker after a restart; if that backfill fails on every attempt, the worker retries it every 5 s with a warning and never gives up, so the target stays `CREATING` (and `DeleteTable` and `DeleteBackup` stay refused) until the cause is fixed or the target's catalog document is removed by hand (no failing case was found to reproduce this). On Cassandra, a target left `CREATING` by a build before catalog 0.0.4 carries no heartbeat and is not swept. The MongoDB and Cassandra heartbeats compare wall clocks across servers (MongoDB uses the database server's clock for refreshes), so a server whose clock is far behind can have a live restore swept | +| Point-in-time recovery | 35-day window: `UpdateContinuousBackups` enables continuous backups, and `RestoreTableToPointInTime` restores to any second in the window | Not supported; use on-demand backups (`CreateBackup` and `RestoreTableFromBackup`) instead. `DescribeContinuousBackups` reports `ContinuousBackupsStatus: ENABLED` (the service reports this unconditionally) with `PointInTimeRecoveryStatus: DISABLED` and no restorable-time window. `UpdateContinuousBackups` with `PointInTimeRecoveryEnabled: true` returns `ContinuousBackupsUnavailableException`; with `false` it returns the same description. `RestoreTableToPointInTime` checks the source table, then returns `PointInTimeRecoveryUnavailableException` ("Point in time recovery is not enabled for table ''"), the exception the service models on that operation. A table that does not exist is `TableNotFoundException` ("Table not found: ") on all three operations, as on the service. Same on all four backends | | Where backups live | Managed storage, independent of the table | Inside the database the server already uses, so a lost database loses its backups too: on PostgreSQL in the catalog database (`backups`, `backup_items`, `backup_definitions`, `table_restores`), on SQLite in the one database file, on MongoDB as a `backups` metadata collection in the catalog database and one data collection per backup in the data database, on Cassandra as backup metadata in the catalog keyspace and item payloads in the account's keyspace. Take database-level backups for off-host copies until the backup store program ships | | Backup creation | `CreateBackup` returns while the backup is `CREATING`; `ListBackups` and `DescribeBackup` expose that state until it becomes `AVAILABLE`. `BackupName` and `TableName` are checked as the model says: length 3 to 255 and `[a-zA-Z0-9_.-]+`, with "1 validation error detected: Value '' at 'backupName' failed to satisfy constraint: " texts, joined under "2 validation errors detected:" when both fail (measured). `TableName` may be the table's ARN (measured). A table that does not exist or is still `CREATING` is `TableNotFoundException` "Table not found: " (measured) | `CreateBackup` copies the items before it returns and returns the backup `AVAILABLE` on all four backends. On PostgreSQL, MongoDB, and Cassandra a concurrent `ListBackups` never saw it `CREATING` *(earlier build)*; on SQLite the backup row is visible as `CREATING` to other requests while the copy runs. Measured *(earlier build)*: 1.3 s for 60,000 items on PostgreSQL, 1.9 s for 60,000 on SQLite, 0.2 s for 20,000 on MongoDB, 5.1 s for 20,000 on Cassandra. MongoDB reports `BackupSizeBytes` 0. The name checks are the service's, with its texts (the length clause before the pattern clause when both fail; the service's order was not captured), counting characters, with `TableName` taking the deployment's table-name limits; before them a 200 KB name was stored and returned in every `ListBackups` page (a 500 on Cassandra) and a NUL byte was a 500 on PostgreSQL. `TableName` as an ARN (`arn:aws:dynamodb:::table/`) is accepted on all four backends: another region's resolves, another account's is `AccessDeniedException` (neither measured on the service); an SDK built from the model refuses the ARN form client-side, so only a hand-built request sends it. `TableNotFoundException` "Table not found: " for a missing or `CREATING` table on all four backends, built from the request's name (the backends' own payloads differ) | | `DeleteBackup` response | The full `DescribeBackup` description with `BackupStatus` `DELETED`, including `SourceTableDetails.ProvisionedThroughput` (a required member) and `SourceTableFeatureDetails` (measured) | The same on all four backends: the engine reads the backup's recorded definition before the delete and fills the description as `DescribeBackup` does, with `BackupStatus` `DELETED`. A backup whose definition this build cannot read or that does not parse is still deleted and described without feature details (logged at warn), which means a damaged `PROVISIONED` backup is described with on-demand `ProvisionedThroughput` `{0, 0}`: ExtendDB's choice, deletability over the throughput member of a row that is being removed, where `DescribeBackup` on the same row is an `InternalServerError`; a failure to reach the database fails the request and deletes nothing. | diff --git a/docs/dynamodb-limits.md b/docs/dynamodb-limits.md index 2b37b9023..e474ce62e 100755 --- a/docs/dynamodb-limits.md +++ b/docs/dynamodb-limits.md @@ -96,7 +96,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Simultaneous shard readers | 2 (1 for global tables) | Not enforced | No concurrent reader tracking | | Max write capacity with streams (provisioned) | 40,000 WCU | Enforced | Same as table WCU limit | | GetRecords: max records per call | 1,000 | Enforced | A `Limit` under 1 answers `1 validation error detected: Value at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1`, a `Limit` over 1000 answers `One or more parameter values were invalid: Limit should be less than or equal to 1000` (both measured 2026-10-25) | -| Shard iterator lifetime | 15 minutes | Not enforced | No shard iterator expiration | +| Shard iterator lifetime | 15 minutes | Enforced | Iterators older than 15 minutes are rejected with `ExpiredIteratorException` | ## API-Level Limits @@ -133,7 +133,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | `BackupArn` | 37 to 1024 characters | Maximum enforced; minimum implied | Surrounding ASCII whitespace is trimmed first, then over 1024 bytes, not of the backup ARN shape, a control or non-ASCII character, or a region outside `[a-z0-9-]`, an account that is not digits, or a table name outside `[a-zA-Z0-9_.-]` is `ValidationException` "Invalid Request: BackupArn is not valid"; the right shape with an id that is not a backup id is "Invalid Backup ARN"; a digit-only account that is not the caller's is `AccessDeniedException`. No minimum is checked: the shortest string of the right shape is longer than 37 characters. Another region of the caller's account resolves (the server's region is tried first, then the ARN as written). `ListBackups`'s `ExclusiveStartBackupArn` must also match the account, the region, and `TableName` when given | | `ListBackups` page size | 100 | Enforced | `Limit` 1 to 100, default 100; more rows come back through `LastEvaluatedBackupArn` and `ExclusiveStartBackupArn`. Each request reads every backup of the account (or of the table, with `TableName`) and pages in memory, so walking N backups reads about N²/100 rows. `TimeRangeLowerBound`, `TimeRangeUpperBound`, and `BackupType` filter the list before paging; a bound before the epoch, or a lower bound above the upper bound ("Time range lower bound must be less than or equal to upper bound"), is `ValidationException` | | Backup contents | Key schema, attribute definitions, items, indexes, billing mode, provisioned throughput, on-demand throughput (table and GSI level), SSE. Table class is not carried (a restore is `STANDARD`) | Partial | ExtendDB restores the same set, except a GSI's on-demand throughput (it records none per index); table class is not restored, as on the service. Streams, TTL, tags, and deletion protection are not restored, as on the service. Backups are stored inside the database the server uses; take database-level backups for off-host copies | -| Point-in-time recovery | 35-day window | N/A | `RestoreTableToPointInTime` is refused. `UpdateContinuousBackups` accepts enabling point-in-time recovery and reports a 35-day restorable window, but nothing is recorded (see differences-from-dynamodb.md) | +| Point-in-time recovery | 35-day window | N/A | Not supported: `UpdateContinuousBackups` refuses to enable it (`ContinuousBackupsUnavailableException`) and `RestoreTableToPointInTime` refuses every restore (`PointInTimeRecoveryUnavailableException`); use on-demand backups (see differences-from-dynamodb.md) | | `RestoreTableFromBackup` overrides | `BillingModeOverride`, `ProvisionedThroughputOverride`, `GlobalSecondaryIndexOverride`, `LocalSecondaryIndexOverride`, `SSESpecificationOverride`, `OnDemandThroughputOverride`, `VectorIndexOverride` | Partial | All but `VectorIndexOverride` are applied as the service applies them: an index override list replaces the backup's indexes of that kind (empty restores none, a subset restores those, a GSI entry's throughput is the restored index's), `OnDemandThroughputOverride` is the restored table's on-demand throughput on an on-demand restore. Refused with `ValidationException` before the target is created: an index the backup does not have or a changed index definition, a throughput member contradicting the billing mode, `{0, 0}`, a `PROVISIONED` restore of a GSI without throughput ("Invalid Request: Must override ProvisionedThroughput if BillingMode is overridden to PROVISIONED for index "), a GSI entry's `OnDemandThroughput` on a `PROVISIONED` restore (on an on-demand restore it is accepted and not recorded), and `VectorIndexOverride`. See differences-from-dynamodb.md | ## Global Tables @@ -162,12 +162,12 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Query/Scan | 2 | 0 | 5 | 0 | | Batch Operations | 3 | 0 | 2 | 0 | | Transactions | 3 | 0 | 1 | 0 | -| Streams | 2 | 0 | 2 | 0 | +| Streams | 3 | 0 | 1 | 0 | | API-Level | 1 | 0 | 3 | 1 | | Import/Export/Backup | 2 | 2 | 0 | 7 | | Global Tables | 0 | 0 | 0 | 2 | | Contributor Insights | 0 | 0 | 0 | 1 | -| **Total** | **32** | **3** | **15** | **13** | +| **Total** | **33** | **3** | **14** | **13** | ### Unenforced Limits Requiring Tracking @@ -179,9 +179,8 @@ The following unenforced limits are tracked in `docs/technical-debt.md`: 4. **BatchGetItem response size** (16 MB) — requires aggregate response size tracking 5. **BatchWriteItem request size** (16 MB) — requires aggregate request size tracking 6. **Transaction request size** (4 MB) — requires aggregate request size tracking -7. **Shard iterator lifetime** (15 minutes) — requires timestamp tracking on shard iterators -8. **Tag count per resource** (50) — requires count validation in TagResource -9. **Tag key/value length** (128/256 chars) — requires length validation in TagResource +7. **Tag count per resource** (50): requires count validation in TagResource +8. **Tag key/value length** (128/256 chars): requires length validation in TagResource --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 8632ad33d..fc513aea1 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -268,7 +268,7 @@ You should see all checks pass: --- Checking catalog connection... OK: Connected to catalog. --- Checking catalog version... - OK: Catalog version 0.0.4 + OK: Catalog version 0.0.5 --- Checking data database... OK: Connected to data database 'extenddb_catalog_data'. --- Enumerating tables... @@ -284,7 +284,7 @@ extenddb runs as a daemon (background process) and logs to syslog. On startup it ```bash ./target/release/extenddb serve --config extenddb.toml -# extenddb 0.1.14 (catalog 0.0.4) starting on 127.0.0.1:18443 +# extenddb 0.1.14 (catalog 0.0.5) starting on 127.0.0.1:18443 # storage: postgres (postgresql://extenddb:***@localhost:5432/extenddb_catalog) ``` @@ -1363,7 +1363,7 @@ Each runner requires its tools to be installed. The runner checks prerequisites ```bash ./target/release/extenddb version # extenddb 0.1.14 -# catalog 0.0.4 (postgres) +# catalog 0.0.5 (postgres) # commit abc1234 # built 2026-04-17T12:00:00Z ``` diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md index bd05b6552..ea9c52da3 100755 --- a/docs/manuals/01-architecture-guide.md +++ b/docs/manuals/01-architecture-guide.md @@ -205,8 +205,8 @@ extenddb uses a dual-database architecture: - **Catalog database** (e.g., `extenddb_catalog`): Stores table metadata, account/user/group/role/policy definitions, access keys, settings, stream metadata, and metrics. Shared across all accounts. - **Data database** (e.g., `extenddb_catalog_data`): Stores user items, GSI/LSI data, and stream records. Each table gets its own PostgreSQL table. -The catalog version is 0.0.4 on PostgreSQL, SQLite, and Cassandra. PostgreSQL and SQLite store it in the `settings` table under the key `catalog_version` and check it at startup; Cassandra has no startup check (see the Upgrade Manual). -The MongoDB backend tracks its own catalog version, 0.0.2. +PostgreSQL, SQLite, and Cassandra store the catalog version in the `settings` table under the key `catalog_version`; PostgreSQL and SQLite check it at startup, and Cassandra has no startup check (see the Upgrade Manual). +The MongoDB backend tracks its own catalog version, separate from the one the other backends share. A mismatch between the compiled-in version and the stored one prevents a PostgreSQL or SQLite server from starting; run `extenddb migrate` to upgrade. ## Pluggable Architecture diff --git a/docs/manuals/03-usage-guide.md b/docs/manuals/03-usage-guide.md index 861f6bba9..c165b0dc5 100755 --- a/docs/manuals/03-usage-guide.md +++ b/docs/manuals/03-usage-guide.md @@ -571,7 +571,8 @@ extenddb reproduces DynamoDB error responses exactly. Common errors: | PartiQL | ✗ (not planned) | | DAX | ✗ (not applicable) | | Global Tables | ✗ (future) | -| Backups / PITR | ✗ (future) | +| On-demand backups (CreateBackup / RestoreTableFromBackup) | ✓ | +| Point-in-time recovery | ✗ (`UpdateContinuousBackups` refuses to enable it; see differences-from-dynamodb.md) | | Import/Export (local filesystem) | ✓ (FileSource instead of S3) | --- diff --git a/docs/manuals/04-quickstart-setup-guide.md b/docs/manuals/04-quickstart-setup-guide.md index 0f57420d8..2bed0dc6a 100755 --- a/docs/manuals/04-quickstart-setup-guide.md +++ b/docs/manuals/04-quickstart-setup-guide.md @@ -130,7 +130,7 @@ Check the version: ```bash ./target/release/extenddb version # extenddb 0.1.14 -# catalog 0.0.4 (postgres) +# catalog 0.0.5 (postgres) # commit abc1234 # built 2026-04-17T12:00:00Z ``` @@ -171,7 +171,7 @@ Expected output: --- Checking catalog connection... OK: Connected to catalog. --- Checking catalog version... - OK: Catalog version 0.0.4 + OK: Catalog version 0.0.5 --- Checking data database... OK: Connected to data database 'extenddb_catalog_data'. --- Enumerating tables... diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f4451a0f5..1dd8e0b23 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -4,21 +4,13 @@ ## Current Status -Catalog 0.0.4 is current. **Every existing PostgreSQL, SQLite, and Cassandra deployment must run `extenddb migrate`** to reach it. PostgreSQL and SQLite servers refuse to start against a catalog version they were not built for; a Cassandra server starts anyway and serves tables and items, but CreateBackup, RestoreTableFromBackup, and DeleteBackup are refused with a `ValidationException` naming `extenddb migrate --yes` until the catalog is migrated (see [Cassandra](#cassandra)). MongoDB's catalog version does not change in this release. - -See [Catalog 0.0.4](#catalog-004-current) below for what changes and the exact sequence. +PostgreSQL and SQLite servers refuse to start against a catalog version they were not built for, so **every PostgreSQL and SQLite deployment must run `extenddb migrate` when a release changes the catalog**. A Cassandra server starts anyway and serves tables and items, but CreateBackup, RestoreTableFromBackup, and DeleteBackup are refused with a `ValidationException` naming `extenddb migrate --yes` until the catalog is migrated (see [Cassandra](#cassandra)), so every Cassandra deployment runs it too. The installed and expected versions are reported by the tooling, not by this manual: see [Seeing the Installed and Expected Versions](#seeing-the-installed-and-expected-versions). What a release changes in the catalog and in behavior is under [Version History](#version-history) and [Behavior Changes by Release](#behavior-changes-by-release). ## How Catalog Upgrades Work ### The Migration System -Migrations are SQL files in `crates/storage-postgres/migrations/`, applied in filename order: - -``` -001_schema.sql ← the complete initial schema -002_vector_indexes.sql ← vector index metadata, catalog 0.0.3 -003_backup_definitions.sql ← backup table definitions, catalog 0.0.4 -``` +Migrations are SQL files in `crates/storage-postgres/migrations/`, applied in filename order. Each file opens with a header comment stating what it changes and which catalog version it introduces; read the files for the per-migration details. The `schema_history` table tracks which files have been applied. When `extenddb migrate` runs, it: @@ -26,6 +18,7 @@ The `schema_history` table tracks which files have been applied. When `extenddb 2. Checks `schema_history` for each filename 3. Applies any unapplied migrations in order 4. Records each applied filename in `schema_history` +5. Writes the catalog version the binary expects, so an upgrade interrupted between applying a migration and recording it converges on the expected version when `extenddb migrate` runs again Running `extenddb migrate` on an up-to-date catalog is a no-op. @@ -35,11 +28,36 @@ A single row in the `settings` table stores the catalog version: ```sql SELECT value FROM settings WHERE key = 'catalog_version'; --- '0.0.2' ``` The binary embeds an expected catalog version (`CATALOG_VERSION` constant in `crates/storage-postgres/src/lib.rs`). At startup, the server compares the database value against the binary's expectation. If they don't match, the server refuses to start and directs the operator to run `extenddb migrate`. +### Seeing the Installed and Expected Versions + +The tooling reports both versions. + +`extenddb verify` prints the stored version and, on a mismatch, the version the binary expects: + +``` +--- Checking catalog version... + WARN: Catalog version (binary expects ) +``` + +`extenddb migrate` without `--yes` reports what an upgrade would apply and changes nothing: + +``` +--- Checking current catalog version... + Current version: +... +Pending: catalog -> ; . Nothing was changed; run again with --yes to apply (exit status 3: work pending). +``` + +A server started against a catalog it was not built for refuses with both versions in the error: + +``` +Catalog version mismatch: expected , found . Run 'extenddb migrate' +``` + ### Version Semantics The catalog version follows semantic versioning: @@ -183,9 +201,11 @@ extenddb verify --config extenddb.toml extenddb serve --config extenddb.toml ``` +SQLite deployments follow the same sequence, backing up the database files as [SQLite](#sqlite) describes; `extenddb migrate` re-applies the catalog schema. MongoDB deployments track their own catalog version, and a release states whether it changes; the Behavior Changes section below still applies to them. + ## Rollback Procedure -If an upgrade fails (PostgreSQL; for SQLite see [SQLite](#sqlite) below): +The upgrade is not reversible in place: an older binary refuses to start against a newer catalog, by the same check in the other direction. If an upgrade fails or must be undone (PostgreSQL; for SQLite see [SQLite](#sqlite) below): 1. Stop the server 2. Restore the catalog from the backup, connected as `` to the `postgres` database: @@ -200,9 +220,22 @@ To discard data written after the upgrade as well, restore `` from its 3. Rebuild the previous version and start it +Data written after the upgrade in schema shapes the previous version cannot represent is lost with the restore. + ## Version History -### Catalog 0.0.4 (Current) +### Catalog 0.0.5 + +Removes the storage of the per-table point-in-time recovery flag, which only `DescribeContinuousBackups` read; the engine now answers the three point-in-time recovery operations without storage (see [Point-in-time recovery is reported as unsupported](#v0115-point-in-time-recovery-is-reported-as-unsupported-catalog-migration-required) under Behavior Changes for the wire change and the per-backend `extenddb migrate` steps): + +- PostgreSQL: migration `004_drop_continuous_backups.sql` drops the `continuous_backups` table. +- SQLite: the re-applied catalog schema drops the `continuous_backups` table. +- Cassandra: catalog migration `V006__drop_continuous_backups.cql` drops the `continuous_backups` table. +- MongoDB: the catalog version stays at its own value; `extenddb migrate --yes` drops the `continuous_backups` collection from a catalog an earlier build created. + +The upgrade is not reversible in place: a 0.0.4 PostgreSQL or SQLite binary refuses to start against a 0.0.5 catalog (`Catalog version mismatch: expected 0.0.4, found 0.0.5`). Roll back by restoring what you saved before the upgrade. + +### Catalog 0.0.4 Adds the table definition a backup records and the registration of a running restore: @@ -319,6 +352,23 @@ Catalog upgrades change the schema; behavior changes alter how the running serve interprets existing data or configuration. Review these before upgrading a live deployment, even when no catalog migration is required. +### v0.1.15: Point-in-time recovery is reported as unsupported (catalog migration required) + +**What changed.** `UpdateContinuousBackups` no longer accepts an enable request, and `DescribeContinuousBackups` no longer reports point-in-time recovery `ENABLED` with a 35-day window. Point-in-time recovery is reported as unsupported: describe answers `PointInTimeRecoveryStatus: DISABLED`, an enable request returns `ContinuousBackupsUnavailableException`, and `RestoreTableToPointInTime` checks the source table and then returns `PointInTimeRecoveryUnavailableException`. A table that does not exist is `TableNotFoundException` on all three operations, where `DescribeContinuousBackups` and `UpdateContinuousBackups` answered `ResourceNotFoundException`, and `UpdateContinuousBackups` without `PointInTimeRecoverySpecification` is a `ValidationException`, where it was taken as disabling. See [Differences from DynamoDB](../differences-from-dynamodb.md) for the full surface. The catalog migration removes the storage of a per-table enable flag that only the description read. + +**Who is affected.** Any client or infrastructure template that enables point-in-time recovery (for example a Terraform resource with `point_in_time_recovery = true`) now receives `ContinuousBackupsUnavailableException` and must stop requesting it. A restore attempt now receives `PointInTimeRecoveryUnavailableException` instead of a generic `ValidationException`. + +**Migration.** Use on-demand backups (`CreateBackup`, `RestoreTableFromBackup`) instead; they are supported on every backend. `extenddb migrate --yes` removes the flag's storage on each backend: + +- PostgreSQL: migration `004_drop_continuous_backups.sql` drops the `continuous_backups` table. +- SQLite: the re-applied catalog schema drops the `continuous_backups` table. +- Cassandra: catalog migration `V006__drop_continuous_backups.cql` drops the `continuous_backups` table. Until it runs, CreateBackup, RestoreTableFromBackup, and DeleteBackup are refused, as on any catalog with a pending migration (see [Cassandra](#cassandra)). Stop every server of an earlier build first: an earlier build's DeleteTable, its account deletion, and its removal of `DELETING` tables write to `continuous_backups` and fail once it is gone, and its `DescribeContinuousBackups` and `UpdateContinuousBackups` answer HTTP 500. +- MongoDB: the catalog version does not change. `extenddb migrate` reports `catalog: drop the continuous_backups collection`, and `--yes` drops it. `extenddb serve` drops nothing, and a new catalog does not create the collection. Stop every server of an earlier build first: an earlier build starts against the migrated catalog (MongoDB has no startup version check), accepts an enable request with `ENABLED` and a 35-day window, and recreates the collection, which the next `extenddb migrate` reports as pending again. + +An earlier PostgreSQL or SQLite binary refuses to start against the migrated catalog; roll back by restoring what you saved before the upgrade, as described under [Rollback Procedure](#rollback-procedure). + +--- + ### v0.1.14 LeadingKeys now enforced on Query, batch and transaction operations **What changed.** `dynamodb:LeadingKeys` is now evaluated on `Query`, diff --git a/docs/manuals/08-install-linux.md b/docs/manuals/08-install-linux.md index 7414ab006..cff9a63cd 100755 --- a/docs/manuals/08-install-linux.md +++ b/docs/manuals/08-install-linux.md @@ -118,7 +118,7 @@ Expected: ``` === extenddb verify === ... - OK: Catalog version 0.0.4 + OK: Catalog version 0.0.5 ... === HEALTHY: All checks passed === ``` diff --git a/docs/manuals/09-install-macos.md b/docs/manuals/09-install-macos.md index feec0a409..aae4b95c9 100755 --- a/docs/manuals/09-install-macos.md +++ b/docs/manuals/09-install-macos.md @@ -96,7 +96,7 @@ Expected: ``` === extenddb verify === ... - OK: Catalog version 0.0.4 + OK: Catalog version 0.0.5 ... === HEALTHY: All checks passed === ``` diff --git a/docs/todo-index.md b/docs/todo-index.md index 3d9abdb12..a5a5f8003 100755 --- a/docs/todo-index.md +++ b/docs/todo-index.md @@ -7,7 +7,6 @@ Regenerated: v0.1.6 (BR-7085) - `crates/core/src/types/item.rs:176` — Route ConsistentRead to read replica when replica support is added. - `crates/engine/src/batch_write_item.rs:181` — DynamoDB charges WCU based on old item size for deletes. - `crates/engine/src/transact_write_helpers.rs:121` — DynamoDB charges WCU based on old item size for deletes. -- `crates/engine/src/backup.rs:233` — Implement real PITR using PostgreSQL temporal/history tables. - `crates/storage-postgres/src/table_helpers.rs:141` — Two queries not in a transaction under concurrent access. ## TODO(cleanup) diff --git a/tests/rust/src/backup_restore.rs b/tests/rust/src/backup_restore.rs index 65b2f24d3..e7b93884a 100755 --- a/tests/rust/src/backup_restore.rs +++ b/tests/rust/src/backup_restore.rs @@ -225,18 +225,36 @@ async fn describe_continuous_backups() { .send() .await .unwrap(); - assert!(resp.continuous_backups_description().is_some()); + let desc = resp.continuous_backups_description().unwrap(); + assert_eq!(desc.continuous_backups_status().as_str(), "ENABLED"); + let pitr = desc.point_in_time_recovery_description().unwrap(); + assert_eq!( + pitr.point_in_time_recovery_status().unwrap().as_str(), + "DISABLED" + ); + assert!( + pitr.earliest_restorable_date_time().is_none(), + "a disabled recovery must not report an earliest restorable time" + ); + assert!( + pitr.latest_restorable_date_time().is_none(), + "a disabled recovery must not report a latest restorable time" + ); c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] -async fn enable_point_in_time_recovery() { +async fn enable_point_in_time_recovery_is_refused() { let c = client(); let table = format!("PITREnable_{}", ts()); make_table(&table).await; - c.update_continuous_backups() + // Point-in-time recovery is not supported by any storage backend, so + // enabling it must fail with the typed exception rather than report a + // recovery capability that can never restore. + let err = c + .update_continuous_backups() .table_name(&table) .point_in_time_recovery_specification( PointInTimeRecoverySpecification::builder() @@ -246,8 +264,14 @@ async fn enable_point_in_time_recovery() { ) .send() .await - .unwrap(); + .unwrap_err(); + assert_eq!( + err_code(&err), + Some("ContinuousBackupsUnavailableException"), + "unexpected error: {err:?}" + ); + // The refusal must not have changed anything. let resp = c .describe_continuous_backups() .table_name(&table) @@ -261,19 +285,55 @@ async fn enable_point_in_time_recovery() { .unwrap(); assert_eq!( pitr.point_in_time_recovery_status().unwrap().as_str(), - "ENABLED" + "DISABLED" ); c.delete_table().table_name(&table).send().await.ok(); } #[tokio::test] -async fn restore_table_to_point_in_time() { +async fn disable_point_in_time_recovery_reports_disabled() { + let c = client(); + let table = format!("PITRDisable_{}", ts()); + make_table(&table).await; + + // Disabling recovery that was never enabled is a no-op that succeeds and + // returns the same description DescribeContinuousBackups reports. + let resp = c + .update_continuous_backups() + .table_name(&table) + .point_in_time_recovery_specification( + PointInTimeRecoverySpecification::builder() + .point_in_time_recovery_enabled(false) + .build() + .unwrap(), + ) + .send() + .await + .unwrap(); + let desc = resp.continuous_backups_description().unwrap(); + assert_eq!(desc.continuous_backups_status().as_str(), "ENABLED"); + assert_eq!( + desc.point_in_time_recovery_description() + .unwrap() + .point_in_time_recovery_status() + .unwrap() + .as_str(), + "DISABLED" + ); + + c.delete_table().table_name(&table).send().await.ok(); +} + +#[tokio::test] +async fn restore_table_to_point_in_time_is_refused() { let c = client(); let table = format!("PITRRestore_{}", ts()); make_table(&table).await; - // PITR restore is not yet implemented — should return an error. + // Point-in-time recovery is not supported, so the restore must fail with + // the typed exception the service models on this operation rather than + // fabricate a restore of current state. let restored = format!("PITRRestored_{}", ts()); let err = c .restore_table_to_point_in_time() @@ -281,8 +341,61 @@ async fn restore_table_to_point_in_time() { .target_table_name(&restored) .use_latest_restorable_time(true) .send() - .await; - assert!(err.is_err(), "RestoreTableToPointInTime should return an error (not yet supported)"); + .await + .unwrap_err(); + assert_eq!( + err_code(&err), + Some("PointInTimeRecoveryUnavailableException"), + "unexpected error: {err:?}" + ); c.delete_table().table_name(&table).send().await.ok(); } + +#[tokio::test] +async fn point_in_time_recovery_operations_report_a_missing_table_as_table_not_found() { + let c = client(); + let missing = format!("PITRMissing_{}", ts()); + let expected = format!("Table not found: {missing}"); + let check = |what: &str, code: Option<&str>, message: String| { + assert_eq!( + (code, message.as_str()), + (Some("TableNotFoundException"), expected.as_str()), + "{what}" + ); + }; + + let err = c + .describe_continuous_backups() + .table_name(&missing) + .send() + .await + .unwrap_err(); + check("describe", err_code(&err), err_msg(&err)); + + for enabled in [false, true] { + let err = c + .update_continuous_backups() + .table_name(&missing) + .point_in_time_recovery_specification( + PointInTimeRecoverySpecification::builder() + .point_in_time_recovery_enabled(enabled) + .build() + .unwrap(), + ) + .send() + .await + .unwrap_err(); + check("update", err_code(&err), err_msg(&err)); + } + + let err = c + .restore_table_to_point_in_time() + .source_table_name(&missing) + .target_table_name(format!("{missing}_restored")) + .use_latest_restorable_time(true) + .send() + .await + .unwrap_err(); + check("restore", err_code(&err), err_msg(&err)); +} diff --git a/tests/test_cli_vector_catalog_migration.py b/tests/test_cli_vector_catalog_migration.py index e50f9f175..a0feeb8df 100644 --- a/tests/test_cli_vector_catalog_migration.py +++ b/tests/test_cli_vector_catalog_migration.py @@ -3,13 +3,17 @@ """Catalog migration tests against a live PostgreSQL deployment. -The vector index schema (catalog 0.0.3) and the backup table definitions -(catalog 0.0.4) are exercised through an upgrade from the pre-vector shape. +The vector index schema, the backup table definitions, and the removal of the +`continuous_backups` table are exercised through an upgrade from the +pre-vector shape. -These exercise the migration against a live deployment rather than asserting the -SQL text: init one, roll its catalog back to the pre-vector shape, and check that -the binary refuses to serve it, that `extenddb migrate` upgrades it, and that the -upgraded deployment then serves. +These exercise the migration chain end to end rather than asserting the SQL +text: init a deployment, roll its catalog back to the pre-vector shape, and +check that the binary refuses to serve it, that `extenddb migrate` upgrades it, +and that the upgraded deployment then serves. + +The version the binary expects is pinned once in `CURRENT_CATALOG_VERSION`; a +release that adds a catalog migration updates that one constant. Shared lifecycle helpers and the `cli_env` fixture live in `lifecycle_helpers.py`. Like the other CLI lifecycle tests these require a PostgreSQL instance @@ -33,13 +37,14 @@ VECTOR_MIGRATION = "002_vector_indexes.sql" BACKUP_DEFINITIONS_MIGRATION = "003_backup_definitions.sql" +CONTINUOUS_BACKUPS_MIGRATION = "004_drop_continuous_backups.sql" # The version the binary expects, written by the last catalog migration. Every # current-version assertion reads it from here so the next bump is one edit. -CURRENT_CATALOG_VERSION = "0.0.4" +CURRENT_CATALOG_VERSION = "0.0.5" # A version no release has reached, for the symmetric version-gate test. -FUTURE_CATALOG_VERSION = "0.0.5" +FUTURE_CATALOG_VERSION = "0.0.6" def _catalog_conn(cli_env): @@ -84,6 +89,12 @@ def _backup_definitions_table_exists(cli_env): ) +def _continuous_backups_table_exists(cli_env): + return _catalog_query( + cli_env, "SELECT to_regclass('public.continuous_backups') IS NOT NULL" + ) + + def _init(cli_env): result = _run_extenddb( "init", *_init_args(cli_env), @@ -107,9 +118,20 @@ def _roll_catalog_back_to_pre_vector(cli_env): cur.execute("DROP TABLE IF EXISTS backup_definitions") cur.execute("DROP TABLE IF EXISTS vector_indexes") cur.execute("ALTER TABLE backups DROP COLUMN IF EXISTS vector_indexes") + # A 0.0.2 deployment still has the continuous_backups table (created + # by 001, dropped by 004 at init), so put it back the way 001 made it. + cur.execute( + "CREATE TABLE IF NOT EXISTS continuous_backups (" + "account_id TEXT NOT NULL, " + "table_name TEXT NOT NULL, " + "pitr_enabled BOOLEAN NOT NULL DEFAULT false, " + "earliest_restorable TIMESTAMPTZ, " + "latest_restorable TIMESTAMPTZ, " + "PRIMARY KEY (account_id, table_name))" + ) cur.execute( - "DELETE FROM schema_history WHERE filename IN (%s, %s)", - (VECTOR_MIGRATION, BACKUP_DEFINITIONS_MIGRATION), + "DELETE FROM schema_history WHERE filename IN (%s, %s, %s)", + (VECTOR_MIGRATION, BACKUP_DEFINITIONS_MIGRATION, CONTINUOUS_BACKUPS_MIGRATION), ) cur.execute("UPDATE settings SET value = '0.0.2' WHERE key = 'catalog_version'") finally: @@ -132,6 +154,7 @@ def test_init_creates_the_catalog_at_the_current_version(self, cli_env): assert _vector_table_exists(cli_env) is True assert _backups_has_vector_column(cli_env) is True assert _backup_definitions_table_exists(cli_env) is True + assert _continuous_backups_table_exists(cli_env) is False conn = _catalog_conn(cli_env) try: @@ -145,6 +168,7 @@ def test_init_creates_the_catalog_at_the_current_version(self, cli_env): # so a replay after a crash between applying and recording is harmless. assert VECTOR_MIGRATION in tracked, tracked assert BACKUP_DEFINITIONS_MIGRATION in tracked, tracked + assert CONTINUOUS_BACKUPS_MIGRATION in tracked, tracked def test_migrate_upgrades_a_pre_vector_deployment(self, cli_env): """A 0.0.2 deployment is refused, upgraded by migrate, and then serves.""" @@ -188,6 +212,7 @@ def test_migrate_upgrades_a_pre_vector_deployment(self, cli_env): assert _catalog_version(cli_env) == CURRENT_CATALOG_VERSION assert _vector_table_exists(cli_env) is True assert _backups_has_vector_column(cli_env) is True + assert _continuous_backups_table_exists(cli_env) is False # The upgraded deployment serves, which is the assertion the version and # the table shape are both really for. @@ -223,8 +248,15 @@ def test_migrate_survives_an_applied_but_unrecorded_migration(self, cli_env): ledger row missing, which is exactly the state a crash leaves behind. Without idempotent statements the replay fails on "relation already exists" and no later migration can ever be applied. + + The end state must be the current version even though the replayed file + writes an older one, because the runner converges the stored version + after the walk. Without that, re-applying 002 while the later files stay + recorded would leave the catalog at 0.0.3 with their schema applied, and + the startup gate would refuse a deployment no further migrate can repair. """ _init(cli_env) + _patch_config_port(cli_env["config_path"], cli_env["port"]) assert _vector_table_exists(cli_env) is True conn = _catalog_conn(cli_env) @@ -270,6 +302,64 @@ def test_migrate_survives_an_applied_but_unrecorded_migration(self, cli_env): finally: conn.close() + # The converged version is what lets the deployment serve again, which + # is the point of the convergence: a crash during an upgrade must not + # strand the catalog behind the version gate. + served = _run_extenddb("serve", config=cli_env["config_path"]) + assert served.returncode == 0, served.stdout + served.stderr + try: + assert _wait_for_server(cli_env["port"]), "replayed deployment did not serve" + finally: + _run_extenddb("stop", config=cli_env["config_path"], check=False) + time.sleep(1) + + def test_migrate_survives_an_applied_but_unrecorded_final_migration(self, cli_env): + """A replay of the latest migration converges the same way. + + Mirror of the test above for the last file in the chain: 004's schema + applied (the table is already gone), its ledger row missing, and the + stored version rolled back so the runner walks the list. The replay must + re-apply the idempotent drop, repair the ledger, and land the catalog on + the current version. + """ + _init(cli_env) + assert _continuous_backups_table_exists(cli_env) is False + + conn = _catalog_conn(cli_env) + try: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute( + "DELETE FROM schema_history WHERE filename = %s", + (CONTINUOUS_BACKUPS_MIGRATION,), + ) + cur.execute( + "UPDATE settings SET value = '0.0.4' WHERE key = 'catalog_version'" + ) + finally: + conn.close() + + replayed = _run_extenddb( + "migrate", "--yes", *_pg_args(), config=cli_env["config_path"], check=False + ) + output = replayed.stdout + replayed.stderr + assert replayed.returncode == 0, output + assert f"Applying {CONTINUOUS_BACKUPS_MIGRATION}" in output, output + assert f"Migration {CONTINUOUS_BACKUPS_MIGRATION} failed" not in output, output + + assert _catalog_version(cli_env) == CURRENT_CATALOG_VERSION + assert _continuous_backups_table_exists(cli_env) is False + conn = _catalog_conn(cli_env) + try: + with conn.cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM schema_history WHERE filename = %s", + (CONTINUOUS_BACKUPS_MIGRATION,), + ) + assert cur.fetchone()[0] == 1 + finally: + conn.close() + def test_serve_refuses_a_catalog_newer_than_the_binary(self, cli_env): """The version gate must refuse a catalog from a future release too. diff --git a/tests/test_continuous_backups.py b/tests/test_continuous_backups.py new file mode 100644 index 000000000..a2a29df92 --- /dev/null +++ b/tests/test_continuous_backups.py @@ -0,0 +1,406 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Continuous backups and point-in-time recovery surface. + +ExtendDB deliberately diverges from real DynamoDB here (see +docs/differences-from-dynamodb.md): DescribeContinuousBackups always reports +PointInTimeRecoveryStatus DISABLED, UpdateContinuousBackups refuses to enable +recovery with ContinuousBackupsUnavailableException, and +RestoreTableToPointInTime refuses with PointInTimeRecoveryUnavailableException +after resolving the source table. These tests pin that divergence, so they run +only against ExtendDB. + +The request validation before those answers, and its order, are the +service's: a missing table name member and the table name constraints, the +missing recovery specification, the restore's source member and time members, +and TableNotFoundException for a missing table on all three operations. The +expected texts were measured on DynamoDB, two samples each, with client-side +parameter validation off. +""" + +from __future__ import annotations + +import os +import uuid + +import pytest +from botocore.exceptions import ClientError + +pytestmark = pytest.mark.skipif( + not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(), + reason="pins ExtendDB's documented divergence: point-in-time recovery is unsupported", +) + +TABLE_NAME_REQUIRED = ( + "The parameter 'TableName' is required but was not present in the request" +) +SPECIFICATION_REQUIRED = ( + "1 validation error detected: Value null at 'pointInTimeRecoverySpecification' " + "failed to satisfy constraint: Member must not be null" +) +FLAG_REQUIRED = ( + "1 validation error detected: Value null at " + "'pointInTimeRecoverySpecification.pointInTimeRecoveryEnabled' " + "failed to satisfy constraint: Member must not be null" +) +NO_RESTORE_TIME = ( + "Invalid Request: Either one of RestoreDateTime or UseLatestRestorableTime " + "must be specified, but not both" +) +TWO_RESTORE_TIMES = ( + "Invalid Request: Both RestoreDateTime and UseLatestRestorableTime cannot be set " + "for point-in-time restore request. Please specify either RestoreDateTime time " + "or UseLatestRestorableTime" +) +SOURCE_NAMED_TWICE = ( + "Invalid Request: Must provide exactly one of: sourceTableArn, sourceTableName" +) +INVALID_TABLE_ARN = "Invalid Request: Table ARN is invalid." +NAME_PATTERN = "Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+" +EMPTY_TABLE_NAME = ( + "2 validation errors detected: Value '' at 'tableName' failed to satisfy " + "constraint: Member must have length greater than or equal to 3; Value '' at " + f"'tableName' failed to satisfy constraint: {NAME_PATTERN}" +) +SOURCE_TABLE_NAME_LENGTH = ( + "TableName must be at least 3 characters long and at most 255 characters long" +) + + +def _missing_name() -> str: + return f"extenddb-missing-{uuid.uuid4().hex[:12]}" + + +def _error(call, **params) -> dict: + with pytest.raises(ClientError) as excinfo: + call(**params) + return excinfo.value.response["Error"] + + +def _assert_error(call, code: str, message: str, **params) -> None: + err = _error(call, **params) + assert (err["Code"], err["Message"]) == (code, message), params + + +def _table_not_found(name: str) -> tuple[str, str]: + return "TableNotFoundException", f"Table not found: {name}" + + +def _recovery_not_enabled(name: str) -> tuple[str, str]: + return ( + "PointInTimeRecoveryUnavailableException", + f"Point in time recovery is not enabled for table '{name}'", + ) + + +def test_describe_continuous_backups_reports_recovery_disabled( + dynamodb_client, create_and_cleanup_table, unique_table_name +): + """ContinuousBackupsStatus is ENABLED, recovery is DISABLED, no window.""" + create_and_cleanup_table() + resp = dynamodb_client.describe_continuous_backups(TableName=unique_table_name) + desc = resp["ContinuousBackupsDescription"] + assert desc["ContinuousBackupsStatus"] == "ENABLED" + pitr = desc["PointInTimeRecoveryDescription"] + assert pitr == {"PointInTimeRecoveryStatus": "DISABLED"} + + +def test_enable_point_in_time_recovery_is_refused( + dynamodb_client, create_and_cleanup_table, unique_table_name +): + """Enabling recovery fails with the typed exception and changes nothing.""" + create_and_cleanup_table() + with pytest.raises(ClientError) as excinfo: + dynamodb_client.update_continuous_backups( + TableName=unique_table_name, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True}, + ) + assert ( + excinfo.value.response["Error"]["Code"] + == "ContinuousBackupsUnavailableException" + ) + + resp = dynamodb_client.describe_continuous_backups(TableName=unique_table_name) + assert ( + resp["ContinuousBackupsDescription"]["PointInTimeRecoveryDescription"][ + "PointInTimeRecoveryStatus" + ] + == "DISABLED" + ) + + +def test_disable_point_in_time_recovery_reports_disabled( + dynamodb_client, create_and_cleanup_table, unique_table_name +): + """Disabling recovery that was never enabled succeeds as a no-op.""" + create_and_cleanup_table() + resp = dynamodb_client.update_continuous_backups( + TableName=unique_table_name, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, + ) + desc = resp["ContinuousBackupsDescription"] + assert desc["ContinuousBackupsStatus"] == "ENABLED" + assert desc["PointInTimeRecoveryDescription"] == { + "PointInTimeRecoveryStatus": "DISABLED" + } + + +def test_restore_to_point_in_time_is_refused( + dynamodb_client, create_and_cleanup_table, unique_table_name +): + """Restore on an existing table fails with the restore-modeled exception.""" + create_and_cleanup_table() + _assert_error( + dynamodb_client.restore_table_to_point_in_time, + *_recovery_not_enabled(unique_table_name), + SourceTableName=unique_table_name, + TargetTableName=f"{unique_table_name}-restored", + UseLatestRestorableTime=True, + ) + + +def test_restore_to_point_in_time_missing_source_table(dynamodb_client): + """Restore resolves the source table first: a missing table reports + TableNotFoundException, not the recovery-unavailable error.""" + missing = _missing_name() + _assert_error( + dynamodb_client.restore_table_to_point_in_time, + *_table_not_found(missing), + SourceTableName=missing, + TargetTableName=f"{missing}-restored", + UseLatestRestorableTime=True, + ) + + +def test_a_missing_table_is_table_not_found_on_both_continuous_backups_operations( + dynamodb_client, +): + """TableNotFoundException with the service's text, for either flag.""" + missing = _missing_name() + _assert_error( + dynamodb_client.describe_continuous_backups, + *_table_not_found(missing), + TableName=missing, + ) + for enabled in (False, True): + _assert_error( + dynamodb_client.update_continuous_backups, + *_table_not_found(missing), + TableName=missing, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": enabled}, + ) + + +def test_the_recovery_specification_is_required_before_the_table_is_looked_up( + dynamodb_client_no_validation, create_and_cleanup_table, unique_table_name +): + """A missing specification is refused for a missing table too, so it is + checked first; an empty one names its missing flag.""" + create_and_cleanup_table() + update = dynamodb_client_no_validation.update_continuous_backups + for name in (_missing_name(), unique_table_name): + _assert_error(update, "ValidationException", SPECIFICATION_REQUIRED, TableName=name) + _assert_error( + update, + "ValidationException", + FLAG_REQUIRED, + TableName=unique_table_name, + PointInTimeRecoverySpecification={}, + ) + + +def test_a_missing_table_name_member_is_the_required_parameter_text( + dynamodb_client_no_validation, create_and_cleanup_table, unique_table_name +): + """No TableName, no restore source, or no TargetTableName: the same text, + and a missing target is reported before the source is looked up.""" + create_and_cleanup_table() + client = dynamodb_client_no_validation + _assert_error( + client.describe_continuous_backups, "ValidationException", TABLE_NAME_REQUIRED + ) + _assert_error( + client.update_continuous_backups, + "ValidationException", + TABLE_NAME_REQUIRED, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, + ) + restore = client.restore_table_to_point_in_time + for params in ( + {"TargetTableName": f"{unique_table_name}-restored"}, + {"SourceTableName": unique_table_name}, + {"SourceTableName": _missing_name()}, + ): + _assert_error( + restore, + "ValidationException", + TABLE_NAME_REQUIRED, + UseLatestRestorableTime=True, + **params, + ) + + +def test_restore_checks_the_source_then_the_time_members_then_recovery( + dynamodb_client_no_validation, create_and_cleanup_table, unique_table_name +): + """The source lookup comes before the time members, the time members + before the recovery check, and the target is never looked up.""" + create_and_cleanup_table() + other = f"{unique_table_name}-other" + create_and_cleanup_table(table_name=other) + restore = dynamodb_client_no_validation.restore_table_to_point_in_time + target = f"{unique_table_name}-restored" + missing = _missing_name() + + _assert_error( + restore, *_table_not_found(missing), SourceTableName=missing, TargetTableName=target + ) + for times in ({}, {"UseLatestRestorableTime": False}): + _assert_error( + restore, + "ValidationException", + NO_RESTORE_TIME, + SourceTableName=unique_table_name, + TargetTableName=target, + **times, + ) + _assert_error( + restore, + "ValidationException", + TWO_RESTORE_TIMES, + SourceTableName=unique_table_name, + TargetTableName=target, + UseLatestRestorableTime=True, + RestoreDateTime=1_700_000_000, + ) + _assert_error( + restore, + *_recovery_not_enabled(unique_table_name), + SourceTableName=unique_table_name, + TargetTableName=target, + RestoreDateTime=1_700_000_000, + ) + # An existing target is not checked before the recovery check. + _assert_error( + restore, + *_recovery_not_enabled(unique_table_name), + SourceTableName=unique_table_name, + TargetTableName=other, + UseLatestRestorableTime=True, + ) + + +def test_restore_source_table_arn_names_the_source_and_must_name_a_table( + dynamodb_client, dynamodb_client_no_validation, create_and_cleanup_table, unique_table_name +): + """SourceTableArn names the source as SourceTableName does; an index ARN + is refused, with or without a time member; giving both is refused.""" + create_and_cleanup_table() + other = f"{unique_table_name}-other" + create_and_cleanup_table(table_name=other) + table_arn = dynamodb_client.describe_table(TableName=unique_table_name)["Table"][ + "TableArn" + ] + prefix = table_arn[: -len(unique_table_name)] + restore = dynamodb_client_no_validation.restore_table_to_point_in_time + target = f"{unique_table_name}-restored" + missing = _missing_name() + + _assert_error( + restore, + *_recovery_not_enabled(unique_table_name), + SourceTableArn=table_arn, + TargetTableName=target, + UseLatestRestorableTime=True, + ) + _assert_error( + restore, + *_table_not_found(missing), + SourceTableArn=prefix + missing, + TargetTableName=target, + UseLatestRestorableTime=True, + ) + for times in ({"UseLatestRestorableTime": True}, {}): + _assert_error( + restore, + "ValidationException", + INVALID_TABLE_ARN, + SourceTableArn=f"{table_arn}/index/gsi1", + TargetTableName=target, + **times, + ) + for arn in (table_arn, prefix + other): + _assert_error( + restore, + "ValidationException", + SOURCE_NAMED_TWICE, + SourceTableName=unique_table_name, + SourceTableArn=arn, + TargetTableName=target, + UseLatestRestorableTime=True, + ) + + +def test_an_empty_table_name_is_refused_before_the_table_is_looked_up( + dynamodb_client_no_validation, +): + """An empty TableName fails the length and pattern constraints on both + continuous-backups operations (M335 probes 56 and 57), and an empty + SourceTableName fails the length check on the restore (probe 58), before + any table is looked up.""" + client = dynamodb_client_no_validation + _assert_error( + client.describe_continuous_backups, + "ValidationException", + EMPTY_TABLE_NAME, + TableName="", + ) + _assert_error( + client.update_continuous_backups, + "ValidationException", + EMPTY_TABLE_NAME, + TableName="", + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": False}, + ) + _assert_error( + client.restore_table_to_point_in_time, + "ValidationException", + SOURCE_TABLE_NAME_LENGTH, + SourceTableName="", + TargetTableName=f"{_missing_name()}-restored", + UseLatestRestorableTime=True, + ) + + +def test_a_table_arn_as_the_restore_source_name_fails_the_name_pattern( + dynamodb_client_no_validation, +): + """SourceTableName holding a table ARN fails the name pattern at + sourceTableName, before the lookup (M335 probe 46).""" + arn = f"arn:aws:dynamodb:us-east-1:123456789012:table/{_missing_name()}" + _assert_error( + dynamodb_client_no_validation.restore_table_to_point_in_time, + "ValidationException", + f"1 validation error detected: Value '{arn}' at 'sourceTableName' failed to " + f"satisfy constraint: {NAME_PATTERN}", + SourceTableName=arn, + TargetTableName=f"{_missing_name()}-restored", + UseLatestRestorableTime=True, + ) + + +def test_the_recovery_flag_as_the_string_false_disables( + dynamodb_client_no_validation, create_and_cleanup_table, unique_table_name +): + """PointInTimeRecoveryEnabled given as the JSON string "false" is taken as + false: 200 with the description (M335 probe 54).""" + create_and_cleanup_table() + resp = dynamodb_client_no_validation.update_continuous_backups( + TableName=unique_table_name, + PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": "false"}, + ) + assert resp["ContinuousBackupsDescription"] == { + "ContinuousBackupsStatus": "ENABLED", + "PointInTimeRecoveryDescription": {"PointInTimeRecoveryStatus": "DISABLED"}, + }