Skip to content

Apply last-key-wins semantics to duplicate map keys in DynamicMessage - #29513

Open
itzikch wants to merge 1 commit into
protocolbuffers:mainfrom
itzikch:dynamicmessage-map-dedup
Open

Apply last-key-wins semantics to duplicate map keys in DynamicMessage#29513
itzikch wants to merge 1 commit into
protocolbuffers:mainfrom
itzikch:dynamicmessage-map-dedup

Conversation

@itzikch

@itzikch itzikch commented Aug 29, 2026

Copy link
Copy Markdown

Fixes #29512.

What

DynamicMessage.Builder.buildPartial() now normalizes map fields: entries are collapsed by key, keeping the last value seen, matching generated messages and the REQUIRED ValidDataMap*.DuplicateKey conformance test.

Before this change, identical well-formed wire input produced different results from a generated message and a DynamicMessage:

entries after parse reserialized
generated message 1 32050a016b1002 (7 B)
DynamicMessage (before) 2 32050a016b100132050a016b1002 (14 B)
DynamicMessage (after) 1 32050a016b1002 (7 B)

The C++ implementation already handles this for dynamic messages via DynamicMapField (backed by a real map container); the Java DynamicMessage stored map entries in a plain List inside FieldSet and appended unconditionally.

Why in buildPartial() rather than addRepeatedField()

De-duplicating on every insertion would make parsing an n-entry map O(n²), since map entries arrive one at a time during a parse. Normalizing once during buildPartial() is a single pass and keeps parsing linear. buildPartial() already contains map-entry-specific handling (defaulting MapEntry fields), so this sits with related logic.

The pass is skipped entirely unless a map field holds at least two entries, and the field is only rewritten when a duplicate was actually found, so messages without duplicate keys are untouched. If a Message.Builder is encountered in the list (possible while a caller is mid-mutation), the method returns without changing anything, preserving existing behavior.

Testing

Two tests added to MapTest.java:

  • testDuplicateMapKey_dynamicMessageMatchesGeneratedMessage — a duplicate key collapses to the last value and the DynamicMessage matches the generated message byte-for-byte. Fails without this change.
  • testDuplicateMapKey_dynamicMessagePreservesDistinctKeys — distinct keys are all preserved, guarding against over-collapsing. Passes before and after.

The wire bytes are hand-built because a builder already applies map semantics; only the wire format can express a repeated key.

Additionally verified against released protobuf-java 4.36.0 with a standalone harness comparing DynamicMessage to a generated message across: empty message, single entry, two distinct keys, duplicate key x2 and x3, duplicates interleaved with distinct keys, 50 distinct keys, and 50 keys each duplicated. 4 of 8 diverged before the change; 0 after. Parse time stays linear on a worst-case all-duplicates message (10k to 80k entries, per-doubling ratios 0.83 / 1.38 / 1.12).

I was unable to run the full Bazel Java suite locally (the macOS toolchain here requires an Xcode version I don't have), so CI coverage on this PR is appreciated.

@itzikch
itzikch requested a review from a team as a code owner August 29, 2026 18:10
@itzikch
itzikch requested review from shaod2 and removed request for a team August 29, 2026 18:10
@google-cla

google-cla Bot commented Aug 29, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@itzikch

itzikch commented Aug 29, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it!

When a map field arrives on the wire with the same key more than once,
a generated message keeps only the last value, but DynamicMessage kept
every entry. The two therefore produced different field contents and
different serializations for identical, well-formed wire input.

Last-key-wins is required behavior, covered by the REQUIRED conformance
test ValidDataMap*.DuplicateKey in
conformance/binary_json_conformance_suite.cc. The C++ implementation
already honors it for dynamic messages via DynamicMapField, which is
backed by a real map container; the Java DynamicMessage had no
equivalent and stored map entries in a plain List inside FieldSet.

The conformance suite did not catch this because ConformanceJava.java
parses exclusively through generated message parsers, so the
DynamicMessage path is never exercised.

Normalize map fields once in buildPartial() rather than on every
addRepeatedField() call: de-duplicating at insertion time would make
parsing an n-entry map O(n^2), and map entries arrive one at a time
during a parse. The pass is skipped unless a map field holds at least
two entries, and the field is only rewritten when a duplicate was
actually found.

Adds two tests to MapTest: one asserting a duplicate key collapses to
the last value and matches the generated message byte-for-byte (fails
without this change), and one asserting distinct keys are all preserved
(guards against over-collapsing).
@itzikch
itzikch force-pushed the dynamicmessage-map-dedup branch from 9d6940f to 1c1a307 Compare August 29, 2026 18:18
@itzikch

itzikch commented Aug 29, 2026

Copy link
Copy Markdown
Author

Amended the commit to drop a Co-Authored-By trailer that referenced a non-CLA-signed address, which was what the CLA check was failing on. The CLA is signed for the commit author (itzikch20@gmail.com).

@googlebot I signed it!

@itzikch

itzikch commented Aug 29, 2026

Copy link
Copy Markdown
Author

Update on testing: I mentioned in the description that I could not run the Bazel Java suite locally (macOS toolchain wanted an Xcode version I do not have). I have since run it in a Linux container, so here are real results for //java/core:MapTest:

With this change — 43/43 pass:

//java/core:MapTest    PASSED in 0.7s
Executed 1 out of 1 test: 1 test passes.

With the tests kept but DynamicMessage.java reverted to its pre-change state — 1 failure, and it is exactly the reported defect:

1) testDuplicateMapKey_dynamicMessageMatchesGeneratedMessage
   value of: getRepeatedFieldCount(...)
   expected: 1
   but was : 2
Tests run: 43,  Failures: 1

So the new test genuinely reproduces the bug rather than passing vacuously, and the remaining 42 tests in MapTest are unaffected in both directions.

Environment: ubuntu:24.04, OpenJDK 21, bazelisk 1.25.0, built from source at this branch.

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.

DynamicMessage does not apply last-key-wins to duplicate map keys, diverging from generated messages on well-formed input

1 participant