Skip to content

[RFC] MongoDB Storage Backend - #207

Open
diegotoledano95 wants to merge 69 commits into
ExtendDB:mainfrom
diegotoledano95:rfc/mongodb-backend
Open

[RFC] MongoDB Storage Backend#207
diegotoledano95 wants to merge 69 commits into
ExtendDB:mainfrom
diegotoledano95:rfc/mongodb-backend

Conversation

@diegotoledano95

@diegotoledano95 diegotoledano95 commented Jul 8, 2026

Copy link
Copy Markdown

What

Adds docs/rfcs/0000-mongodb-backend.md, a draft RFC for adding MongoDB as a optional ExtendDB storage backend.

Why

MongoDB is a natural fit as an additional database target: data model alignment; high read/write throughput through horizontal scalability; infrastructure fit.

DynamoDB and MongoDB share the same data model approach - documents stored as schema-less JSON-like data. MongoDBs document model maps directly to the approach taken by DynamoDB with each item stored as a MongoDB BSON document with no impedance mismatch at the data model level. Unlike relational databases, the translation from JSON to BSON is direct without complicated relational mapping techniques required.

This PR proposes the RFC tracked by the below issue.

Closes #206
Related forked implementation code

Testing done

git diff --check
python docs/build-docs.py

Checklist

  • I have read CONTRIBUTING.md
  • Code is formatted (cargo fmt --check) (No Rust code was changed)
  • I have updated documentation if behavior changed - [x] This PR is the RFC for the proposed MongoDB storage backend

ADR / RFC: This PR

@LeeroyHannigan LeeroyHannigan added the RFC Request for Comments, a proposal open for discussion before implementation label Jul 8, 2026
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Hi @diegotoledano95,

Thank you for the contribution RFC. The RFC looks great, but I did find some gaps while reviewing the reference implementation. Below are the findings, ranging from critical to minor.

Please let us know if you need any of the below clarified, we'd be happy to help.

Critical Findings

C1. GSI index collection uses GSI keys as document _id, silent data loss on duplicate index keys

Where: data_engine.rs:1215-1234 (sync_indexes / sync_indexes_in_session); _id construction in data/mod.rs:51.

DynamoDB: GSIs (and LSIs) allow multiple items with identical index pk+sk. A Query on the index returns all of them. The index entry is uniquely identified by index keys plus base table keys, the PostgreSQL reference stores base_pk/base_sk_* columns and deletes filter on them (storage-postgres/src/data/index.rs:234-299).

This code: Index documents are built with item_to_document(&projected, &idx_key_schema, ...), so _id and the replace filter derive solely from the index pk/sk. Two base items with the same GSI key produce the same _id; the second replace_one(...upsert) silently overwrites the first.

Failure scenario: Table Orders with GSI on customer_id + order_date. A customer places two orders on the same date -> only the second appears in GSI queries. Worse: deleting the first item afterwards deletes the surviving index entry that now represents the second item (delete_one matches on index keys only), so both vanish from the index.

C2. TransactWriteItems drops stream records and GSI updates entirely

Where: data_engine.rs:1876-1902 (OwnedTransactWriteOp has no stream field), :1904-1957 (clone_transact_write_op discards stream and return_values_on_ccf via .. on every variant), :1618-1865 (execute_transact_write_op_in_session never calls sync_indexes_in_session or write_stream_inline_in_session).

Trait contract: storage/src/lib.rs:313-315: "When stream is Some, stream records for each write operation are inserted in the same transaction as the data writes." PostgreSQL does exactly this (storage-postgres/src/data/transactions.rs:145-168) and syncs GSIs in-transaction (:342-468).

Failure scenario: Any application using TransactWriteItems on a streams-enabled table (the canonical outbox/CDC pattern) gets zero stream records, consumers silently miss every transactional write. Any GSI query after a transactional write returns permanently stale results; there is no async repair queue in this backend.

C3. MongoDB WriteConflict -> InternalServerError (500) under normal contention; TransactionConflictException never produced

Where: every Mongo error maps to StorageError::Internal(e.to_string()), put commit data_engine.rs:445-448, delete commit :560-563, update replace/commit :698-702, 734-737, transact op writes :1685, 1741, 1814, transact commit :1610-1613. No check for TransientTransactionError / UnknownTransactionCommitResult labels or WriteConflict (code 112) anywhere; no retry loop around any transaction.

DynamoDB: two concurrent PutItems on the same key both succeed (last-writer-wins). A write conflicting with a transaction returns retryable TransactionConflictException. The PostgreSQL backend matches via row-level locks (second writer waits, then succeeds).

This code: every single-item PutItem/DeleteItem opens a snapshot multi-document transaction (:340-359, :484-503), so WiredTiger aborts one side of any same-document race with WriteConflict -> HTTP 500. MongoDB's contract is "abort-and-retry"; the backend implements neither retry nor error mapping. Under sustained contention the backend sprays 500s where DynamoDB and the PostgreSQL backend absorb the contention silently.

C4. Sequence numbers assigned outside the transaction -> gap-read: stream records permanently skipped

Where: stream_engine.rs:430-453 (next_sequence_number: find_one_and_update $inc on a counters doc, no session, runs outside any transaction); data_engine.rs:1388-1391 (called from write_stream_inline_in_session without the session; record inserted with .session() but the counter increment is immediately visible while the data transaction commits later).

Failure scenario: Writer A draws seq 5, its transaction commits at T+50ms. Writer B draws seq 6, commits at T+10ms. A consumer polling at T+20ms with sequence_number $gt <cursor> reads seq 6 and advances its iterator past 5 (the engine encodes AFTER_SEQUENCE_NUMBER|<last_seq> in the next iterator, engine/src/streams.rs:236-247). When A commits, seq 5 lands behind the cursor and is never returned. Permanent record loss for the consumer. The update_item retry loop (:630-742) re-draws a fresh sequence number per conflicted attempt, widening the window. PostgreSQL assigns nextval() inside the data transaction (storage-postgres/src/data/tx_helpers.rs:284-289).

C5. UpdateTable GSI creation performs no backfill (and reports ACTIVE immediately)

Where: table_engine.rs:610-653, the create branch inserts only a catalog document with "index_status": "ACTIVE" (:636). No scan of the existing base collection, no index-entry writes, no CREATING->ACTIVE transition.

DynamoDB: adding a GSI backfills all existing items while the index is CREATING, then flips to ACTIVE. PostgreSQL: backfill_gsi(...) inside the DDL transaction (storage-postgres/src/update_table.rs:365-374).

Failure scenario: add a GSI to a table with 1M items, the index reports ACTIVE immediately and permanently contains only items written after the UpdateTable. Queries silently return incomplete results with no error.

C6. UpdateItem old-image not captured in the common case -> stale GSI entries on every index-key change

Where: slow path data_engine.rs:672-677, need_old = return_old || stream.is_some(); when the client didn't request ReturnValues=ALL_OLD and streams are off (the overwhelmingly common case), old_item = None, so sync_indexes_in_session's "delete old index entry" branch (:1295-1300) never runs. Fast path :587-612 is worse: explicitly calls sync_indexes(key_info, None, Some(&item)) (:608), non-transactionally, and it handles native $set/$unset, precisely the updates that change or remove GSI key attributes.

DynamoDB: changing a GSI key attribute removes the entry under the old key and adds one under the new key; removing the attribute removes the entry (sparse).

Failure scenario: UpdateItem SET status = :done on a status-keyed GSI -> the item remains indexed under pending and done forever. REMOVE status leaves it in the GSI forever. PostgreSQL always fetches the old image and deletes the old index row in the same transaction (storage-postgres/src/data/index.rs:138-180).

C7. Shard IDs collide across accounts and table re-creations, cross-tenant stream record leakage (SECURITY)

Where: stream_engine.rs:42 (shard_id = format!("shardId-{table_name}-{i:012}"), embeds table name, not table_id); lib.rs:231 (single shared extenddb_data database for all accounts); stream_engine.rs:114-121, 505-515 (get_stream_records / latest_sequence_number filter only by shard_id); table_engine.rs:415-470 (delete_table_impl never deletes stream_shards/stream_records rows); stream_engine.rs:44-52 (no unique index on stream_shards.shard_id; plain insert_one).

Confirmed exploitable end-to-end: engine/src/streams.rs::handle_get_records calls get_stream_records(shard_id, …) with no account_id and no validate_shard, only GetShardIterator validates ownership, and the attacker never needs to call it.

