Skip to content

Resolve discriminators to codecs once per codec (plan item 3.3) - #4405

Merged
evanchooly merged 2 commits into
masterfrom
discriminator-self-match
Oct 20, 2026
Merged

evanchooly merged 2 commits into
masterfrom
discriminator-self-match

Conversation

@evanchooly

@evanchooly evanchooly commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Plan item 3.3 (review B2, step 1). Step 2, a single pass with no rewind, is plan item 3.17.

The problem

Every entity decode, both root and embedded, ran the discriminator pre-scan:

  1. Read up to _t.
  2. Look the value up in DiscriminatorLookup.
  3. Ask the codec registry for that class's codec, even though the discriminator almost always names the codec's own type.
  4. Rewind and decode.

The decode then read _t again into a throwaway String. Separately, DecodeSession's _id pre-read (peekId) built a new DecoderContext and looked up the Object codec on every root decode.

Change

  • MorphiaCodec.codecForDiscriminator(d) (@MorphiaInternal): returns the codec itself when d is its own model's discriminator. Otherwise it resolves the codec once and caches it per codec in a ConcurrentHashMap. It uses get + putIfAbsent rather than computeIfAbsent, because building a codec can look up other codecs. EntityDecoder.getCodecFromDocument and LifecycleDecoder both use it.
  • decodeProperties skips the discriminator value (skipValue()) instead of reading it into a string.
  • peekId reuses a constant DecoderContext and resolves its Object codec once per decoder.
  • getCodecFromDocument drops its now-unused registry and discriminatorLookup parameters. EntityDecoder is @MorphiaInternal, and this was its only caller.

