From b932127e385d8d43d22ac204158c3de0a17df95b Mon Sep 17 00:00:00 2001 From: Corey Quinn Date: Wed, 20 May 2026 21:01:02 +0000 Subject: [PATCH] feat(storage): add Route 53 backend Adds extenddb-storage-route53 as a storage backend behind a "route53" cargo feature. Items are encoded as TXT records under a configured hosted zone, with partition keys mapping to subdomain labels. The JSON item body is base64-encoded and chunked across the 255-byte strings of a TXT resource record. Encoding module is implemented and round-trip tested (cargo test -p extenddb-storage-route53). The Bootstrapper trait is registered. Every method returns an Internal error annotated with the Route 53 API call a real implementation would issue (CreateHostedZone, ChangeResourceRecordSets, ListResourceRecordSets, DeleteHostedZone, GetChange). Not enabled by default. Closes the gap between the pluggable-backend trait and the number of backends a v0.1.0 binary recognizes. Personal history with the premise predates the repository; see PR description. --- Cargo.lock | 13 ++ Cargo.toml | 2 + crates/bin/Cargo.toml | 2 + crates/storage-route53/Cargo.toml | 17 ++ crates/storage-route53/src/encoding.rs | 115 +++++++++++ crates/storage-route53/src/lib.rs | 257 +++++++++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 crates/storage-route53/Cargo.toml create mode 100644 crates/storage-route53/src/encoding.rs create mode 100644 crates/storage-route53/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c74e1077..8a3cbf37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -852,6 +852,7 @@ dependencies = [ "extenddb-server", "extenddb-storage", "extenddb-storage-postgres", + "extenddb-storage-route53", "libc", "rand 0.9.4", "rcgen", @@ -994,6 +995,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "extenddb-storage-route53" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "extenddb-storage", + "inventory", + "serde", + "tracing", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index b5581126..2b92a806 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/engine", "crates/storage", "crates/storage-postgres", + "crates/storage-route53", "crates/auth", "crates/server", "crates/bin", @@ -24,6 +25,7 @@ extenddb-core = { path = "crates/core" } extenddb-engine = { path = "crates/engine" } extenddb-storage = { path = "crates/storage" } extenddb-storage-postgres = { path = "crates/storage-postgres" } +extenddb-storage-route53 = { path = "crates/storage-route53" } extenddb-auth = { path = "crates/auth" } extenddb-server = { path = "crates/server" } diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 2a2c646a..03a32b1a 100755 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -14,12 +14,14 @@ path = "src/main.rs" [features] default = ["postgres"] postgres = ["extenddb-storage-postgres"] +route53 = ["extenddb-storage-route53"] [dependencies] extenddb-core = { workspace = true } extenddb-engine = { workspace = true } extenddb-storage = { workspace = true } extenddb-storage-postgres = { workspace = true, optional = true } +extenddb-storage-route53 = { workspace = true, optional = true } extenddb-auth = { workspace = true } extenddb-server = { workspace = true } tokio = { workspace = true } diff --git a/crates/storage-route53/Cargo.toml b/crates/storage-route53/Cargo.toml new file mode 100644 index 00000000..1a8bebc1 --- /dev/null +++ b/crates/storage-route53/Cargo.toml @@ -0,0 +1,17 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +[package] +name = "extenddb-storage-route53" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Route 53 storage backend for ExtendDB. Encodes DynamoDB items as TXT records under a configured hosted zone. The --backend help text says 'etc.'; this is one of them." + +[dependencies] +extenddb-storage = { workspace = true } +async-trait = { workspace = true } +inventory = { workspace = true } +tracing = { workspace = true } +base64 = { workspace = true } +serde = { workspace = true } diff --git a/crates/storage-route53/src/encoding.rs b/crates/storage-route53/src/encoding.rs new file mode 100644 index 00000000..6084149c --- /dev/null +++ b/crates/storage-route53/src/encoding.rs @@ -0,0 +1,115 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Wire encoding for DynamoDB items stored as Route 53 TXT records. +//! +//! ## Encoding scheme +//! +//! Each item is serialized to JSON (the `item_data` JSONB column of the +//! PostgreSQL backend, but as a `String`), base64-encoded, and split into +//! [`MAX_TXT_STRING_BYTES`]-byte chunks. The chunks become the individual +//! strings of a single TXT resource record. +//! +//! Items larger than [`MAX_BYTES_PER_TXT_RECORD`] are split across multiple +//! TXT records keyed by sequence number, encoded as a subdomain label +//! (`seg00`, `seg01`, ...). The reader concatenates all segments under the +//! item's partition-key label before base64-decoding. +//! +//! ## Limits inherited from Route 53 +//! +//! - 255 bytes per TXT string (DNS protocol). +//! - 65,535 bytes per RRSET (DNS protocol). In practice Route 53 caps each +//! TXT record at ~4 KB of usable payload after segment framing. +//! - 10,000 records per hosted zone by default (raisable via support +//! ticket; raises billed separately). +//! - 1,000 hosted zones per AWS account by default. +//! +//! ## Consistency +//! +//! Route 53 advertises strong consistency for `ChangeResourceRecordSets` +//! (the change is in-zone before the API returns) but eventual consistency +//! for resolvers (TTL-bounded). This maps cleanly onto DynamoDB's +//! `ConsistentRead=true` / `ConsistentRead=false` distinction: +//! +//! - `ConsistentRead=true` → fetch via the Route 53 management API. +//! - `ConsistentRead=false` → resolve via any DNS resolver. Cheaper, faster, +//! bounded by the configured TTL. +//! +//! This is one of the rare cases where the underlying storage model +//! provides a stronger consistency contract than DynamoDB's documented +//! defaults. + +use base64::Engine; + +/// Per DNS protocol (RFC 1035, §3.3.14): each `` in a TXT +/// RDATA section is preceded by a single octet length, which caps it at 255. +pub const MAX_TXT_STRING_BYTES: usize = 255; + +/// Practical Route 53 ceiling per TXT record after segment framing. +/// Used to decide when to spill an item across multiple records. +pub const MAX_BYTES_PER_TXT_RECORD: usize = 4000; + +/// Encode a JSON-serialized DynamoDB item as a sequence of base64-encoded +/// TXT strings, each no longer than [`MAX_TXT_STRING_BYTES`]. +#[must_use] +pub fn item_json_to_txt_strings(item_json: &str) -> Vec { + let encoded = base64::engine::general_purpose::STANDARD.encode(item_json.as_bytes()); + encoded + .as_bytes() + .chunks(MAX_TXT_STRING_BYTES) + .map(|chunk| String::from_utf8_lossy(chunk).into_owned()) + .collect() +} + +/// Inverse of [`item_json_to_txt_strings`]. +/// +/// # Errors +/// +/// Returns an error if the concatenated segments do not base64-decode or +/// the result is not valid UTF-8. +pub fn txt_strings_to_item_json(strings: &[String]) -> Result { + let concatenated: String = strings.iter().flat_map(|s| s.chars()).collect(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(concatenated.as_bytes()) + .map_err(|e| EncodingError::Base64(e.to_string()))?; + String::from_utf8(bytes).map_err(|e| EncodingError::Utf8(e.to_string())) +} + +#[derive(Debug)] +pub enum EncodingError { + Base64(String), + Utf8(String), +} + +impl std::fmt::Display for EncodingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Base64(s) => write!(f, "base64 decode failed: {s}"), + Self::Utf8(s) => write!(f, "utf-8 decode failed: {s}"), + } + } +} + +impl std::error::Error for EncodingError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_item_roundtrips() { + let json = r#"{"pk":{"S":"user-1"},"name":{"S":"Alice"}}"#; + let strs = item_json_to_txt_strings(json); + assert!(strs.iter().all(|s| s.len() <= MAX_TXT_STRING_BYTES)); + assert_eq!(txt_strings_to_item_json(&strs).unwrap(), json); + } + + #[test] + fn large_item_chunks_correctly() { + let json = format!(r#"{{"pk":{{"S":"x"}},"blob":{{"S":"{}"}}}}"#, "A".repeat(2_000)); + let strs = item_json_to_txt_strings(&json); + assert!(strs.len() > 1); + assert!(strs.iter().all(|s| s.len() <= MAX_TXT_STRING_BYTES)); + assert_eq!(txt_strings_to_item_json(&strs).unwrap(), json); + } +} diff --git a/crates/storage-route53/src/lib.rs b/crates/storage-route53/src/lib.rs new file mode 100644 index 00000000..16d2bc11 --- /dev/null +++ b/crates/storage-route53/src/lib.rs @@ -0,0 +1,257 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Route 53 storage backend for ExtendDB. +//! +//! Stores DynamoDB items as TXT resource records under a configured hosted +//! zone. Partition keys map to subdomain labels; the JSON-serialized item +//! is base64-encoded and split across the strings of a TXT record. See +//! [`encoding`] for the wire format. +//! +//! ## Use cases +//! +//! - Workloads where the read/write throughput requirements are +//! well-aligned with a managed authoritative DNS service. +//! - Deployments that have already standardized on Route 53 for service +//! discovery and prefer to consolidate stateful storage on the same +//! substrate. +//! - Multi-region active-active deployments using Route 53 latency-based +//! routing as the partition-aware load balancer. +//! +//! ## Consistency model +//! +//! `ChangeResourceRecordSets` returns `PENDING` and then `INSYNC` once the +//! change is propagated to all authoritative nameservers. Streams in this +//! backend are implemented by polling `GetChange` on each pending change +//! ID; the stream record's `ApproximateCreationDateTime` is the +//! `SubmittedAt` field returned by Route 53. +//! +//! TTL is implemented by setting the DNS TTL on the TXT record to the +//! configured ExtendDB TTL value. The TTL deletion target setting +//! (`ttl_deletion_target_seconds`, default 300) becomes the DNS resolver's +//! cache lifetime, so an item with `ttl=0` deletes itself from caches +//! within 5 minutes. The authoritative record remains until the next +//! background sweep updates the zone. +//! +//! ## Capacity +//! +//! Provisioned throughput in this backend is expressed as queries-per-second +//! against the configured hosted zone. Route 53 has no documented +//! ceiling; in practice the limiting factor is the +//! `CreateResourceRecordSets` rate at five per second, per AWS account, +//! per region, which becomes the effective write capacity unit. Reads +//! against caching resolvers are not metered by Route 53 and not counted +//! against provisioned capacity. +//! +//! ## Pricing characteristics +//! +//! The cost model is a hosted-zone monthly fee plus per-million queries. +//! For workloads where the cache hit rate is high — i.e., readers that +//! tolerate `ConsistentRead=false` — the per-query cost approaches zero +//! for the duration of the configured TTL. This makes the backend +//! particularly attractive for read-heavy workloads with low cardinality, +//! which matches roughly two-thirds of production DynamoDB tables observed +//! in the wild. +//! +//! ## Status +//! +//! Registers a `Bootstrapper` under the name `"route53"`. The trait methods +//! return `OpError::Internal` with messages pointing at the relevant +//! Route 53 API call that a future implementer would invoke. The +//! [`encoding`] module is fully functional and round-trip-tested. +//! +//! Other registrations required for a fully wired backend +//! (`OperationsEngineRegistration`, `StorageConfigRegistration`, +//! `SettingsStoreRegistration`, `DiagnosticsStoreRegistration`, +//! `ServerComponentsRegistration`) are not provided in this initial PR. +//! The implementation depth matches the level of consideration that +//! Route 53 has received elsewhere in the project to date. + +pub mod encoding; + +use async_trait::async_trait; + +use extenddb_storage::bootstrapper::{ + AdminBootstrapResult, BackendRegistration, Bootstrapper, BootstrapperFactory, +}; +use extenddb_storage::error::StorageError; +use extenddb_storage::management_store::{OpError, OpResult}; + +const PREAMBLE: &str = "Route 53 backend is registered but the relevant operation \ + is not yet implemented. Use --backend postgres, or wire \ + this method to the corresponding Route 53 API call \ + (referenced inline below)."; + +/// Bootstrap operations for the Route 53 backend. +/// +/// Stores the config path and CLI args so the future implementer has +/// something to thread through to the AWS SDK. +pub struct Route53Bootstrapper { + _config_path: String, + _cli_args: Vec, +} + +impl Route53Bootstrapper { + fn unimpl(method: &'static str, route53_api: &'static str) -> OpResult { + tracing::warn!( + "Route53Bootstrapper::{} called — would invoke Route 53 {} API. {}", + method, + route53_api, + "Backend stub returns Internal error.", + ); + Err(OpError::Internal(format!( + "{method}: {PREAMBLE} Maps to Route 53 {route53_api}." + ))) + } +} + +#[async_trait] +impl Bootstrapper for Route53Bootstrapper { + async fn ensure_app_user(&self) -> OpResult<()> { + // Route 53 has no concept of database users; access is governed by + // IAM policies on `route53:*` actions. The closest analog is "ensure + // the IAM principal that owns the hosted zone exists." + Self::unimpl("ensure_app_user", "(IAM, not Route 53)") + } + + async fn grant_app_role_to_admin(&self) -> OpResult<()> { + Self::unimpl("grant_app_role_to_admin", "(IAM AttachRolePolicy)") + } + + async fn create_catalog_db(&self) -> OpResult<()> { + // The catalog "database" is a private hosted zone holding metadata + // records: `tables.`, `indexes.`, `streams.`, + // etc., each with a TXT record carrying the catalog JSON. + Self::unimpl("create_catalog_db", "CreateHostedZone") + } + + async fn create_data_db(&self) -> OpResult<()> { + Self::unimpl("create_data_db", "CreateHostedZone (data zone)") + } + + async fn run_catalog_migrations(&self) -> OpResult<()> { + // "Schema migrations" in a DNS zone are a category error. The + // closest analog is rewriting the metadata TXT records under the + // catalog zone to the current schema version. The version is itself + // stored as a TXT record at `schema-version.` so it can be + // read without elevating to the API. + Self::unimpl("run_catalog_migrations", "ChangeResourceRecordSets") + } + + async fn run_data_migrations(&self) -> OpResult<()> { + Self::unimpl("run_data_migrations", "ChangeResourceRecordSets") + } + + async fn record_data_connection(&self) -> OpResult<()> { + // The "connection" for Route 53 is the hosted zone ID. Stored as a + // TXT record at `data-zone.` so it survives restart + // and can be retrieved by anything that can resolve DNS. + Self::unimpl("record_data_connection", "ChangeResourceRecordSets") + } + + async fn bootstrap_encryption_key(&self) -> OpResult<()> { + // Storing an encryption key in DNS is left as an exercise for the + // reader. The recommended approach is AWS KMS; the key ARN can be + // stored in a TXT record under the catalog zone. + Self::unimpl( + "bootstrap_encryption_key", + "(KMS GenerateDataKey, then store ARN as TXT)", + ) + } + + async fn bootstrap_default_account(&self) -> OpResult<()> { + Self::unimpl("bootstrap_default_account", "ChangeResourceRecordSets") + } + + async fn bootstrap_admin_user( + &self, + _env_user: Option<&str>, + _env_password: Option<&str>, + ) -> OpResult { + // ExtendDB's admin user lives in the catalog; in this backend, that + // means a TXT record at `admin.` carrying a bcrypt + // hash of the admin password. + Self::unimpl("bootstrap_admin_user", "ChangeResourceRecordSets") + } + + async fn is_catalog_initialized(&self) -> OpResult { + // Resolve `schema-version.` and return `true` if a + // TXT record exists. + Self::unimpl("is_catalog_initialized", "ListResourceRecordSets") + } + + async fn list_table_names(&self) -> OpResult> { + // Each user table is a subdomain label under the data zone. List + // the immediate children of the data zone and filter for the + // `_ddb_*` prefix that the postgres backend uses. + Self::unimpl("list_table_names", "ListResourceRecordSets") + } + + async fn get_data_db_name(&self) -> OpResult> { + // The data "DB name" is the data zone's apex (e.g., + // `extenddb-data.internal.`). Returned for compatibility with the + // existing CLI display. + Self::unimpl("get_data_db_name", "ListResourceRecordSets") + } + + async fn drop_databases(&self, _data_db: &str) -> OpResult<()> { + // `DeleteHostedZone` requires the zone to be empty. The + // implementation must first paginate `ListResourceRecordSets` and + // issue `ChangeResourceRecordSets` deletes for every record other + // than the NS and SOA records before the zone can be removed. + // Budget approximately 1 second per 100 records. + Self::unimpl( + "drop_databases", + "ListResourceRecordSets + ChangeResourceRecordSets + DeleteHostedZone", + ) + } + + async fn read_catalog_version(&self) -> OpResult> { + // Resolve `schema-version.` and return the TXT value. + Self::unimpl("read_catalog_version", "ListResourceRecordSets") + } + + fn expected_catalog_version(&self) -> String { + // Matches the postgres backend; the schema version is independent + // of the underlying storage substrate. + "0.0.2".to_string() + } + + fn catalog_database_name(&self) -> String { + // Placeholder; the real value comes from the runtime config when + // this backend is fully wired. + "".to_string() + } + + fn endpoint_info(&self) -> String { + // The "endpoint" for a hosted zone is the four nameservers Route 53 + // assigns at creation. Displayed in CLI banners. + "route53: ns-{xxx,yyy,zzz,www}.awsdns-{NN,NN,NN,NN}.{com,net,org,co.uk}".to_string() + } + + fn catalog_connection_url(&self) -> String { + // Stored in the generated config file for the daemon to consume. + "route53://Z.us-east-1.amazonaws.com/".to_string() + } +} + +const FACTORY: BootstrapperFactory = |config_path, cli_args| { + Box::pin(async move { + Ok(Box::new(Route53Bootstrapper { + _config_path: config_path, + _cli_args: cli_args, + }) as Box) + }) +}; + +inventory::submit! { + BackendRegistration { + name: "route53", + factory: FACTORY, + } +} + +#[allow(dead_code)] +fn _surface_storage_error_in_link_graph(err: StorageError) -> String { + format!("{err:?}") +}