PostgreSQL baseline: one data database per account (extenddb_account_<id>), shard_id PRIMARY KEY with table_id REFERENCES tables ON DELETE CASCADE and cascading stream_records (storage-postgres/migrations/001_schema.sql:85-107).

Failure scenarios:

  • Account A and Account B both create table users with streams -> both get shard shardId-users-000000000000 in the shared stream_records collection. Account B's consumer receives Account A's items (keys + full images). Cross-tenant data disclosure.
  • Delete and recreate a table with the same name -> the new stream replays the deleted table's history (records are never cleaned, see M11), and init_stream_shards inserts duplicate shard docs so DescribeStream returns duplicated shards.

C8. UpdateItem that creates an item emits MODIFY with a fabricated OldImage instead of INSERT

Where: data_engine.rs:648-679, when existing_doc is None, existing_item = key.clone(); with streams enabled old_item = Some(<key attributes>) even though no item existed. write_stream_inline_in_session (:1346-1351) then matches (Some(_), Some(_)) => Modify and populates OldImage with a phantom key-only item for OLD_IMAGE/NEW_AND_OLD_IMAGES view types (:1367-1370).

DynamoDB: UpdateItem on a non-existent key is an insert -> eventName: INSERT, no OldImage. PostgreSQL is correct here (storage-postgres/src/data/update_item.rs:89-94).

Failure scenario: consumers that branch on eventName ("on INSERT, send welcome email") never fire for upsert-created items; OLD_IMAGE consumers receive an old image for an item that never existed.

C9. _id = "{pk}#{sk}" composite is collision-prone, two distinct items cannot coexist

Where: data/mod.rs:51 (doc.insert("_id", format!("{pk_text}#{sk_text}"))); pk_to_text (storage/src/util/key.rs:77-86) does not escape #, only multi-HASH-attribute composites get netstring encoding.

Failure scenario: item A {pk:"a#b", sk:"c"} and item B {pk:"a", sk:"b#c"} both produce _id = "a#b#c". The second insert fails E11000, which put_item_impl:401 misclassifies as ConditionalCheckFailedException, or an unconditional replace silently targets the wrong document. Two legitimately distinct DynamoDB items cannot both exist. Scan pagination (_id $gt start_id, data_engine.rs:964-968) is ambiguous across the same collision. (Note: N-value normalization in core means "1" vs "1.0" is not a collision source, the # delimiter is the real problem.)

C10. Numbers with >34 significant digits: Decimal128 rounding / f64 fallback collides distinct keys

Where: data/mod.rs:62-79 (item_to_document), :152-164 (pk_filter), data_engine.rs:2032-2043 (sk_to_bson).

DynamoDB: 38 significant digits, all values distinct. Decimal128 holds 34; the fallback stores an f64 (~15-17 digits), and sk_n then mixes BSON double and Decimal128 across documents.

Failure scenario: two sort keys differing only in digits 35-38 map to the same sk_n -> E11000 on the unique (pk, sk_n) index, or pk_filter equality matches the wrong document, GetItem returns / PutItem replaces a different item. PostgreSQL uses exact BigDecimal.

(Verification status: spec-grounded, Decimal128 = 34 sig digits by IEEE-754 decimal128; DynamoDB Number = 38 sig digits. Live us-east-1 confirmation with a 39-digit sort key available on request.)

C11. Idempotency tokens are not account-scoped in the shared data DB, cross-account ClientRequestToken collision (ADDED)

Where: data_engine.rs:1549-1583 (token check/insert: find_one({token}) / insert_one({token, fingerprint, created_at}), no account_id); the shared compute_fingerprint (HMAC over TransactItems) also omits account; lib.rs:231 (single shared extenddb_data database for all accounts). Distinct from M4 (which is the missing unique index / concurrent double-execution); this is the cross-account collision on the same collection.

DynamoDB: ClientRequestToken idempotency is scoped per-account (+region); two accounts may reuse the same token string independently.

PostgreSQL baseline: per-account data database (extenddb_account_<id>) inherently scopes tokens; the canonical fix keys idempotency on (account_id, token) (SQLite commit 2738c4e; PR #208 does the same for Postgres, still open).

Failure scenario: Account B issues a TransactWriteItems reusing a ClientRequestToken string that Account A used within the 10-minute window -> B's write is silently deduped as a replay (or rejected with IdempotentParameterMismatchException if the fingerprint differs). Cross-tenant correctness violation. Fix: key idempotency_tokens on (account_id, token) and include account_id in the fingerprint (same shape as C7's per-account data-layout decision).

Major Findings

M1. ReturnValuesOnConditionCheckFailure ignored inside transactions

Where: data_engine.rs:1667-1671 (Put), :1731-1735 (Delete), :1792-1796 (Update), :1856-1860 (ConditionCheck), all hardcode condition_check_failed_with_item(None); the field is dropped in clone_transact_write_op.

DynamoDB: with ALL_OLD, CancellationReasons[i].Item contains the existing item. PostgreSQL implements this (storage-postgres/src/data/transactions.rs:505-528). Per-item reason ordering and ConditionalCheckFailed-at-the-right-index are otherwise correct.

M2. UpdateItem OCC retry loop guards a near-impossible failure; real conflicts are not retried

Where: data_engine.rs:630-747. The read, condition eval, and versioned replace_one all execute inside one snapshot transaction, so the _v mismatch path (matched_count == 0, the only retried path, :704-710) is nearly dead code. Actual concurrent conflicts surface as WriteConflict on replace_one or commit_transaction, mapped to Internal and returned immediately, the 50-attempt loop never engages. Concurrent-create upserts (:711-719) both take the insert branch; the loser gets a 500. Exhaustion returns Internal("too many version conflicts") -> 500, an error DynamoDB has no analogue for.

Failure scenario: an ADD requests :one rate-limiter at ~50 concurrent updates/sec on one key returns a stream of 500s; DynamoDB and PostgreSQL (lock waits) succeed. The native $inc fast path explicitly gives up on numeric ADD (:1074-1094), so every ADD takes the conflict-prone transaction path.

M3. Conditional-insert race: E11000 -> ConditionalCheckFailed mapping is dead code inside a transaction

Where: data_engine.rs:399-413. The insert_one runs inside a snapshot transaction: a competing insert that committed after the snapshot manifests as WriteConflict (code 112), not E11000, the E11000 branch (:401, also fragile string matching on e.to_string()) almost never fires; the race lands in the Internal arm -> 500.

Failure scenario: two clients race PutItem with attribute_not_exists(pk). DynamoDB: one OK, one ConditionalCheckFailedException. Here: one OK, one InternalServerError. Static (non-racing) condition combinations were verified correct.

M4. No unique index on idempotency token -> duplicate ClientRequestToken can execute twice

Where: token check/insert data_engine.rs:1549-1583; bootstrapper.rs:84-106 creates only a TTL index on created_at. Two concurrent requests with the same token start snapshot transactions; neither sees the other's uncommitted token doc; both insert (no unique constraint) and both execute. PostgreSQL avoids this with ON CONFLICT (token) (storage-postgres/src/data/tx_helpers.rs:338-368). (See also C11: even with a unique index, the key must be (account_id, token).)

Failure scenario: double-applied ADD inside a TransactWriteItems Update, violates DynamoDB's exactly-once guarantee for ClientRequestToken.

M5. Binary sort key ordering diverges from DynamoDB

Where: data/mod.rs:82-90 (sk_b stored as BSON Binary), data_engine.rs:820-836 (native range filters/sort). MongoDB compares BinData by length first, then subtype, then bytes; DynamoDB orders binary by unsigned lexicographic byte order. Example: DynamoDB says [0x01,0xFF] < [0x02]; MongoDB says [0x02] < [0x01,0xFF]. The code patches begins_with with a post-fetch filter (:881-904) but not range conditions (BETWEEN, <, >) or sort order.

Failure scenario: any table with Binary sort keys returns Query results in the wrong order and wrong result sets for range conditions. (Verification status: spec-grounded, BSON BinData comparison order is length->subtype->bytes; DynamoDB binary is unsigned byte order. Live confirmation available on request.)

M6. Condition-pushdown compiler is dead code with multiple latent correctness bugs; RFC describes it as the design

Where: condition.rs, condition_to_filter is imported at data_engine.rs:27 but never called. Every runtime condition path (put :373, delete :516, update :660, all four transact ops) does read-then-extenddb_core::expression::evaluate_condition inside a transaction, matching the PostgreSQL baseline. #![allow(unused)] at lib.rs:10 suppresses the warning that would have exposed this.

The RFC's headline claim, "condition expressions compiled to MongoDB filters and executed via atomic findOneAndReplace, no separate fetch, no race window", does not describe the implementation as built. Worse, the compiler contains latent bugs that go live the moment someone wires it up:

  • Numeric comparisons compile to BSON string comparisons (condition.rs:61-73): {"item_data.price.N": {"$lt": "100"}} is lexicographic, "9" > "10", "2" > "100", negative ordering inverted. The unit test at condition.rs:548 enshrines the bug. BETWEEN and IN inherit it.
  • Eq/Ne on sets, lists, maps compile to Bson::Null (:71), matches nothing where DynamoDB does deep equality.
  • size() in a comparison is rejected (:359-366, :115) with a ValidationException where DynamoDB evaluates it.
  • IN with a mixed-type list uses the first literal's type for all entries (:415-427): a IN (:s, :n) can match across types where DynamoDB says S"7" ≠ N"7".
  • begins_with on Binary rejected (:279-281); DynamoDB accepts B operands.
  • attribute_type type-name injected into the field path unvalidated (:346-356).

Either wire the compiler up after fixing these (with $expr/$toDecimal or typed storage for numerics), or delete the module and correct the RFC text.

M7. Parallel Scan can silently drop items

Where: data_engine.rs:974-1028, fetch window capped at (limit+1) * total_segments, CRC32 segment filter applied post-fetch, last_evaluated_key = None whenever fewer than limit+1 items survive. With key-hash skew, a segment's items beyond the fetch window are silently dropped and the scan terminates early. DynamoDB guarantees every item is returned by exactly one segment. (The RFC discloses full-collection-scan-per-segment as a performance tradeoff, but not this correctness gap.)

M8. gsi_cache incoherent across processes, indexes silently never maintained

Where: lib.rs:213 (per-process DashMap<table_id, bool>); negative-cache early return data_engine.rs:1165-1169, 1252-1256; populated on any write (:1239, :1327); invalidated only in-process on UpdateTable (table_engine.rs:652, 669). If process A cached false and process B adds a GSI, process A skips index maintenance for every write until restart. Combined with C5 (no backfill), these gaps are unrecoverable.

M9. Query pagination clobbers the sort-key range condition (all queries, not just index queries)

Where: data_engine.rs:810-835, build_sk_filter inserts e.g. {sk_n: {$gte: 5, $lte: 10}} into the filter (:813-816), then the ExclusiveStartKey block does filter.insert(sk_f, doc! { "$gt": sk_bson }) (:829/:831). Document::insert replaces the existing entry under the same key.

Failure scenario: Query sk BETWEEN 5 AND 10, Limit 2 -> page 1 correct (LEK at sk=7); page 2's filter is just sk > 7, the <= 10 bound is gone -> returns items 11, 12, 15... outside the requested range. Same for begins_with (the upper prefix bound is lost -> page 2 escapes the prefix) and backward pagination ($lt replaces the lower bound). For index queries the resume additionally discards the base-key half of the ESK that DynamoDB requires to disambiguate duplicate index keys (masked today by C1). The engine layer builds the combined base+index LEK correctly (engine/src/query.rs:397-407); the storage resume logic discards it.

M10. No index-key type validation on write

Where: data_engine.rs:2089-2093 (item_has_index_keys checks contains_key only); the backend never calls extenddb_core::validation::validate_index_keys (PostgreSQL does, storage-postgres/src/data/put_item.rs:44-57).

Failure scenario: an item whose GSI key attribute has the wrong scalar type produces a malformed index document (typed sk_* field silently skipped, data/mod.rs:61-79) that later deletes can't match, another stale-entry source; non-scalar values surface as InternalServerError mid-write. DynamoDB returns a clean ValidationException up front.

M11. 24-hour stream retention never enforced, cleanup worker never spawned

Where: lib.rs:124-130, spawn_workers spawns only the TTL worker. cleanup_expired_stream_records (stream_engine.rs:365-380) has no caller; no Mongo TTL index on stream_records.created_at (the bootstrapper does create one for idempotency_tokens, so the omission is streams-specific). PostgreSQL spawns an hourly cleanup with RETENTION_HOURS = 24 (storage-postgres/src/workers.rs:92-118).

Failure scenario: stream_records grows without bound; TRIM_HORIZON replays the table's entire lifetime instead of ≤24h; combined with C7, deleted tables' records persist forever.

M12. UpdateTable stream enable is not idempotent, duplicate shards and broken stream ARNs

Where: table_engine.rs:578-596, whenever stream_enabled == true, unconditionally generates a new stream_label and re-runs init_stream_shards; no existing-shards check, no unique index. PostgreSQL checks for existing shards first (storage-postgres/src/update_table.rs:149-201). DynamoDB rejects enabling an already-enabled stream with ValidationException.

Failure scenario: a redundant UpdateTable with StreamEnabled: true (idempotent IaC re-apply) (a) inserts 4 duplicate shard docs -> DescribeStream returns 8 shards; consumers enumerate shards and process every record twice; (b) rewrites stream_label, so the previously issued stream ARN stops resolving, in-flight consumers get ResourceNotFoundException mid-stream.

M13. Scan on a GSI builds the pagination cursor from the base-table key schema

Where: data_engine.rs:936-969, the collection is the index collection (:940), but composite_pk_to_text(start_key, &key_info.key_schema) and sk_info(&key_info.key_schema, ...) (:950-952) use the base table schema, while index documents' _id is index-pk#index-sk. The resume filter compares against the wrong _id shape -> paginated index Scans skip or repeat arbitrary ranges. LEK extraction (:1036-1038) likewise uses the base schema on index items.

M14. Binary begins_with post-filter drops matches and loses pagination

Where: data_engine.rs:2010-2016 returns an empty filter for binary begins_with (fetches the whole partition), fetch capped at limit+1 (:847), items retain-ed by prefix post-fetch (:890-901), LEK computed only if items.len() > limit (:907-917).

Failure scenario: a partition with 1000 items where 50 match the binary prefix but none appear in the first limit+1 docs -> returns 0 items with no LastEvaluatedKey -> the 50 matching items are silently unreachable. DynamoDB computes LEK from the last item read, never dropping matches.

M15. UpdateItem fast path bypasses version control, concurrent CAS update can silently discard its write

Where: data_engine.rs:587-612, the native $set/$unset fast path doesn't bump _v, so a concurrent slow-path update (:694-709) can pass its version check against the pre-fast-path version and overwrite the fast path's committed write. Lost update where DynamoDB serializes.

Minor Findings

  • m1. Unconditional single-item writes needlessly wrapped in snapshot multi-document transactions (data_engine.rs:340-359, 484-503), the direct cause of C3's contention 500s and added latency; a plain find_one_and_replace with majority write concern would match DynamoDB last-writer-wins in the no-condition/no-GSI/no-stream case.
  • m2. Decimal128 sort keys: 34 significant digits vs DynamoDB's 38; keys with 35-38 sig digits lose precision (collide or misorder). The f64 fallback (data/mod.rs:68-77) mixes BSON double and Decimal128 in one field.
  • m3. A user-supplied connection string containing readPreference=secondaryPreferred would silently break ConsistentRead=true; nothing validates/strips it (lib.rs:216-241). Read-your-writes otherwise holds (primary reads, majority writes).
  • m4. Idempotency window is ~10-11 min (Mongo TTL monitor runs every 60s) and there is no created_at check on read; PostgreSQL explicitly allows token reuse after 10 min.
  • m5. Single global sequence counter ({"_id": "stream_seq"}) shared across all shards/tables/accounts, cluster-wide write hotspot; get_i64("value").unwrap_or(1) silently resets sequencing to 1 on a type mismatch instead of erroring (stream_engine.rs:450).
  • m6. No index (or uniqueness) on stream_records (shard_id, sequence_number), GetRecords is a sorted full-collection scan that degrades as records accumulate (unbounded per M11). Zero-padded {:021} formatting is safe for i64, for the record.
  • m7. GSI/LSI data collections never get MongoDB indexes (every index query is a collection scan; the "simple" collation in query_impl:849-857 couldn't use one anyway) and are leaked on DeleteTable / UpdateTable-delete (table_engine.rs:443-455, 655-670).
  • m8. Stream label uses nanosecond ISO8601 with Z (table_engine.rs:151-162) vs PostgreSQL's second precision and DynamoDB's millisecond-no-zone format, may break clients that parse the label out of the ARN.
  • m9. No-op UpdateItem still emits a MODIFY record, but the PostgreSQL baseline does the same, so this is a pre-existing project-level divergence from DynamoDB, not a Mongo regression.
  • m10. TransactGetItems is correct (snapshot session, op order, None for missing); it's more permissive than DynamoDB only in that snapshot reads never conflict, benign.
  • m11. Dead code: non-session write_stream_inline (data_engine.rs:231-322) and the non-transactional StreamEngine::write_stream_record (stream_engine.rs:59-101) are unused within the crate, masked by #![allow(unused)] (lib.rs:10), should be deleted to prevent future misuse; the allow(unused) itself hides real issues (M6).
  • m12. String begins_with upper bound uses prefix + char::MAX with $lt (data_engine.rs:2064-2069), wrongly excluding stored keys of exactly prefix + '\u{10FFFF}'. Pathological but real.
  • m13. <> on a missing attribute evaluates TRUE in the core evaluator (evaluator.rs:41), and Mongo $ne matches missing fields the same way, backend and PostgreSQL baseline are mutually consistent, but real DynamoDB evaluates all comparators (including <>) to false on a missing attribute (hence the canonical attribute_not_exists(a) OR a <> :v pattern). Project-level divergence, not a Mongo regression; deserves an integration test against real DynamoDB.

Verified Correct

  • Plugin registration: four inventory::submit! blocks; no changes to engine/server/auth/core crates; cmd_serve backend gate generalized cleanly.
  • ConsistentRead=true on GSI queries is rejected with the correct ValidationException at the engine layer (engine/src/query.rs:64-73), despite the RFC's misleading "GSI reads are strongly consistent" claim, there is no runtime divergence here. LSI consistent reads pass through, matching DynamoDB.
  • Sparse-index omission for items missing GSI key attributes.
  • Index projections (ALL / KEYS_ONLY / INCLUDE) at write time, always including base table keys.
  • LSIs structurally cannot be added post-creation (matches DynamoDB).
  • Stream image population per view type (INSERT no OldImage, REMOVE no NewImage, MODIFY both; gated by KEYS_ONLY/NEW_IMAGE/OLD_IMAGE/NEW_AND_OLD_IMAGES), modulo C8's phantom old image.
  • Per-key shard affinity: CRC32 of the partition key only, so same pk / different sk lands on the same shard.
  • TTL worker: deletes route through delete_item with a condition re-check and a StreamCapture carrying UserIdentity {Service, dynamodb.amazonaws.com} -> REMOVE records written in-transaction. Matches PostgreSQL and DynamoDB.
  • put/delete/update (non-fast-path) write the stream record and GSI updates on the same ClientSession transaction as the data write, with snapshot read concern + majority write concern.
  • Number normalization: validate_and_normalize_number in core canonicalizes N values before storage, so "1" vs "1.0" key-identity is handled project-wide.
  • Shard iterator semantics (TRIM_HORIZON / LATEST / AT / AFTER, 15-min expiry) live in the shared engine layer, identical for both backends.
  • FilterExpression / Limit ordering: the storage trait's query/scan take no filter, the engine applies FilterExpression post-read (engine/query.rs:430, engine/scan.rs:381) with Limit applied to items read and LEK from the last read item, matching DynamoDB (except where M7/M9/M13/M14 corrupt the LEK).
  • String sort-key collation: the (pk, sk_s) index and Query both use locale "simple" (table_engine.rs:212-215, data_engine.rs:849-857), MongoDB simple collation is code-point comparison, which for UTF-8 matches DynamoDB's byte ordering.
  • Cross-type comparisons in live condition evaluation (e.g. filter on a.N vs stored a.S) correctly don't match, since the typed path is absent.

@diegotoledano95

Copy link
Copy Markdown
Author

The fixes for the gaps detailed in the comment above have been done and are ready for review. You can find them in the Related forked implementation code link in the PR description.

The RFC presented in this PR has also been changed to reflect those changes.

The Related forked implementation code link has also been updated in the Github issue related to this PR.

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Thanks for the substantial revision.

All 11 Critical and 15 Major findings from the first pass are addressed. The core data-plane design is sound: transactional GSI + stream propagation, the netstring composite _id, in-transaction stream sequencing, and WriteConflict retry all hold up.

Conformance (verified live)

Suite Result
Python pytest 844 / 844
Python comprehensive 330 / 330
Rust integration 382 / 384
Total 1,556 passing, 2 failing

Blocking

1. Table CREATING state is not implemented. table_engine.rs:212 writes table_status: "ACTIVE" unconditionally, and control_plane_delay_seconds appears nowhere in the crate (Postgres uses it in four files). This fails two existing conformance tests that pass on Postgres: put_item_on_creating_table_returns_not_found
(PutItem wrongly succeeds) and restore_table_from_backup. GSI-level CREATING is implemented correctly; only table status is missing.

2. Binary begins_with returns wrong results. data_engine.rs:3283-3286 derives the range upper bound by incrementing the prefix bytes then hex-encoding, which is not the next prefix in fixed-width hex space. Reproduced at the wire level:

  • begins_with([0xFF]) → returns nothing (all matches dropped)
  • begins_with([0x2F, 0xFF]) → also returns the unrelated key [0x30] (false positive)

The string path already handles this correctly via next_string_prefix; applying the same logic in hex space fixes it.

3. Field-vs-field conditions evaluate backwards. pushdown.rs:179 admits Field <op> Field for all types, and condition.rs:181 then compares untyped tagged subdocuments, so Numbers compare lexically. Reproduced both directions:

  • "counter_a < counter_b" with a=42, b=9 → write allowed (should reject)
  • same expression with a=9, b=42 → write rejected (should allow)

Fix: mark Field vs Field non-pushable and fall back to the in-Rust evaluator, consistent with the existing N/B literal exclusions.

4. Rebase required The branch is based on 3ad1dcc; main is now ecc69e3. Our commit db0baba added account_id to Storage::get_stream_records after you forked, so the crate will not compile after rebase. You'll also need the account-ownership guard the trait now expects, reference implementation at storage postgres/src/stream_engine.rs:133+. Apologies for the moving target.

5. Steering violations in devtools/run-mongodb-tests. :214 starts the server with the & background operator; :109 uses kill instead of extenddb stop.

Non-blocking (worth tracking at merge)

  • 35–38-digit numeric sort keys are rejected (Decimal128 caps at 34); Postgres accepts them, same key, different answer per backend.
  • Multi-node GSI cache staleness can drop index entries during backfill (bounded ~60s; benign single-node).
  • Per-index MongoDB collections are never dropped on DeleteTable/index-delete, orphans accumulate.
  • BETWEEN bound validation compares via f64, so an inverted range with tiny low-order differences escapes validation.
  • Stream writes read the catalog DB inside the data DB session, valid only when both share one deployment; please assert and document.
  • The shared StorageError enum gained a TransactionConflict variant, additive and reasonable, but it's a shared type, so flag it for an owner decision.
  • Doc inconsistencies: min version 7.0 (RFC) vs 6.0+ (design), neither enforced; the design describes a single shared client, the code builds six.
  • differences-from-dynamodb.md isn't updated, notably "GSI reads are strongly consistent" (stronger than DynamoDB) and the replica-set requirement.

@diegotoledano95

Copy link
Copy Markdown
Author

@LeeroyHannigan Acknowledging the feedback, will start working on the blocking issues. Quick question, do you want to continue reviewing the code as we have on the forked branch?

Or do you want to start adding the code here in the current PR or a new PR?

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Thanks @diegotoledano95

Would be great to get it here, along with your intended CI. #218 does change how backends register, so if you want to wait until we merge that in, make those changes on your fork and then push here, might be cleanest.

@diegotoledano95

Copy link
Copy Markdown
Author

@LeeroyHannigan Will do thanks! Would you have an ETA on #218 ?

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

@diegotoledano95 #218 has just landed. That should unblock you.

@diegotoledano95

Copy link
Copy Markdown
Author

@LeeroyHannigan Have pushed the changes for the blocking issues above, and put the code in this branch and PR as requested too. Please let me know what you think, thanks!

Architecture design for the extenddb-storage-mongodb crate covering:
- Collection schema (catalog_db + data_db)
- Document structure (_id, pk, sk_*, item_data)
- Concurrency model (transactions + optimistic versioning)
- GSI synchronous propagation strategy
- Stream record storage
- Bootstrapper and configuration
Implements the full TableEngine, DataEngine, MetadataEngine, StreamEngine,
BackupEngine, WorkerStore, and catalog traits against MongoDB 6.0+.

Key design decisions:
- Single-item writes (put/delete/update) use MongoDB transactions with
  snapshot read concern and majority write concern for atomicity
- Stream records and GSI sync are in the same transaction as the data write
- UpdateItem uses optimistic concurrency (_v version field) with session
  reuse across retries for performance under contention
- Condition expressions compiled to MongoDB query filters via condition.rs
- Numbers stored as strings in item_data to preserve DynamoDB 38-digit
  decimal precision
- Binary sort key begins_with uses post-fetch filtering (BSON Binary
  comparison sorts by length first, making $gte/$lt unreliable for
  prefix matching)
- Simple unconditional SET/REMOVE updates use native MongoDB operators
  via findOneAndUpdate for lower latency

Wiring: adds mongodb feature flag to bin crate, registers backend via
inventory, and generalizes cmd_serve backend validation.

Requires: MongoDB 6.0+ configured as a replica set (even single-node)
for multi-document transactions and snapshot reads.
- extenddb-mongo.toml: integration test config for MongoDB backend
  using ~/.extenddb/tls paths (portable) and enforce_reserved_keywords=true
- extenddb.sample.toml: add [storage.mongodb] section
- devtools/run-tests: export EXTENDDB_CONFIG; only set
  EXTENDDB_TEST_PG_CONNECTION_STRING for postgres URLs
- docs/local-mongodb-setup.md: MongoDB installation and replica set setup
- docs/getting-started.md: add MongoDB build/init instructions
- AGENTS.md: update architecture, prerequisites, pitfalls for MongoDB
stream_engine.rs and data_engine.rs wrote the shadow event_name column
via format!("{:?}", record.event_name), producing "Insert" / "Modify" /
"Remove". DynamoDB Streams' wire contract is uppercase: "INSERT" /
"MODIFY" / "REMOVE".

Add event_name_ddb_str() in stream_engine.rs to map StreamEventName to
its wire-format string, and use it at both call sites.

Unit test asserts each enum variant maps to the expected uppercase
string.
DynamoDB rejects a KeyConditionExpression sk BETWEEN :lo AND :hi with
:lo > :hi as ValidationException. The engine layer's condition
evaluator does this check for filter/condition expressions, but the
KeyConditionExpression path in Query goes through the storage
backend's sort-key filter builder, which was emitting $gte lo, $lte hi
with no check — matching zero documents without an error.

Add the check in build_sk_filter. Comparison is done in the
AttributeValue domain before Decimal128/f64 conversion. Numeric
comparison uses f64 for ordering only; values that would lose
Decimal128 precision are rejected downstream in sk_to_bson.

Unit tests cover S, N, and B ordering.
…lures

Previously all four TransactWriteItems condition-failure sites in
data_engine.rs (Put, Delete, Update, ConditionCheck) called
condition_check_failed_with_item(None), discarding the pre-existing
item that had already been loaded into scope. Callers that set
ReturnValuesOnConditionCheckFailure=ALL_OLD on their transact op
therefore got a CancellationReason with no Item field — inconsistent
with DynamoDB, which returns the failing item under that flag.

Thread return_values_on_ccf from TransactWriteOp through the mongo
crate's OwnedTransactWriteOp, and add a small helper ccf_return_item
that gates inclusion on both (a) ALL_OLD requested and (b) the item
actually existed. Preserves DDB's guarantee that missing items never
manifest as CancellationReason.Item.

Adds unit tests for the helper covering all three code paths.
The mongo backend stores numeric partition/sort keys as BSON Decimal128
for correct numeric ordering. Decimal128 supports 34 significant
decimal digits; DynamoDB supports up to 38.

Previously the write path (data/mod.rs::item_to_document), key-filter
path (data/mod.rs::pk_filter), and sort-key comparison path
(data_engine.rs::sk_to_bson) all fell back to f64 on Decimal128 parse
failure. f64 has ~15 digits of precision, so values in the 35-38 digit
range were silently truncated, breaking numeric ordering guarantees on
sort keys (e.g. Query with ScanIndexForward could return items in an
order that disagrees with the callers numeric interpretation).

Reject values that exceed Decimal128 precision at all three sites with
a ValidationException explaining the limit. Document as a
MongoDB-backend-specific behavioral difference in
docs/differences-from-dynamodb.md.

Numbers in non-key attribute positions are unaffected: item_data
stores the DynamoDB number string verbatim inside the {"N": ...} tag
and is never numerically compared by the backend.

Adds unit tests for the write path and pk_filter path at the
34-digit boundary and beyond.
Long function bodies and lint-boundary formatting picked up by
cargo fmt after the preceding four fix commits. No behavior change.
…ne.rs

Nested `if let Some(ref sk_cond) = key_condition.sk_condition` around
`if let SortKeyCondition::BeginsWith { .. } = sk_cond` collapsed into
a single pattern. Behavior identical.
Upstream pinned a stricter Rust toolchain in 6c59a25, whose clippy is
stricter about the collapsible_if and collapsible_match lints. 16 sites
in the original mongo backend contribution now trip these lints:

- authorization_store.rs (1 site)
- data_engine.rs (11 sites)
- metadata_engine.rs (4 sites)

Mechanical fix — every site is 'if outer { if inner { ... } }' collapsed
to 'if outer && inner { ... }', or the equivalent 'if let' pattern.
Applied via 'cargo clippy --fix -p extenddb-storage-mongodb', followed
by 'cargo fmt --all' to re-align the resulting blocks.

Behavior unchanged. 24 unit tests still pass.
stream_engine.rs::next_sequence_number used a single global counter
document (_id: "stream_seq") for every shard of every table. The
shard_id argument was accepted but ignored. latest_sequence_number
filters by shard_id when reading, so writer and reader disagreed on
the sequence-number space: a write to shard B advanced the counter
shard A reads back, producing non-contiguous sequence numbers on
shard A's GetRecords pages.

DynamoDB Streams' contract is that sequence numbers are strictly
monotonic within a shard and independent across shards.

Key the counter document by shard_id (stream_seq:<shard_id>) so each
shard gets its own atomic counter. The 21-digit zero-padded encoding
of the sequence number is preserved.
MongoEngine::gsi_cache is a per-process DashMap<table_id, bool>. In a
multi-instance deployment sharing a MongoDB catalog, an admin adding
a GSI via instance A does not invalidate instance B's cache. Instance
B continues to see the cached false and silently skips index updates
on subsequent writes — the base table and the newly-added GSI drift
out of sync until the cache is reset by a restart.

Change the cache value from bool to (bool, Instant) and reject
entries older than GSI_CACHE_TTL (60 seconds) on read. When a stale
entry is observed, the caller falls through to the catalog query,
which returns the authoritative state and refreshes the cache.

Introduce three MongoEngine helpers (gsi_cache_get_fresh,
gsi_cache_set, gsi_cache_invalidate) so callers don't reach into the
DashMap directly. Update the five existing call sites (data_engine.rs
sync_indexes and sync_indexes_in_session; table_engine.rs DropTable,
CreateGSI, DeleteGSI) to use them.
Only the base-table collection got mongo indexes at CreateTable
time. GSI/LSI collections were left to be lazy-created on first
index-row insert, and — critically — never got any indexes on
their query columns. Every Query / Scan against a secondary index
therefore ran a full-collection scan; cost was linear in the
index's row count.

Add `create_index_data_collection` on MongoEngine and invoke it
from all three sites that create a GSI/LSI catalog row:
- CreateTable initial GSI list
- CreateTable initial LSI list
- UpdateTable GSI-create path (async D-C5 backfill flow)

The compound index is `(pk, sk?, base_pk, base_sk?)` — the same
tuple that `sync_indexes` and `scan_impl` sort by after D-C1's
schema change. String sort keys get the `simple` collation so
range comparisons stay byte-wise, matching the base table.
The mongo backend generated `stream_label` values via the `time`
crate's default Iso8601 formatter — nanosecond precision, `Z`
suffix, `.123456789Z` tail. Postgres emits second precision
without a timezone (`YYYY-MM-DDThh:mm:ss`) via a to_char cast.
Clients parsing labels round-tripped through one backend but not
the other, and comparison-based lookups (list-streams pagination,
by-label ARN parsing) could see two backend runs produce
incompatible ARNs for the same table.

Add `format_stream_label` and route all three writer sites — the
initial CreateTable label, the UpdateTable re-enable path, and the
label-restore branch — through it. Byte-for-byte matches postgres
output.
The `StreamEngine::write_stream_record` trait method predates D-C2 —
back when stream records were written outside the data-write
transaction. Every real caller now routes through
`MongoEngine::write_stream_inline_in_session`, which enrolls the
stream write in the same session as the base-table mutation so a
rolled-back data write can't leave a phantom stream record behind
(RFC-0003 §3.2, §5.1).

The non-transactional impl remained in the file — 40+ lines of live
code that would silently break stream/data atomicity if called
externally. Replace the body with an immediate error explaining the
supersession. Trait method stays (it's declared upstream in
`extenddb-storage`), the body no longer does subtly-wrong work.
`build_sk_filter` computed the exclusive upper bound for
`BEGINS_WITH P` as `P + char::MAX` and matched with `$lt`. That
misses any stored value equal to `P + char::MAX` — which still
begins with `P` — and any value that extends past it (a string
whose first char::MAX is followed by more content). Rare in
practice but a real correctness gap.

Replace `increment_string` with `next_string_prefix`: walk the
prefix from the right, find the first char that isn't `char::MAX`,
increment it (skipping the surrogate gap via `char::from_u32`), and
truncate everything to its right. That yields the least string
strictly greater than every P-starting string.

Edge case: a prefix consisting entirely of `char::MAX` (or an empty
prefix — but the engine layer already rejects that) has no upper
bound; drop the `$lt` clause so mongo matches every value ≥ P.
Matches DDB's behavior.
The condition compiler in `condition.rs` has latent correctness bugs
on numeric operands, set/list/map equality, mixed-type IN lists, and
attribute_type with an unvalidated type tag. Option (c) from the
D-M6 decision: keep the compiler, expand analyzer tests to lock the
buggy shapes out of the pushdown path.

- Tighten `attribute_type` in the analyzer: require the placeholder
  to resolve to a string that is exactly one of the ten DDB type
  tags (S, N, B, BOOL, NULL, L, M, SS, NS, BS). The compiler
  interpolates the tag verbatim into the mongo field path, so
  accepting an arbitrary string like `$ne` would produce a filter
  clause where "field" starts with a mongo operator.
- Add four analyzer regression tests that lock in exclusion of
  every input shape D-M6 flagged as compile-time-buggy: numeric
  compares, set/list/map equality, IN, and attribute_type with an
  invalid tag. Plus a positive test that `attribute_type` with a
  valid tag still pushes.

Compiler bodies stay unchanged — the analyzer is now the load-
bearing correctness boundary, and its expansion is checked in with
tests.
Rewrites three sections of docs/rfcs/0000-mongodb-backend.md where the
RFC described an initially-planned design that differs from what the
implementation does, plus small errata.

Section rewrites:

- Condition expression evaluation (was "Condition expression
  pushdown"). RFC previously described a single-round-trip design in
  which compiled MongoDB filters were pushed into findOneAndReplace /
  findOneAndDelete. Implementation evaluates conditions in-Rust
  against a loaded item inside a MongoDB client session that also
  wraps the write. The condition compiler exists as scaffolding for
  a future filter-pushdown optimization but is not on the correctness
  path. Section rewritten to describe the session-scoped approach,
  with the pushdown path documented as an alternative considered.

- Backup. RFC previously described using MongoDB's server-side $out
  aggregation stage to per-backup collections named
  _backup_{backup_id}_{table_id}. Implementation writes items into a
  shared backup_items collection keyed by backup_arn, with backup
  metadata in extenddb_catalog.backups. Section rewritten; $out
  documented as a future optimization.

- Performance characteristics. The "single-item writes:
  transaction-free" claim was too broad. Rewritten to reflect that
  the session wrap is what provides DynamoDB's atomicity contract,
  with a sessionless fast path planned as a follow-up optimization
  for the narrow case of tables with no streams and no GSIs.

Errata:

- Plugin registration: four -> five inventory::submit! calls
  (diagnostics store registration was omitted).
- Database layout: 17 -> 18 catalog collections; enumerated the
  additional collections; noted iam_group_members and backup_items
  auto-create on first insert rather than in migrations.
- Write conflict handling: OCC retry base 100us -> 50us (matches
  code at data_engine.rs:706).
- Removed size from the list of supported compiled functions.
- Removed the "all-backends" convenience flag from the roadmap.
- Design decisions summary and implementation summary tables updated
  to reflect the rewrites (session-scoped conditional writes; shared
  backup_items collection; cmd_serve.rs added as a modified file).
- Numeric sort keys: added a note that values exceeding Decimal128's
  34 significant digits are rejected at write/query time (see
  docs/differences-from-dynamodb.md).
- Collation: dropped the strength: 3 specifier; the code uses
  MongoDB's default (Tertiary) which matches DynamoDB behavior.

No behavioral change to the proposed design.
Rewrites both documents from scratch to describe the MongoDB
backend as it exists today, dropping earlier-planned architecture
that never made it into the shipping code (single-round-trip filter
pushdown as the primary path, sk_b as BSON Binary, index docs
without base-key disambiguation, synchronous inline GSI create,
etc.).

RFC-206 (`docs/rfcs/0000-mongodb-backend.md`):
- Document structure section covers netstring `_id`, typed sort-key
  fields including hex-encoded binary, `_v` OCC counter, and the
  index-doc base_pk/base_sk_? disambiguation with 4-tuple _id.
- Condition-evaluation section describes the session-scoped
  read + evaluate + write flow as the correctness path, with the
  analyzer-gated pushdown as an opt-in fast path.
- Query/Scan covers hex-based binary begins_with, sort-key range
  preservation on pagination, and the compound index cursor.
- GSI/LSI section covers the CREATING → ACTIVE state machine and
  the async gsi_backfill_worker.
- Transactions section covers WriteConflict retry with
  TransactionConflict-on-exhaustion.
- Streams section covers per-shard session-scoped sequence
  counters, shard_id embedding table_id, 24 h TTL retention,
  and idempotent stream-enable.
- Operational requirements adds primary-only readPreference
  enforcement.

Design doc (`docs/design/13-storage-mongodb.md`):
- Catalog schemas (§3) now show TableClass / SSE / OnDemandThroughput
  persistence, indexes with `backfill_cursor` + `CREATING`.
- Data schemas (§4) document netstring `_id`, `_v`, index docs
  with base-key fields and per-index compound query index,
  stream_records (TTL + compound query index), stream_shards
  (unique on shard_id derived from table_id), counters keyed
  per-shard, idempotency_tokens (540 s TTL + unique compound on
  account_id+token), and `_backup_{backup_id}` collections.
- §5 rewritten around: session-scoped conditional writes,
  analyzer-gated pushdown, base-key disambiguation with compound
  cursor pagination, async GSI backfill, session-scoped per-shard
  sequence counters, WriteConflict retry, hex sort keys, and the
  next_string_prefix upper-bound.
- §12 replaced with a feature-coverage inventory grouped by trait
  and background worker.
- §14 adds primary readPreference enforcement.
RFC-0003 §6.2 forbids blanket `#[allow(unused)]` suppressions
because they conceal unimplemented trait methods and let latent
bugs ship dormant. The crate had `#![allow(unused)]` at the top of
`lib.rs`; removing it surfaced 19 real dead-code items.

Cleanup:
- Delete `MongoEngine::sync_indexes` (non-session variant). Every
  write path routes through `sync_indexes_in_session` since D-C2;
  the standalone variant was unused and independently missed the
  D-C2 through D-C6 fixes (it had the old wrong index-key filter,
  the `let _ = delete_one` swallow, and no compound-cursor support).
  Removing it eliminates a shadow implementation that could have
  been called by mistake.
- Delete `data::index_collection_name` — dupe of `data_collection_name`.
- Delete `MongoCatalogStore::client` accessor — nothing outside the
  struct used it.
- Delete `MongoEngine.max_connections` field — set at construction
  and never read (mongo driver's `max_pool_size` already carries
  the value into the client options).
- Delete an unused `use futures::TryStreamExt` inside
  `sync_indexes_in_session`; the method uses `cursor.next(session)`
  which comes from a different trait.
- Fix the unused-var warning on `item_count` in `backup_engine.rs`.
- Strip 14 unused imports via `cargo fix`.
The previous `WorkerStore::process_control_plane_transitions` body
was dead code with two independent problems:

1. Its query filters (`{ "account_id": X, "table_name": Y, ... }`)
   read those fields at the top level of the tables catalog
   document, but the mongo catalog stores account_id/table_name
   inside `_id.account_id` / `_id.table_name`. The filter would
   never match a real row.
2. `MongoRuntimeHooks::spawn_workers` never spawns anything that
   calls this method, and the mongo backend never puts a table
   into a transient CREATING/DELETING state to begin with —
   `create_table_impl` writes `TableStatus: ACTIVE` synchronously,
   `delete_table_impl` runs collection/tag/stream cleanup inline.

RFC-0003 §6.3 forbids dead-code impls that pretend to be operational.
RFC-0003 §10.1 requires that any trait method needing periodic
maintenance be spawned via `ServerRuntimeHooks::spawn_workers`.
Either fix the impl and wire it up, or state clearly that the
backend has no work to do. The latter is honest here — GSI create
is the one control-plane operation that does need async work, and
that lives in `ttl_worker::gsi_backfill_worker` on the `indexes`
catalog, not the `tables` catalog.

Replace the body with `Ok(Vec::new())`. Update RFC-206 and the
design doc to describe control-plane transitions as inline, and
call out the no-op nature of the WorkerStore trait method.
`sync_indexes_in_session` used `let _ = idx_coll.delete_one(...).await;`
when removing an index entry whose base item's GSI-key attribute
just changed or was removed. A transient error on that delete was
silently discarded, leaving the stale index row live under the old
GSI-key value while the new value was upserted. Subsequent Query
requests kept returning the stale projection forever.

Propagate the error. `sync_indexes_in_session` is called from the
data-plane write path inside the same session as the base write,
so a real error here rolls back the whole transaction — including
the base-table replace/insert — and the client sees a retryable
error rather than silent index divergence.

RFC-0003 §2.2 (stale entry prevention) + §9.1 (no silent
degradation).
Two concurrent `UpdateTable(stream_enabled=true)` calls can both
observe "no shards yet" for the same table_id (snapshot isolation
lets each transaction see a state that predates the other's insert)
and both proceed to `init_stream_shards`. Because
`stream_shards.shard_id` carries a unique index — see
`bootstrapper.rs` — one of the inserts hits E11000 and surfaces as
`StorageError::Internal` → HTTP 500.

RFC-0003 §10.3 requires repeated `UpdateTable` calls with the same
specification to not corrupt state and either reject cleanly or
no-op. Treat E11000 as a no-op here: the shard already exists with
the same `(shard_id, table_id)` binding by construction (the
shard_id is deterministic from the table_id), so the client's
retry sees the expected state without a wire-visible error.

Non-duplicate errors still propagate as `Internal`.
…ites

RFC-0003 §4.1 requires that concurrent unconditional single-item
writes never surface a client-visible conflict — "two concurrent
`PutItem` on the same key must both succeed (last-writer-wins)."
The backend previously wrapped every PutItem/DeleteItem/UpdateItem
in a snapshot MongoDB transaction with a 50-attempt WriteConflict
retry loop. Under sustained contention the loop exhausts and
surfaces `StorageError::Internal("too many concurrent write
conflicts")` → HTTP 500, which DDB never emits.

The txn wrapper is only necessary when a write has dependent side
effects — conditional evaluation, stream capture, GSI sync — that
must be atomic with the base write. When none of those apply, the
storage engine's single-doc atomicity is sufficient on its own,
and the txn wrapper is what *creates* the conflict it then has to
retry through.

Split each write into a sessionless fast path:

- **PutItem** — when `condition.is_none() && stream.is_none() &&
  gsi_cache_get_fresh == Some(false)`, run a plain
  `find_one_and_replace(upsert=true, ReturnDocument::Before)`. No
  session, no retry. Concurrent writers converge naturally at the
  WiredTiger level; no error is ever emitted for contention alone.

- **DeleteItem** — same gate, plain `find_one_and_delete`.

- **UpdateItem** — extend the existing native fast path to handle
  numeric ADD via an aggregation-pipeline update. `$toDecimal`
  parses the string-stored `.N` value server-side, `$add` applies
  the delta as Decimal128, `$toString` writes it back. 50
  concurrent `ADD counter :one` calls now serialize inside
  MongoDB with a `_v` bump per apply — no OCC retry, no client-
  visible conflict. RFC-0003 §4.4.

Introduces `NativeUpdate::{Doc, Pipeline}` so the update fast path
can dispatch to either the operator-document form (simple
$set/$unset) or the pipeline form (numeric ADD, or a mix).

Session-scoped paths remain for: conditional writes (need read +
check + write atomicity), streams-enabled tables (need base +
stream-record atomicity), GSI-bearing tables (need base + index
atomicity), and updates the pipeline can't express (list_append,
if_not_exists, arithmetic on strings, DELETE from set). Those
paths still retry on WriteConflict, but they only cover cases
that RFC-0003 §4.1's exemption doesn't apply to.

The `stream_shards` unique-index defense from the earlier
`init_stream_shards` idempotency fix means that even the racy
session-scoped path is safer than before.
Adds a pytest module that asserts strict conformance against the
four RFC-0003 §4.x scenarios. Client uses `retries={"max_attempts": 0}`
via the shared conftest fixture so any InternalServerError from the
backend surfaces immediately instead of being masked by the SDK.

Cases covered:
- §4.1  50 concurrent unconditional PutItem on the same key —
        all must succeed, no client-visible errors.
- §4.1  50 concurrent conditional PutItem with attribute_not_exists —
        exactly one wins, the rest fail with
        ConditionalCheckFailedException. No InternalServerError.
- §4.4  50 threads × 20 iterations of `UpdateItem ADD counter :one`
        on the same key — every increment applies, final counter
        equals 1000, no lost updates, no client-visible errors.
- §4.1  50 concurrent unconditional DeleteItem on the same key —
        all succeed, item ends up absent.

These are the specific scenarios the sessionless fast path fix
targets. Prior to the fix, `TestRfc0003UnconditionalPutOnHotKey`
and `TestRfc0003AtomicCounterAdd` would surface InternalServerError
after the 50-attempt WriteConflict retry ceiling exhausted.
…lict

RFC-0003 §4.3: when a single-item write conflicts with an in-flight
`TransactWriteItems` on the same item, the backend must return
`TransactionConflictException` — never `InternalServerError`. The
backend may retry transient conflicts internally, but must not
exhaust retries and surface an unmapped error.

The mongo backend's put/delete/update paths exhaust their 50-retry
WriteConflict loop and returned `StorageError::Internal(...)` →
HTTP 500. That's the exact case §4.3 forbids.

Two-part fix:

1. Add `StorageError::TransactionConflict(String)` to the shared
   storage trait surface and map it in
   `engine/src/create_table.rs::storage_err_to_dynamo` to
   `DynamoDbError::TransactionConflictException`. The variant is
   general — any backend can emit it when a contention path
   exhausts internal retry.

2. Mongo `put_item_impl` / `delete_item_impl` / `update_item_impl`
   now return `StorageError::TransactionConflict` on ceiling
   exhaustion instead of `StorageError::Internal`. With the Phase 6
   sessionless fast paths, ceiling-exhaustion is only reachable on
   the session-scoped path — i.e. writes on GSI-bearing or
   streams-enabled tables — where §4.3's applicability is exact.
Derive Zeroize and ZeroizeOnDrop so the base64-encoded AES-256-GCM
master encryption key is scrubbed from memory when the credential store
is dropped. Matches the DbCredentialStore in the postgres backend.
Inspect the parsed ClientOptions in MongoEngine::new and emit a WARN
log when the URI does not enable TLS. Without this warning, an operator
who configures a bare mongodb://host:27017 URI has no indication that
credentials and data are traversing the network in cleartext.
…tring

MongoBootstrapper::record_data_connection previously wrote the raw
connection string into the settings collection under
data_connection_string. When the URI carries a userinfo password
(mongodb://user:pass@host/...), the plaintext password sat at rest in
the catalog and was readable by anyone with read access to the
extenddb_catalog database.

Add redact_connection_string, which replaces the password component of
the userinfo with `<redacted>`, and apply it before the upsert. The
scheme, username, host, port, and query string are preserved so the
stored value remains a useful reference.

Unit tests cover the standard mongodb:// scheme, mongodb+srv://, bare
URIs with no userinfo, username-only URIs, `@` characters that appear
only in the query string (authSource=admin), and inputs without a URI
scheme.
MongoCredentialStore::lookup_user_credential read the is_active field
with unwrap_or(true), so a record whose is_active field was missing,
absent, or of the wrong BSON type was treated as active. Combined with
a partial write during key rotation or a schema mismatch after a
migration, this could authenticate a credential that should have been
inactive.

Change the default to false: if the flag cannot be read as a bool, the
credential is rejected. Correct records with is_active: true continue
to authenticate normally.
The runner was implicitly postgres-only in two places: it greps the
config for `backend = "postgres"` before extracting a pg connection
string, and it runs `test_cli_lifecycle.py` (postgres-only) whenever
that connection string is set. Both worked accidentally on a mongo
config today — the postgres-backend grep just missed and everything
downstream was a no-op — but the coupling to config-file contents is
fragile.

Add an explicit `--backend {postgres,mongodb}` flag (default
`postgres` for backward compat). The flag gates the two postgres-only
paths and prints the backend in the target-info block. Everything
else — health check, credential provisioning, throttling +
import/export config mutation, pytest / rust / external / catalog-
check suites — stays backend-agnostic and needs no change.

Unblocks a mongo CI workflow that can delegate to `run-tests` the
same way `.github/workflows/integration.yml` does for postgres.
Nothing in the mongo backend has been verified against 6.x; local
development, the bench-compare harness, and the container tag used in
the planned CI workflow all use `mongo:7`. Bring the docs in line —
stating 6.0+ implies a support surface we don't test and can't stand
behind.

Documentation-only change. No code touches the mongo-driver version
floor; that's controlled by the `mongodb` crate's own minimum.
`devtools/run-tests` is a runner, not an orchestrator — it assumes the
server is already up at `$EXTENDDB_TEST_ENDPOINT`. The postgres CI
workflow supplies the server lifecycle (init, serve, poll /health)
inline before delegating to `run-tests`. Local mongo runs had no
equivalent — the bench-compare harness recreated the lifecycle each
time by hand.

`devtools/run-mongodb-tests` fills that gap: one entry point that
spins up a `mongo:7` single-node replica set in Docker, initializes
and serves extenddb against it, then delegates to
`devtools/run-tests --backend mongodb`. Teardown on exit; `--keep`
leaves everything up for post-run inspection.

Arguments after `--` are forwarded to `run-tests` verbatim so callers
can pick the suite (`--pytest`, `--comprehensive`, `--parallel`,
`--filter …`). Default is `--pytest --comprehensive --parallel`.

The mongo CI workflow (a follow-up commit on this branch) can call
this script directly and drop most of its shell-level orchestration.
  Upstream db0baba added `account_id` to `Storage::get_stream_records` so
  GetRecords is scoped to the shards owning account; the mongo backend
  still implemented the old 4-arg signature and returned records without an
  ownership check, so a caller could read another accounts stream records
  by presenting a forged shard iterator.

  Add the `account_id` parameter and an ownership guard that mirrors
  storage-postgres: resolve shard_id -> table_id from `stream_shards`
  (data db), then confirm a `tables` catalog document with that table_id is
  owned by the calling account (account_id lives inside the compound `_id`,
  so the comparison is done in Rust after a single table_id lookup). When
  the shard is unowned or absent, return
  ValidationException("Invalid ShardIterator") — matching DynamoDB, which
  does not distinguish "exists but not yours" from "does not exist".

  Verified by tests/test_cross_account_isolation.py::TestStreamAccountScoping
  ::test_shard_iterator_only_returns_owning_account_records against the
  mongo backend.

  Also syncs Cargo.lock (extenddb-storage-mongodb 0.1.0 -> 0.1.2) to the
  workspace version bump pulled in by the rebase.
…eering

 Start the server via extenddb serve (which daemonizes itself) instead of
 serve --foreground with a background &, and stop it via extenddb stop
 instead of kill. Set server.run_dir to the test output dir so serve and
 stop share an isolated PID-file location. Removes the manually-managed
 server.pid file.
 pushdown.rs admitted Field <op> Field for all types, but a plain field
 type is unknown at compile time, so the emitted $expr compared the raw
 tagged subdocuments. Two Number fields (stored string-encoded) then
 compared lexically, so counter_a < counter_b evaluated backwards in both
 directions. Mark Field vs Field NotPushable so it falls back to the
 in-Rust evaluator, consistent with the existing N and B literal
 exclusions. Adds a regression test locking every comparator.
 Binary sort keys are stored as lowercase hex strings, so begins_with is a
 string-prefix range over the hex encoding. The upper bound was computed as
 hex(increment_bytes(prefix)) -- incrementing the raw bytes then re-encoding
 -- which is not the next prefix in fixed-width hex space and widens the
 range. begins_with(0x2F,0xFF) produced ["2fff","3000") and wrongly matched
 the stored key 0x30 ("30"); begins_with(0xFF) produced an empty range and
 dropped every match.

 Use next_string_prefix on the hex encoding, mirroring the string sort-key
 path: sk_b >= hex(B) AND sk_b < next_string_prefix(hex(B)), dropping the
 upper bound when the prefix is empty. Removes the now-unused increment_bytes
 helper. Adds a regression test for both wire-level repros.
 CreateTable and RestoreTableFromBackup now write the catalog row as
 CREATING with a status_transition_at timestamp when
 control_plane_delay_seconds > 0 (default 0.25), and return CREATING; a new
 background control_plane_worker flips rows to ACTIVE once the scheduled
 transition time passes. When the delay is 0 the row is written ACTIVE
 directly. Matches the postgres backend and real DynamoDB, which report
 CREATING before a table is usable. DeleteTable stays inline (no DELETING
 state).

 Restore delegates row creation to create_table and no longer forces the
 table ACTIVE inline, so it enters the same CREATING window; the data is
 copied via $out before the worker flips the table to ACTIVE.

 Data-plane key-schema resolution against a non-ACTIVE table now returns
 ResourceNotFoundException (TableNotFound) instead of ResourceInUse,
 matching DynamoDB and the postgres backend.

 Restores WorkerStore::process_control_plane_transitions (fixing the
 compound _id query the previous no-op replaced) and spawns the poller from
 MongoRuntimeHooks::spawn_workers. Reverts the RFC and design-doc language
 that described control-plane transitions as inline.

 Fixes the conformance tests put_item_on_creating_table_returns_not_found
 and restore_table_from_backup.
…ndDB#218 main

 Rebase onto upstream main after PR ExtendDB#218 (serve lib decoupling), which
 replaced inventory backend registration with an explicit set_backend/Backend
 model and split the CLI into extenddb-app. Also adapts to backup-trait and
 worker changes and to new backup_arn_scoping conformance tests pulled in by
 the rebase.

 - Replace the six inventory::submit! blocks with a single
  extenddb_storage_mongodb::backend() constructor plus a
  server_components_factory fn, mirroring the postgres backend.
 - Drop the now-removed inventory dependency.
 - Feature-gate the thin bin: install the mongodb backend under
  --features mongodb, else postgres.
 - Scope describe_backup and delete_backup to account_id (added to the
  BackupEngine trait upstream); exclude DELETED backups from describe_backup
  so a deleted backup reads as BackupNotFoundException.
 - Give backup ARNs a timestamp-plus-8-hex-char random id so they are not
  guessable from creation time alone.
 - Return the spawned worker JoinHandles from spawn_workers, whose trait
  signature now requires Vec<JoinHandle<()>>.
 Rebase onto upstream main (6dcb14c), whose per-index consumed-capacity
 work added global_secondary_indexes and local_secondary_indexes to
 TableKeyInfo. Load all secondary indexes from the catalog in
 table_key_info_from_doc and populate both lists (via a new
 index_info_from_doc helper) so per-index consumed capacity is computed
 from the cached TableKeyInfo without an extra describe_table per write,
 matching the postgres backend. has_lsi is now derived from the LSI list.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFC Request for Comments, a proposal open for discussion before implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] MongoDB Storage Backend

2 participants