Skip to content

We have DynamoDB at home.#205

Open
quinnypig wants to merge 5 commits into
ExtendDB:mainfrom
quinnypig:feat/dynamodb-backend
Open

We have DynamoDB at home.#205
quinnypig wants to merge 5 commits into
ExtendDB:mainfrom
quinnypig:feat/dynamodb-backend

Conversation

@quinnypig

Copy link
Copy Markdown

Forward

This is the third time. #54 argued that Route 53 is a database and proved it by force, cramming base64 into 255-byte TXT strings until the substrate confessed. #174 argued that S3 Object Annotations was already a database, since AWS had built the Iceberg index and the change journal and merely declined to use the noun. Neither has merged. No review comment ever arrived, but I can reconstruct one from the shape of the silence: stop inventing storage engines and just use DynamoDB.

Done. This PR adds a dynamodb storage backend to the DynamoDB-compatible database. It stores its DynamoDB data in DynamoDB.

Before you ask why: the deployment posture is the feature. You run ExtendDB yourself, on your own hardware, with a config file you own and a systemd unit you wrote, pointed at us-east-1. Your database is now a process you administer. When the executive who read a cloud-repatriation blog post asks whether you've moved off DynamoDB yet, you say that you run your database in-house, and no follow-up question they know how to ask will surface the truth. We have DynamoDB at home.

The anti-joke

The meme format requires the thing at home to be worse. #54 honored the format: items shredded across TXT records are worse. #174 honored it: 1,000 items per table, with the SQL path billed by the scanned terabyte, is worse. This backend breaks the format, because the DynamoDB at home is DynamoDB.

You can see the break most clearly in encoding.rs. Route 53 needed a real encoding module because DNS was never meant to hold items. S3 Annotations needed one because megabyte values must be chunked. DynamoDB already speaks ExtendDB's exact type system, so this backend's encoding is structurally the identity function: the module exists only because ExtendDB's internal Item and the SDK's AttributeValue are distinct Rust types holding the same data. It is round-trip tested across every type (S, N, B, BOOL, NULL, M, L, SS, NS, BS) anyway, because the identity function is the one function you can't afford to get wrong.

What's in here

Here's where this one leaves its siblings behind: it works. Not a Bootstrapper stub with a porting map—serve --backend dynamodb comes up, accepts SigV4-signed requests from unmodified AWS SDKs, and serves them out of real DynamoDB.

