Repository navigation
fix(mongodb): reach map members under their M field in a pushed-down condition - #582
Conversation
condition.rs was 759 lines. The test module moves unchanged to condition_tests.rs, loaded with #[path] as backup_engine.rs loads its test files, so the next change can add tests without the file passing 600 lines.
…condition On a table with no index and no stream, while the server's per-process cache holds a fresh entry recording that the table has no index, a pushable UpdateItem or DeleteItem condition is compiled to a MongoDB filter. The compiler turned `#m.#k` into `item_data.m.k`, but each map's members are stored under its `M` field (`item_data.m.M.k`), so a condition on a map member never matched: `attribute_exists(#m.#k)` and `#m.#k = :v` answered ConditionalCheckFailedException when they held, and `attribute_not_exists` and `<>` on a present member let the write through when they failed. A member name after the top-level attribute now gets an `M` segment, as a list index already got an `L` segment. Amazon DynamoDB evaluates these conditions as expected on map members, list elements inside maps and map members inside list elements (measured 2026-10-25 at 20:51 and 20:52 UTC, each request twice).
Dual-target wire tests from measurements on 2026-10-25 at 20:51 and 20:52 UTC: attribute_exists, attribute_not_exists, comparisons, begins_with, contains and attribute_type on map members, list elements inside maps and map members inside list elements, on UpdateItem and DeleteItem, holding and failing. Each test puts its own item right before the conditional write, so on MongoDB the write runs while the cache entry that enables the pushdown is fresh.
A member name after the top-level attribute is a key of the enclosing map, so its compiled path goes through the map's M field even when the name is a type tag (M, L, S) or all digits (0). Without that segment the path names a field of the stored value's typed wrapper: item_data.t.M is the map's own M field, and item_data.t.L, item_data.t.S and item_data.t.0 are absent. The unit helper path() now takes PathElement values, so a member named 0 can be written as a name and not as a list index. The new unit test pins item_data.m.M.M, item_data.m.M.L, item_data.m.M.S and item_data.m.M.0 for attribute_exists on m.M, m.L, m.S and m.0. The wire item gains t, a map with members named M, L, S and 0, and DERIVED_HOLDS has one equality row per member. These rows were not measured on the service; their expected answers are the ones ExtendDB's in-Rust condition evaluator gives.
An element of a list is stored under the list's L field, and an element that is itself a list keeps its own L field, so each index in a path gets an L segment: l[2][1] compiles to item_data.l.L.2.L.1. Without the second segment the path is item_data.l.L.2.1, a field of the inner list's typed wrapper that is absent, and the compiled filter answers as if the element were not there. The unit test map_members_and_list_elements_use_the_typed_fields now pins item_data.l.L.2.L.1 for attribute_exists on l[2][1]. The wire item gains g, a list whose third element is a list, and DERIVED_HOLDS gains #g[2][1] = :g21. The row was not measured on the service; its expected answer is the one ExtendDB's in-Rust condition evaluator gives.
The parity harness compared the compiled filter with evaluate_condition for single-name paths over flat items, so no compiled path through a map's M field or a list's L field was ever checked, and a path builder that left out the M segment for map members passed it. Items now hold maps and lists nested two deep, with members named a, b, M, L, S and 0. M, L and S are also type tags of the stored form and 0 reads as an array index in a MongoDB path, so a misplaced typed segment lands on a different field for these names. A path has up to three elements, each one after the first a member name from that set or an index below 3. Three in four paths of a generated condition are replaced by paths the item has, deeper ones more often, so a nested member or element the condition names is present often enough for a wrong compiled path to give a different answer than evaluate_condition. The case budget stays at 1024 per property. The design doc's sentence on the harness now says what it generates.
… at depth four The five rows on members named M, L, S and 0 and on a list inside a list were encoded from ExtendDB's own evaluator; on 2026-10-28 each applied its update on Amazon DynamoDB, two samples per row, and a wrong value answered ConditionalCheckFailedException and left the item. The wire test says so. A four-element path assertion pins that the typed segment is added at every depth, which no test reached before. The design document gives the measured share of generated paths the item has.
robinnsc
left a comment
There was a problem hiding this comment.
Looks correct to me. On a local MongoDB I ran about 900 conditions (nested maps, a[0].b, a.b[1], deep mixes, literal and aliased names, members named M/L/S/0) as UpdateItem and DeleteItem against a stream-enabled table as the reference. Head matched on all of them; the build before the fix differed on 295. Plans stay IXSCAN {pk:1}, one key and one doc examined.
Two things outside the diff, both already on main, so probably for a separate issue rather than this PR:
begins_with/containswith U+0000 in the operand returns InternalServerError ("Regular expression cannot contain an embedded null byte"), top-level or nested. #368 gates NUL in member names but not in the operand.- A long operand does the same once the escaped pattern goes past about 32 KB (Mongo error 51091), e.g. 40,000
aor 20,000.. Refusing pushdown in the analyzer for either case would send it to the in-Rust evaluator.
|
|
||
| from conftest import scoped_table | ||
|
|
||
| NAMES = { |
There was a problem hiding this comment.
The description mentions member rows for x$, $x and a.b measured on the service, but I couldn't find them in this file. Might be worth adding them, maybe with a decoy member a next to the a.b one. As far as I can tell nothing covers the per-element ./$ check in unsafe_attribute_reason past the first element: changing it to elements.iter().take(1) passes every test. Before this PR a nested path never matched anyway, but now that it reaches real data, #m.#x with #x = "a.S" would compile to item_data.m.M.a.S if that check slipped. (Adding a.b to MEMBER_NAMES in the parity harness probably won't work, since that property compiles without going through is_pushable.)
| a MongoDB filter while the server's cache records that the table has no index. | ||
| That entry is written by a session-path write and lasts 60 seconds; each test | ||
| puts its own item immediately before the conditional write, and that put | ||
| renews the entry when it has expired. |
There was a problem hiding this comment.
Could consider having the MongoDB leg check that the compiled filter actually ran, e.g. a profiler entry with item_data. in its filter. Right now, if the cache TTL or the put's cache renewal changes, every row would still pass through the in-Rust evaluator, and the file would quietly stop covering #572. Not a big deal since it's dual-target, just a thought.
Fixes #572.
On MongoDB, a condition on a map member was compiled to a filter path that skipped the map's
Mfield, so it never matched. A condition that held was refused, andattribute_not_existsor<>on a member that was present let the write through.What happened on main
On a table with no index and no stream, while the server's cache holds a fresh entry recording that the table has no index (60 seconds after a write), an UpdateItem or DeleteItem condition that the analyzer accepts is compiled to a MongoDB filter and checked by MongoDB. Items are stored in their typed form, so a map's members sit under its
Mfield and a list's elements under itsLfield:#m.#kcompiled toitem_data.m.k; the member is stored atitem_data.m.M.k.#m.#l[0]compiled toitem_data.m.l.L.0; the element is stored atitem_data.m.M.l.L.0.#l[0].#kcompiled toitem_data.l.L.0.k; the member is stored atitem_data.l.L.0.M.k.attribute_exists, comparisons,begins_with,containsandattribute_typeon those paths answered ConditionalCheckFailedException when they held.attribute_not_exists(#m.#k)and#m.#k <> :von a present member let the write through. Top-level attributes and list indexes were compiled correctly.The change
A member name after the top-level attribute now gets an
Msegment, as a list index already got anLsegment. The rule reaches the stored value for maps in maps, lists in maps, maps in lists and lists in lists. A member namedM,L,Sor0is looked up inside the parent'sMdocument, so it cannot be read as a type tag or an index.What the service answers
Measured on 2026-10-25 at 20:51, 20:52 and 21:13 UTC in us-east-1, each request sent twice with the same answer: 21 shapes on map members, list elements inside maps and map members inside list elements, on UpdateItem and DeleteItem, holding and failing, plus 52 further shapes covering missing and wrong-type intermediates, out-of-range indexes, members named
M,L,S,0,x$,$xanda.b, empty maps, binary, BOOL and NULL members, sets and lists inside maps, and<and>on nested strings. On the 50 condition rows of the second set that ExtendDB can also run, SQLite matches the service on all 50 and MongoDB at the head on 47; the 3 it misses are the U+0000 rows under "Not changed here". MongoDB on the build before the fix matches 29.Tests
crates/storage-mongodb/src/condition_tests.rs: unit tests for the compiled paths of map members, list elements inside maps, map members inside list elements, an element of a list inside a list (l[2][1]compiles toitem_data.l.L.2.L.1), members namedM,L,Sand0(item_data.m.M.M,item_data.m.M.L,item_data.m.M.Sanditem_data.m.M.0), and a four-element path (m.mm.n.kcompiles toitem_data.m.M.mm.M.n.M.k). Theexists_at()helper takesPathElementvalues, so a member named0can be written as a name and not as an index. Each of these changes to the path builder fails a test: removing theMsegment, adding it before the first name, adding it before an index, leaving it out for members namedM,L,Sor0, leaving it out for an all-digit member name, leaving out theLsegment of the second of two consecutive indexes, and leaving theMsegment out from the fourth path element on.tests/test_condition_nested_paths.py: 26 dual-target wire tests. The 21 in HOLDS, FAILS and the DeleteItem tests are from the 2026-10-25 measurements. The 5 in DERIVED_HOLDS, equality on the members namedM,L,Sand0of a maptand ong[2][1], an element of a list inside a list, were encoded from ExtendDB's in-Rust condition evaluator and then measured on Amazon DynamoDB on 2026-10-28 with the test's item, names and values, two samples per row: each applied its update, and#t.#M = :otherand#g[2][1] = :otheransweredConditionalCheckFailedExceptionand left the item. Each test puts its own item right before the conditional write, so on MongoDB the write runs while the cache entry is fresh and reaches the compiled filter.crates/storage-mongodb/tests/pushdown_parity.rs: the property harness now generates items whose attributes can hold maps and lists nested two deep, with members nameda,b,M,L,Sand0, and conditions on paths of up to three elements; when the item has an attribute, three in four of the paths are replaced by paths the item has (about three in five of all generated paths, since one item in five is empty). It comparesevaluate_conditionwith the compiled filter as the crate's BSON interpreter evaluates it, on each. The harness changes in this PR because it generated single-name paths over flat items and could not see this PR's defect: with main's path builder, the old harness passes and the new one fails. The case budget is unchanged at 1024 per property.Run locally on 2026-10-25:
EXTENDDB_TEST_MONGODB_CONTAINER; CI sets it, and with it set they are the expected failures main already marks.a,b,M,L,S,0,N), sent with retries off: MongoDB at the head and SQLite gave the same answer for all 6,000, and MongoDB's profiler shows each one was checked by the compiled filter. The build before the fix differed from SQLite on 472.cargo +1.97.0 fmt --all -- --check,cargo +1.97.0 clippy -p extenddb-storage-mongodb --all-targets -- -D warnings, andcargo test -p extenddb-storage-mongodb --lib condition(17 passed).Run locally on 2026-10-28, on the final tree:
tandgrows: leaving out theMsegment for members namedM,L,Sand0fails the fourtrows, leaving it out for an all-digit member name fails the#t.#zerorow, and leaving out theLsegment of the second of two consecutive indexes fails the#g[2][1]row.pushdown_parity.rswith 50 fixed seeds (102,400 cases): no difference between the compiled filter andevaluate_condition. With theMsegment removed as on main, added only after a name, or left out for members namedM,L,Sand0, or with the second index'sLsegment left out, both properties failed on all 40 seeds run for each change, and on all 200 seeds run for the two changes that need an index inside the path.pushdown_parity.rsin a debug build: 0.41 s for its 20 tests, up from 0.27 s before the change (median of 7 runs).cargo +1.97.0 test -p extenddb-storage-mongodb -- --include-ignoredagainst the replica set: the 147 unit and live tests, the 18 interpreter self-tests and the 20 harness tests passed.cargo +1.88.0 check -p extenddb-storage-mongodb --all-targetspassed.cargo +1.97.0 fmt --all -- --check,cargo +1.97.0 clippy -p extenddb-storage-mongodb --all-targets -- -D warnings, andcargo test -p extenddb-storage-mongodb --lib condition(18 passed; with theMsegment left out from the fourth element on, 17 passed and the typed-fields test failed).CI runs the new file in
run-integration,run-integration-sqlite,mongodb-pytestandcassandra-pytest.Not changed here
A map member whose name holds U+0000 answers InternalServerError on MongoDB, in PutItem and in a condition on it, where the service accepts both. That is on main and unchanged here; #368 escapes such names in storage.
Files
condition.rswas 759 lines on main. The first commit moves its test module unchanged intocondition_tests.rs, loaded with#[path]asbackup_engine.rsloads its test files.condition.rsis now 505 lines.docs/design/13-storage-mongodb.mdsection 5.2 now says what the parity harness generates.Merging
git merge-tree --write-treeis clean against #571 and #368, and against the other open PRs that touchcrates/storage-mongodb. #571 counts AND, OR and NOT nesting; theMsegment lengthens a field name and adds no nesting, so its count is unchanged.