Tests

  • CodecHarnessTest.codecForDiscriminatorResolvesEachTypeOnce runs under both mappers. A codec's own discriminator resolves to itself, and a subtype's discriminator (HarnessCircle through HarnessShape's codec) resolves to the subtype's codec, the same instance every time.
  • TestCodecForDiscriminator (both mappers) checks the cache itself. A self-match leaves it empty, and repeated lookups of two other discriminators leave exactly two entries. It fails if the self-match shortcut is removed.
  • The rest of the harness is unchanged and passes, including the polymorphic, no-discriminator, _t-late and subtype-in-declared-field fixtures. The core and Kotlin suites pass with -Dmorphia.mapper=critter and with reflection: 1487 + 5 tests, 0 failures.

JMH: CodecBenchmark, master (7d357ef7191) vs this branch

Same machine as the baseline (i9-11900H, Temurin 17), -prof gc, the benchmark's own settings (3 forks × 5 × 1 s). Before and after ran back to back with nothing else running. Critter is the AOT variant.

reflection

benchmark model before ns/op after ns/op Δ time before B/op after B/op Δ alloc
decode ARRAYS 69,432 ± 1,705 68,015 ± 957 -2.0% 77,706 77,378 -0.4%
decode COLLECTIONS 9,389 ± 145 8,689 ± 133 -7.5% 18,840 16,816 -10.7%
decode CONCRETE_COLLECTIONS 3,041 ± 56 2,940 ± 73 -3.3% 6,608 6,272 -5.1%
decode LAZY_REFERENCE 3,247 ± 31 3,380 ± 36 +4.1% 8,091 7,776 -3.9%
decode LIFECYCLE 1,166 ± 16 1,100 ± 51 -5.7% 2,960 2,819 -4.8%
decode NESTED 3,168 ± 69 2,942 ± 80 -7.1% 6,768 5,784 -14.5%
decode POLYMORPHIC 5,846 ± 102 5,332 ± 94 -8.8% 12,024 9,648 -19.8%
decode SIMPLE 1,337 ± 56 1,182 ± 73 -11.6% 2,584 2,256 -12.7%
decodeInCursor ARRAYS 68,394 ± 885 68,196 ± 836 -0.3% 77,394 77,066 -0.4%
decodeInCursor COLLECTIONS 9,427 ± 142 8,779 ± 118 -6.9% 18,520 16,504 -10.9%
decodeInCursor CONCRETE_COLLECTIONS 3,104 ± 101 3,079 ± 57 -0.8% 6,312 5,984 -5.2%
decodeInCursor LAZY_REFERENCE 3,467 ± 130 3,399 ± 109 -2.0% 7,837 7,491 -4.4%
decodeInCursor LIFECYCLE 1,181 ± 12 1,157 ± 16 -2.0% 2,648 2,536 -4.2%
decodeInCursor NESTED 3,318 ± 43 3,041 ± 123 -8.4% 6,464 5,464 -15.5%
decodeInCursor POLYMORPHIC 5,961 ± 159 5,470 ± 108 -8.2% 11,704 9,336 -20.2%
decodeInCursor SIMPLE 1,281 ± 12 1,169 ± 13 -8.8% 2,272 1,944 -14.4%
encode ARRAYS 97,573 ± 1,494 96,166 ± 2,455 -1.4% 137,198 137,187 -0.0%
encode COLLECTIONS 7,685 ± 96 7,746 ± 92 +0.8% 7,336 7,336 -0.0%
encode CONCRETE_COLLECTIONS 2,081 ± 30 2,088 ± 23 +0.3% 3,125 3,104 -0.7%
encode LAZY_REFERENCE 1,889 ± 121 1,824 ± 17 -3.5% 4,125 4,083 -1.0%
encode LIFECYCLE 824 ± 13 819 ± 16 -0.6% 3,021 3,043 +0.7%
encode NESTED 2,295 ± 31 2,374 ± 66 +3.4% 1,856 1,856 -0.0%
encode POLYMORPHIC 4,303 ± 91 4,249 ± 62 -1.3% 6,024 6,024 +0.0%
encode SIMPLE 787 ± 10 790 ± 10 +0.4% 1,216 1,216 -0.0%

critter

benchmark model before ns/op after ns/op Δ time before B/op after B/op Δ alloc
decode ARRAYS 67,983 ± 808 67,769 ± 654 -0.3% 77,697 77,378 -0.4%
decode COLLECTIONS 9,664 ± 149 8,975 ± 148 -7.1% 18,847 16,816 -10.8%
decode CONCRETE_COLLECTIONS 3,053 ± 87 3,036 ± 121 -0.6% 6,616 6,272 -5.2%
decode LAZY_REFERENCE 3,291 ± 116 3,354 ± 48 +1.9% 8,083 7,720 -4.5%
decode LIFECYCLE 1,165 ± 17 1,171 ± 56 +0.6% 2,960 2,840 -4.1%
decode NESTED 3,491 ± 66 3,285 ± 115 -5.9% 6,784 5,768 -15.0%
decode POLYMORPHIC 6,265 ± 75 5,745 ± 61 -8.3% 12,016 9,656 -19.6%
decode SIMPLE 1,330 ± 54 1,223 ± 35 -8.0% 2,584 2,256 -12.7%
decodeInCursor ARRAYS 69,752 ± 2,999 69,731 ± 999 -0.0% 77,394 77,066 -0.4%
decodeInCursor COLLECTIONS 9,927 ± 204 9,257 ± 271 -6.7% 18,512 16,504 -10.8%
decodeInCursor CONCRETE_COLLECTIONS 3,159 ± 33 3,167 ± 115 +0.3% 6,304 5,984 -5.1%
decodeInCursor LAZY_REFERENCE 3,498 ± 101 3,506 ± 40 +0.2% 7,829 7,437 -5.0%
decodeInCursor LIFECYCLE 1,195 ± 12 1,175 ± 13 -1.6% 2,648 2,549 -3.7%
decodeInCursor NESTED 3,569 ± 44 3,292 ± 82 -7.7% 6,464 5,464 -15.5%
decodeInCursor POLYMORPHIC 6,427 ± 75 5,811 ± 77 -9.6% 11,704 9,336 -20.2%
decodeInCursor SIMPLE 1,370 ± 44 1,283 ± 65 -6.4% 2,272 1,944 -14.4%
encode ARRAYS 97,252 ± 1,461 97,564 ± 1,337 +0.3% 150,051 141,496 -5.7%
encode COLLECTIONS 7,794 ± 130 7,716 ± 54 -1.0% 7,336 7,336 +0.0%
encode CONCRETE_COLLECTIONS 2,022 ± 26 2,044 ± 14 +1.0% 3,104 3,104 -0.0%
encode LAZY_REFERENCE 1,707 ± 16 1,797 ± 127 +5.3% 4,072 4,125 +1.3%
encode LIFECYCLE 811 ± 9 820 ± 12 +1.0% 3,043 3,021 -0.7%
encode NESTED 2,361 ± 30 2,377 ± 21 +0.7% 1,856 1,856 -0.0%
encode POLYMORPHIC 4,360 ± 39 4,347 ± 36 -0.3% 5,640 5,640 +0.0%
encode SIMPLE 754 ± 9 760 ± 8 +0.8% 1,216 1,216 -0.0%

Reading it:

  • Decode allocation is down sharply and consistently, on both mappers: −328 B per document on SIMPLE (−12.7%), −1.0 KB on NESTED (−15%), −2.0 KB on COLLECTIONS (−11%) and −2.4 KB on POLYMORPHIC (−20%).
  • Decode time is 6–12% faster on those models.
  • This is well above the review's step-1 estimate of ~48 B per document for the self-match alone. The likely extra is the per-decode DecoderContext and Object-codec lookup in peekId, plus the dropped throwaway _t string. I didn't measure the parts separately.
  • Encode is untouched. The critter encode ARRAYS allocation drop (150,051 → 141,496 B) is the usual noise for that model, whose before value is an outlier against earlier runs.
  • One decode cell looked slower: reflection decode LAZY_REFERENCE, +4.1%. I reran it twice, alternating master and this branch. This branch was faster both times (3,329 vs 3,293 and 3,407 vs 3,328 ns/op) and allocated ~340 B less, so it was noise.

Decisions to review

  1. peekId keeps the Object codec rather than the id property's codec, even though the plan suggested the id property's codec. The pre-read id is the key the entity is registered under in DecodeSession. References look entities up by the id as stored in the reference: an ObjectId, a String, or a Document for a compound id. The id property's codec could produce a different value, such as a POJO for an embedded id or a converted type, and then the session lookup would miss and reference-cycle detection would stop working. Resolving the Object codec once gets the allocation win without that risk.
  2. getCodecFromDocument's signature changed. It's protected on a @MorphiaInternal class, and nothing outside EntityDecoder calls it.

Every entity decode looked up its discriminator through
DiscriminatorLookup and the codec registry, even though the discriminator
almost always names the codec's own type. MorphiaCodec.codecForDiscriminator
returns the codec itself on a self-match and caches the codec for any
other discriminator; EntityDecoder and LifecycleDecoder use it.
decodeProperties skips the discriminator value instead of reading it into
a string, and peekId reuses one DecoderContext and resolves its Object
codec once per decoder.
Copilot AI balanced review requested due to automatic review settings October 20, 2026 04:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No blocking defects were identified; the remaining lookup-count testing suggestion is optional.

1 open issue
What changed in this PR

Reduces repeated work in Morphia’s entity decoding while retaining the discriminator pre-scan.

Changes:

  • Adds per-codec discriminator resolution caching and a self-match shortcut.
  • Reuses _id decoding resources and skips already-processed discriminator values.
  • Adds codec-resolution identity tests for both mappers.
File Description
design/​mapping-game-plan.md Marks plan item 3.3 complete.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​CodecHarnessTest.java Tests self-match and subtype codec identity.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​MorphiaCodec.java Adds discriminator codec caching.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​LifecycleDecoder.java Uses the shared discriminator resolver.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityDecoder.java Reuses decoding resources and simplifies discriminator handling.

Review effort: Balanced

Comment thread core/src/main/java/dev/morphia/mapping/codec/pojo/MorphiaCodec.java
A self-match leaves the cache empty, and repeated lookups of other
discriminators leave one entry each. Identity checks alone couldn't tell
a cached lookup from a repeated one, since the codec provider caches
codecs by class too.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Static review found no blocking issues in the focused caching changes; automated tests were not independently run.

1 open issue

Review effort: Balanced

@evanchooly
evanchooly merged commit 0586221 into master Oct 20, 2026
5 checks passed
@evanchooly
evanchooly deleted the discriminator-self-match branch October 20, 2026 12:54
evanchooly added a commit that referenced this pull request Oct 22, 2026
… 3.4 + 3.5) (#4406)

Plan items **3.4** and **3.5**. The plan notes that 3.4, a one-line fix,
can ride along with any PR, so each has its own commit here.

## 3.4: `getProperty` does one lookup
`EntityModel.getProperty` used
`propertyModelsByMappedName.getOrDefault(name,
propertyModelsByName.get(name))`. The default argument is evaluated
eagerly, so it probed the second map on **every** call, including once
per decoded field. It now checks the mapped-name map first and falls
back only on a miss. The result is the same, since neither map holds
null values.

## 3.5: memoize codec lookups by class
The driver's `ProvidersCodecRegistry.get(Class)` allocates several
objects on every call just to find a codec it has already cached: a
`ChildCodecRegistry`, a `CodecCacheKey`, an `Optional` and a capturing
lambda. Morphia looks codecs up per value, for array elements, map
values, references, untyped properties and subtype values. Three
changes:
- **`MemoizingCodecRegistry`** (`@MorphiaInternal`) wraps the
datastore's registry. A repeat `get(Class)` is a single
`ConcurrentHashMap` hit. It uses `get` + `putIfAbsent` rather than
`computeIfAbsent`, because building a codec can recurse into lookups.
Failed lookups aren't cached. Lookups that take type arguments or a
registry depend on their context, so they go straight to the wrapped
registry. The driver caches one codec per class and returns the cached
instance, and that's the instance memoized here, so results are
unchanged. This also covers the driver's own per-operation lookups
through `database.withCodecRegistry`.
- **`MorphiaCodec.codecForSubtype(Class)`** caches the codec for each
subtype value encoded through a codec. `EntityEncoder` and
`LifecycleEncoder` use it instead of the codec's composite
`fromRegistries(...)` registry, which is a driver registry and so
allocates on every lookup.
- **`ArrayCodec`** resolves its component codec once, instead of once
per element. The cache field is `volatile` because the codec is shared
across threads. `EntityDecoder`'s cached `peekId` codec from #4405 gets
the same fix.

## Tests
- `TestMemoizingCodecRegistry` uses a counting delegate to check three
things:
- repeated `get(String.class)` calls reach the delegate once and return
the same codec;
  - a failing lookup isn't cached, so it fails each time;
- contextual `get(Class, CodecRegistry)` lookups always reach the
delegate.
- The harness, creator and discriminator tests are unchanged and pass.
The core and Kotlin suites pass with `-Dmorphia.mapper=critter` and with
reflection: 1492 + 5 tests, 0 failures.

## JMH: `CodecBenchmark`, `master` (`05862218aea`) vs this branch
Same machine as the baseline (i9-11900H, Temurin 17), `-prof gc`, the
benchmark's own settings (3 forks × 5 × 1 s). Before and after ran back
to back with nothing else running. Critter is the AOT variant.

**reflection**

| benchmark | model | before ns/op | after ns/op | Δ time | before B/op
| after B/op | Δ alloc |
|---|---|---:|---:|---:|---:|---:|---:|
| decode | ARRAYS | 69,196 ± 483 | 68,390 ± 3,149 | -1.2% | 77,378 |
74,849 | -3.3% |
| decode | COLLECTIONS | 9,091 ± 254 | 8,242 ± 64 | -9.3% | 16,824 |
16,712 | -0.7% |
| decode | CONCRETE_COLLECTIONS | 3,015 ± 53 | 2,818 ± 89 | -6.5% |
6,272 | 6,128 | -2.3% |
| decode | LAZY_REFERENCE | 3,260 ± 136 | 3,120 ± 124 | -4.3% | 7,755 |
7,293 | -5.9% |
| decode | LIFECYCLE | 1,138 ± 27 | 1,063 ± 29 | -6.6% | 2,851 | 2,840 |
-0.4% |
| decode | NESTED | 3,015 ± 88 | 2,818 ± 33 | -6.5% | 5,776 | 5,656 |
-2.1% |
| decode | POLYMORPHIC | 5,461 ± 54 | 5,048 ± 64 | -7.6% | 9,656 | 9,528
| -1.3% |
| decode | SIMPLE | 1,216 ± 14 | 1,102 ± 15 | -9.4% | 2,256 | 2,136 |
-5.3% |
| decodeInCursor | ARRAYS | 68,529 ± 509 | 67,378 ± 708 | -1.7% | 77,066
| 74,546 | -3.3% |
| decodeInCursor | COLLECTIONS | 9,034 ± 126 | 8,347 ± 96 | -7.6% |
16,504 | 16,384 | -0.7% |
| decodeInCursor | CONCRETE_COLLECTIONS | 3,071 ± 65 | 2,928 ± 72 |
-4.7% | 5,968 | 5,848 | -2.0% |
| decodeInCursor | LAZY_REFERENCE | 3,530 ± 42 | 3,260 ± 37 | -7.7% |
7,507 | 7,005 | -6.7% |
| decodeInCursor | LIFECYCLE | 1,178 ± 38 | 1,120 ± 26 | -4.9% | 2,536 |
2,528 | -0.3% |
| decodeInCursor | NESTED | 3,200 ± 269 | 2,819 ± 103 | -11.9% | 5,464 |
5,344 | -2.2% |
| decodeInCursor | POLYMORPHIC | 5,565 ± 77 | 5,140 ± 109 | -7.6% |
9,336 | 9,216 | -1.3% |
| decodeInCursor | SIMPLE | 1,185 ± 34 | 1,137 ± 38 | -4.0% | 1,944 |
1,824 | -6.2% |
| encode | ARRAYS | 96,905 ± 1,853 | 97,915 ± 1,481 | +1.0% | 137,198 |
135,598 | -1.2% |
| encode | COLLECTIONS | 7,774 ± 88 | 7,575 ± 74 | -2.6% | 7,400 | 7,336
| -0.9% |
| encode | CONCRETE_COLLECTIONS | 2,060 ± 34 | 2,030 ± 31 | -1.5% |
3,136 | 3,104 | -1.0% |
| encode | LAZY_REFERENCE | 1,806 ± 19 | 1,809 ± 78 | +0.2% | 4,083 |
3,885 | -4.8% |
| encode | LIFECYCLE | 827 ± 8 | 802 ± 15 | -3.1% | 3,043 | 3,064 |
+0.7% |
| encode | NESTED | 2,281 ± 24 | 2,248 ± 44 | -1.4% | 1,856 | 1,856 |
-0.0% |
| encode | POLYMORPHIC | 4,293 ± 33 | 4,032 ± 71 | -6.1% | 6,024 | 4,584
| -23.9% |
| encode | SIMPLE | 798 ± 12 | 777 ± 11 | -2.6% | 1,216 | 1,216 | -0.0%
|

**critter**

| benchmark | model | before ns/op | after ns/op | Δ time | before B/op
| after B/op | Δ alloc |
|---|---|---:|---:|---:|---:|---:|---:|
| decode | ARRAYS | 67,049 ± 741 | 66,715 ± 840 | -0.5% | 77,378 |
74,858 | -3.3% |
| decode | COLLECTIONS | 8,845 ± 121 | 8,533 ± 102 | -3.5% | 16,824 |
16,688 | -0.8% |
| decode | CONCRETE_COLLECTIONS | 3,005 ± 27 | 2,952 ± 83 | -1.7% |
6,272 | 6,128 | -2.3% |
| decode | LAZY_REFERENCE | 3,323 ± 47 | 3,095 ± 96 | -6.9% | 7,752 |
7,227 | -6.8% |
| decode | LIFECYCLE | 1,126 ± 12 | 1,056 ± 36 | -6.2% | 2,840 | 2,840 |
-0.0% |
| decode | NESTED | 3,222 ± 139 | 3,020 ± 52 | -6.3% | 5,752 | 5,656 |
-1.7% |
| decode | POLYMORPHIC | 5,697 ± 72 | 5,431 ± 115 | -4.7% | 9,664 |
9,528 | -1.4% |
| decode | SIMPLE | 1,182 ± 74 | 1,135 ± 36 | -4.0% | 2,256 | 2,136 |
-5.3% |
| decodeInCursor | ARRAYS | 67,135 ± 608 | 67,103 ± 2,040 | -0.0% |
77,066 | 74,546 | -3.3% |
| decodeInCursor | COLLECTIONS | 9,157 ± 145 | 8,776 ± 164 | -4.2% |
16,504 | 16,384 | -0.7% |
| decodeInCursor | CONCRETE_COLLECTIONS | 3,109 ± 54 | 2,983 ± 36 |
-4.1% | 5,976 | 5,840 | -2.3% |
| decodeInCursor | LAZY_REFERENCE | 3,397 ± 91 | 3,114 ± 45 | -8.3% |
7,453 | 6,979 | -6.4% |
| decodeInCursor | LIFECYCLE | 1,158 ± 21 | 1,136 ± 21 | -1.9% | 2,549 |
2,557 | +0.3% |
| decodeInCursor | NESTED | 3,350 ± 153 | 2,969 ± 44 | -11.4% | 5,464 |
5,344 | -2.2% |
| decodeInCursor | POLYMORPHIC | 5,770 ± 97 | 5,480 ± 101 | -5.0% |
9,336 | 9,216 | -1.3% |
| decodeInCursor | SIMPLE | 1,221 ± 14 | 1,140 ± 58 | -6.7% | 1,944 |
1,824 | -6.2% |
| encode | ARRAYS | 95,994 ± 1,289 | 98,008 ± 3,130 | +2.1% | 145,784 |
135,598 | -7.0% |
| encode | COLLECTIONS | 7,650 ± 115 | 7,604 ± 69 | -0.6% | 7,336 |
7,336 | +0.0% |
| encode | CONCRETE_COLLECTIONS | 2,016 ± 20 | 2,022 ± 30 | +0.3% |
3,104 | 3,147 | +1.4% |
| encode | LAZY_REFERENCE | 1,697 ± 17 | 1,658 ± 16 | -2.3% | 4,072 |
3,832 | -5.9% |
| encode | LIFECYCLE | 827 ± 19 | 784 ± 11 | -5.3% | 3,064 | 3,043 |
-0.7% |
| encode | NESTED | 2,348 ± 30 | 2,306 ± 15 | -1.8% | 1,856 | 1,856 |
-0.0% |
| encode | POLYMORPHIC | 4,290 ± 36 | 4,023 ± 58 | -6.2% | 5,640 | 4,200
| -25.5% |
| encode | SIMPLE | 769 ± 27 | 735 ± 7 | -4.4% | 1,216 | 1,216 | -0.0% |

**Reading it:**
- **POLYMORPHIC encode allocates 1,440 B less (−24% / −25.5%) and runs
about 6% faster.** That's the 12 subtype values per document no longer
going through the driver's registry. The review estimated ~600 B per
operation.
- **Decode is 4–12% faster on most models, on both mappers.** That comes
from 3.4's lookup per field, plus the memoized lookups. Decode
allocation drops 120 B on SIMPLE, 2.5 KB on ARRAYS (the component codec)
and ~500 B on LAZY_REFERENCE (reference lookups).
- **No cell moved the wrong way beyond its error bars.** The critter
`encode ARRAYS` allocation drop includes that cell's usual outlier
`before` value.

## Decisions to review
1. **3.4 and 3.5 ship in one PR, as two commits.** The plan allows it
for 3.4, and benchmarking a one-line change on its own would cost
another hour of JMH. The table measures both together.
2. **The memoizing wrapper covers the whole datastore registry, not only
Morphia's providers.** The driver's own lookups during operations
benefit too. The wrapper never invents a codec: it caches what the
driver returns, the driver itself keeps exactly one codec per class, and
failed lookups aren't cached.
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.

2 participants