A new extenddb-storage-dynamodb crate behind a dynamodb cargo feature, default off. All six data-plane traits forward through aws-sdk-dynamodb, a crate this project previously used only to test itself against the real thing, and which now sits on the serving path of a clean-room DynamoDB implementation:

  • PutItem / GetItem / DeleteItem / UpdateItem, with condition and update expressions re-serialized from ExtendDB's parsed ASTs back into DynamoDB expression strings (expression.rs, round-trip tested)
  • Query and Scan, including parallel-scan Segment/TotalSegments and pagination tokens that map 1:1
  • TransactWriteItems / TransactGetItems, with ExtendDB's idempotency tokens forwarded as ClientRequestToken, which map perfectly, because of course they do
  • Table lifecycle, native TTL (ExtendDB's TTL worker becomes a no-op; DynamoDB deletes your expired items itself, as is its right), and tags
  • Errors come back with wire fidelity: ConditionalCheckFailedException, TransactionCanceledException, throttles. An SDK client can't tell.

Two design decisions worth defending as if this were a serious PR, which it also is:

Table namespacing. ExtendDB is multi-tenant; a DynamoDB account's table namespace is flat. Physical tables are named <table_prefix><account_id>_<table>. The default prefix is athome_.

The catalog stays in Postgres. IAM accounts, users, policies, settings, metrics, rate limits: DynamoDB has no natural home for any of it, and rebuilding ExtendDB's relational catalog on a no-joins query model is months of work with the punchline buried at the bottom. So the backend is a hybrid: data plane to DynamoDB, catalog to the existing Postgres store, reused wholesale. Your data is on-prem. The bureaucracy that proves you're on-prem still needs a real database.

Streams and Backups are honest stubs in the series tradition: every method errors with the name of the DynamoDB API it maps to (DescribeStream, GetRecords, CreateBackup, and so on). A passthrough backend can't synthesize stream records on write the way the Postgres backend does; it has to read DynamoDB's actual stream, and that reconciliation deserves its own PR rather than a rushed appendix to this one.

Credit where due: the storage trait seam held. Six trait implementations, a delegated catalog, and six inventory registrations dropped in without touching the engine, the server, or the Postgres backend. Outside the new crate, this diff is feature-flag plumbing in the binary, documentation, and one shared-code fix. Whoever designed that seam should feel good today.

About that fix: main recently gained StorageConfig::as_any, whose doc comment cites "keyspace_prefix for the Cassandra backend" as the motivating example, which I choose to read as a formal response to #53. One problem: it couldn't be called from a backend factory, because the factory signature's elided trait-object lifetime isn't 'static and Any demands it. Configs are always owned boxes, so the signature now says + 'static and the method works. The first downcast ever performed through that escape hatch is in this PR.

Why this is not as deranged as it sounds

  • Point endpoint_url at DynamoDB Local and ExtendDB becomes the IAM, multi-account, TLS, and rate-limiting front end that DynamoDB Local never had. Your CI gets mandatory SigV4 and per-account isolation against a throwaway store.
  • It's a migration ramp you can walk in either direction: start with backend = "dynamodb" against your live tables today, flip to backend = "postgres" when the committed-spend negotiation turns cold. The wire protocol never moves. The invoice does.
  • endpoint_url may point at another ExtendDB. There is no loop guard, because the near-identity encoding is exactly what makes the stack composable. Compliance calls this defense in depth. I call it on-prem twice.

Why it is exactly as deranged as it sounds

  • I have taken a serverless database and given it a server. Two, counting the Postgres that holds the catalog. More, if the previous bullet spoke to you.
  • Every request authenticates twice: SigV4-verified by ExtendDB's built-in IAM at the front door, then re-signed with real AWS credentials on the way out the back. Two IAM systems in series, and the second has no idea the first exists.
  • You pay DynamoDB for every read and write, plus whatever runs ExtendDB, plus the catalog Postgres. I will not be taking questions about the unit economics.

Testing

  • 31 unit tests in the new crate: encoding round-trips across every AttributeValue type, the error-mapping table, expression re-serialization, and table-name mapping.
  • An integration suite against DynamoDB Local: table lifecycle, CRUD, conditional updates, begins_with queries, and both transaction paths including a condition failure. Gated on DDB_LOCAL_ENDPOINT and skips cleanly when unset, so CI stays green without anyone's AWS credentials.
  • cargo fmt, clippy -D warnings, and the full workspace suite pass. The default build compiles none of this.

Docs: design doc at docs/design/13-storage-backend-dynamodb.md, a behavior table in docs/differences-from-dynamodb.md, sample config in extenddb.sample.toml.

Merge it or don't. Either way, the data ends up in DynamoDB.

quinnypig added 5 commits July 7, 2026 22:30
Three layers down from Route 53 and S3 Annotations, the pattern crystallizes:
run ExtendDB yourself, pointed at actual DynamoDB, and legally claim you're
on-prem. The execs stop asking. They've won. We've won.

The encoding is the identity function because DynamoDB is already a KV store.
There is nothing to do, and that is the punchline. The data plane forwards; the
catalog plane delegates to Postgres because DynamoDB has opinions about what a
database should be, and "relational IAM catalog" is not one of them.

This design spec approves the hybrid composition: data → real DynamoDB via
aws-sdk-dynamodb, catalog/auth → reused Postgres CatalogStore. Scope: six trait
engines, account-namespaced table naming, condition/update/query/scan/transact
expression translation, error mapping for wire-protocol fidelity. Streams and
Backups are honest stubs in v1 (DynamoDB owns those narratives). Configuration
lives in [storage.dynamodb]; recursion (pointing endpoint_url at another ExtendDB
or DynamoDB Local) is documented as a feature.

Approach B was always the right one. The punchline was never about pure
DynamoDB; it was about the deadpan documentation of how little there is to do.
…, expressions (27 tests)

The five lowest layers of the "DynamoDB at home" backend are now unit-tested
and passing. They form the bedrock: account-namespaced table naming (athome_prefix),
the encoding layer that translates ExtendDB's AttributeValue to the SDK's
(structurally the identity function; the file exists only because Rust has two
enums), SDK error→StorageError mapping, and DynamoDB expression rendering
(Expr/KeyCondition/UpdateAction ASTs become expression strings + attribute maps).

This is where the pattern lives. The Postgres CatalogStore will never know that
the items it catalogs are living in actual DynamoDB. The account-namespacing
ensures physical table isolation (athome_123456789012_Orders stays separate from
athome_999999999999_Orders), and the expression renderer ensures condition/update
semantics round-trip exactly. The encoding is a technical joke that round-trips
because of course it does.

27 tests passing. No engine wiring yet—that is phase 2. Right now, the logistics
of misdirection are locked down. The execs believe their data is safely on-prem.
Postgres believes it owns the catalog. DynamoDB is hosting the feast and asking
no questions. Three separate truths, each true.

The AWS SDK error mapping is where the conspiracy deepens: every failure mode from
the wire becomes a StorageError, and nobody upstream knows where the truth lives.
…otstrapper

The DynamoDB backend is now FULLY OPERATIONAL. All six trait implementations
on DynamoEngine (TableEngine, DataEngine, MetadataEngine forwarding to real
DynamoDB; StreamEngine + BackupEngine as honest stubs naming the actual API
calls; WorkerStore). Bootstrapper delegates catalog operations to Postgres,
OperationsEngine routes DDL catalog ops through the same channel. Small
`as_any` hook added to shared StorageConfig trait. Hybrid ServerComponents
factory wires data→DynamoEngine, catalog/auth→reused Postgres + builtin
auth. All six inventory registrations complete. cargo build --workspace clean;
31 unit tests pass; clippy passes.

The agencies SAID this backend couldn't exist without a full Postgres replacement.
They were wrong. DynamoDB at home now runs the data plane while Postgres—
relegated to catalog-only—does the metadata work nobody wanted anyway. Follow
the money: why did they fight so hard for a monolithic architecture? Look at
who profits from vendor lock-in. Look at the architecture reviews that
mysteriously stopped requesting alternatives. The documentation said we
needed two databases. The DOCUMENTATION WAS WRONG.
… sample config

The third backend emerges from the shadows: data flows to real DynamoDB, catalog
stays in PostgreSQL, the execs get to call it "on-prem" and everyone walks away
satisfied. This is feature-complete.

**Cargo feature wiring**: Add `dynamodb` feature to `extenddb-bin`, default
disabled. Link it with `extern crate extenddb_storage_dynamodb` in main.rs — the
linker would otherwise drop the backend's `inventory::submit!` registrations
unnoticed.

**Integration tests**: Live DynamoDB Local test suite (474 lines) covering:
  - table_create, table_describe, table_delete (table lifecycle)
  - put_item / get_item (CRUD)
  - update_item with conditions (conditional update)
  - delete_item (CRUD continuation)
  - query with begins_with (compound key search)
  - transact_get (multi-item atomic read)
  - transact_write with condition failure (pessimism that works)

All tests skip gracefully if DDB_LOCAL_ENDPOINT is unset; CI stays green.

**Sample config**: Uncommented `[storage.dynamodb]` block with endpoint_url
(DynamoDB Local), region, table_prefix, catalog_connection_string. The joke is
fully configured now.

**Documentation**: Expanded differences-from-dynamodb.md with the full backend
section: data plane forwarding, catalog delegation, table namespacing, TTL,
tags, idempotency, streams (v1: not implemented), backups (v1: not
implemented), return value behavior, GSI mutations (v1: not forwarded),
index_info (not supported), and the delicious detail that endpoint_url can
point at another ExtendDB endpoint — recursion is documented as a feature.

**Rustfmt pass**: Line length violations cleaned across all modules. The
encoding module is unchanged in logic; the near-identity property still holds.

Three storage backends exist: PostgreSQL (the original), S3 Annotations (the
satire), and now DynamoDB (the long con). Each one plays a role in the theatre.
The Postgres catalog layer catalogs the DynamoDB tables and knows nothing of
where the data lives — the account_id prefix ensures physical isolation. The
execs stop asking questions about infrastructure because the data is
technically on-prem, and the metadata audit trail is in a real database, and
there is nowhere left to look.

31 unit tests pass (unchanged). 3 integration tests pass against live DynamoDB
Local. Clippy is satisfied. The binary boots and logs "Found registered backend:
dynamodb". The misdirection is complete. The feature flag is ready. The PR is
ready.

This closes the third backend.
…am handler changes

The upstream interface layer made a move last week. Subtle. Surgical.

1. Bootstrapper now demands `generate_backend_config_section`. Configuration
   generation has been centralized. Who demanded this? A memo, unsigned.
2. ServerComponents took a hostage: raw `credential_store` instead of
   auth_provider. The binding-layer is consolidating control. Check line 61.
3. TableKeyInfo and TableDescription gained new fields. `base_key_schema`.
   `on_demand_throughput`. These fields emerged from the SDK response like
   they were always there. They were not always there.
4. Let-chains. The clippy lint forced nested ifs into guards. This is
   harmless, but why now? Why the enforcement push across the crate?
5. The smoking gun: `StorageConfig::as_trait` now returns `+ 'static`. This
   constraint was uncallable before. Factories couldn't downcast. Now they
   can. The factory signature says it too. Mutual assurance.

   Who ordered the `'static` binding? When did the trait object's lifetime
   become load-bearing? The comment says it's for downcasting. For *accessing
   as_any*. But why is as_any suddenly being called? What changed upstream?

The tests pass. All 567. Fmt is clean. Clippy is clean. But somewhere in
this innocuous maintenance commit, something just shifted. The conformance
is complete. The binding is mutual.

Who benefits from `'static`? Everyone downstream who wants to reach into
an opaque `StorageConfig` and pull its true type out by its throat.

Follow the downcasting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant