Skip to content

Per-codec CodecPlan; skip the reader mark for codecs that can't fail part-way (plan items 3.0 + 3.1) - #4398

Merged
evanchooly merged 3 commits into
masterfrom
codec-plan
Oct 20, 2026
Merged

evanchooly merged 3 commits into
masterfrom
codec-plan

Conversation

@evanchooly

@evanchooly evanchooly commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Plan items 3.0 and 3.1, together, so the per-codec plan lands with its first real use.

3.0: CodecPlan

CodecPlan (@MorphiaInternal) is an immutable object that MorphiaCodec builds once, in its constructor. It holds:

  • the model's properties;
  • their codecs by slot;
  • encodeOrder, the properties encoded after the id;
  • and, from 3.1, a direct-type mask per slot.

Why it exists. Codec-derived state used to live on PropertyModel, which every datastore using the mapper shares, and it was set lazily. Most Phase 3 items cache something per property that depends on the codec (3.1's masks, 3.2's creator positions, 3.6's flags, 3.7's boxed types). The plan gives that state one per-codec home that's immutable once built, so it doesn't pile up as more lazily set fields on the shared model. In 4.1, codecs are resolved into it per datastore and PropertyModel.codec is removed.

How properties are addressed. Each PropertyModel records its slot, its position in its own model, assigned by EntityModel.addProperty. A plan lookup is therefore an array index. codec(property) rejects a property from another model with an identity check.

Who reads it. EntityEncoder, EntityDecoder and LifecycleDecoder read codecs from the plan of the codec for the model being encoded or decoded. When LifecycleDecoder resolves a subtype from the discriminator, it uses that subtype codec's plan. New decodeProperties(…, CodecPlan) and decodeModel(…, CodecPlan, …) overloads carry the plan, and the old overloads delegate to them.

The plan holds exactly the codecs MorphiaCodec already set on the property models, so what gets decoded is unchanged.

3.1: skip the reader mark for codecs that can't fail part-way

Before, every decoded property took a reader mark (80 B) before even checking for null, so that a mismatched value could be rewound and decoded through the fallback. Now:

  • The NULL check comes first.
  • The plan holds a per-slot bitmask of the BSON types the property's codec reads directly: it either reads the value completely or rejects it before reading anything. When the current type is in the mask, the property decodes with no mark.
  • Every other type keeps today's mark-and-fallback path, so schema drift behaves the same, as the harness fixtures confirm.

The table is keyed on the exact codec class, so subclasses and user codecs keep the mark:

codec reads directly
StringCodec STRING, SYMBOL
ObjectIdCodec OBJECT_ID
IntegerCodec INT32
LongCodec INT32, INT64
DoubleCodec DOUBLE, INT32
BooleanCodec BOOLEAN
Decimal128Codec, BigDecimalCodec DECIMAL128
driver DateCodec DATE_TIME
Morphia EnumCodec STRING
MorphiaLocalDateTimeCodec DATE_TIME, INT64
MorphiaDateCodec DATE_TIME

How the table was checked:

  • I read the NumberCodecHelper bytecode in bson 5.13.0. decodeInt reads INT32 with no way to throw, but reads INT64, DOUBLE and DECIMAL128 first and then throws on precision loss, so those stay off IntegerCodec's list. decodeLong and decodeDouble follow the same pattern.
  • I read the Morphia codecs' decode methods.
  • Anything a listed codec throws after reading, such as EnumCodec on an unknown constant (IllegalArgumentException), was never caught by the BsonInvalidOperationException fallback, so it propagates exactly as before.

Tests

  • CodecHarnessTest.codecPlanResolvesEveryPropertyBySlot runs under both mappers. It checks that every property resolves to a codec, that the plan reports its model, and that a property of another model is rejected. It also checks the masks: StringCodec reads STRING directly but not INT32, and IntegerCodec reads INT32 directly but not INT64.
  • TestDirectBsonTypes pins the direct-type table against the real codecs. Each listed class must be what the registry resolves for its Java type, and must read every listed BSON type completely, boundary values included, without throwing. It fails for a wrong entry, and a driver upgrade that changes one of these codecs would fail it too.
  • The rest of the harness is unchanged and passes, including the scalars-schema-drift and nested-single-value-for-list fixtures that exercise the fallback.
  • The core and Kotlin suites pass with -Dmorphia.mapper=critter and with reflection: 1485 + 5 tests, 0 failures.

JMH: CodecBenchmark, master (30554c3d456) 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 68,120 ± 474 66,648 ± 1,069 -2.2% 77,850 77,770 -0.1%
decode COLLECTIONS 9,865 ± 195 9,124 ± 187 -7.5% 22,656 19,376 -14.5%
decode CONCRETE_COLLECTIONS 3,013 ± 28 3,038 ± 97 +0.8% 6,704 6,672 -0.5%
decode LAZY_REFERENCE 3,493 ± 144 3,421 ± 90 -2.1% 8,307 8,195 -1.3%
decode LIFECYCLE 1,177 ± 8 1,161 ± 30 -1.4% 3,096 3,024 -2.3%
decode NESTED 3,441 ± 94 3,176 ± 84 -7.7% 8,240 7,040 -14.6%
decode POLYMORPHIC 6,350 ± 114 5,958 ± 84 -6.2% 15,056 12,671 -15.8%
decode SIMPLE 1,620 ± 325 1,221 ± 20 -24.6% 3,280 2,648 -19.3%
decodeInCursor ARRAYS 68,517 ± 1,621 67,363 ± 1,609 -1.7% 77,538 77,458 -0.1%
decodeInCursor COLLECTIONS 10,229 ± 197 9,399 ± 189 -8.1% 22,352 19,055 -14.8%
decodeInCursor CONCRETE_COLLECTIONS 3,085 ± 86 3,071 ± 127 -0.5% 6,416 6,376 -0.6%
decodeInCursor LAZY_REFERENCE 3,550 ± 173 3,420 ± 100 -3.6% 8,016 7,885 -1.6%
decodeInCursor LIFECYCLE 1,228 ± 32 1,223 ± 24 -0.4% 2,784 2,723 -2.2%
decodeInCursor NESTED 3,501 ± 103 3,236 ± 54 -7.6% 7,920 6,720 -15.2%
decodeInCursor POLYMORPHIC 6,551 ± 116 6,104 ± 88 -6.8% 14,744 12,344 -16.3%
decodeInCursor SIMPLE 1,403 ± 54 1,264 ± 15 -9.9% 2,976 2,336 -21.5%
encode ARRAYS 95,701 ± 3,084 97,158 ± 1,626 +1.5% 137,219 137,187 -0.0%
encode COLLECTIONS 7,742 ± 112 7,690 ± 117 -0.7% 7,688 7,336 -4.6%
encode CONCRETE_COLLECTIONS 2,072 ± 48 2,036 ± 31 -1.8% 3,136 3,115 -0.7%
encode LAZY_REFERENCE 1,794 ± 23 1,844 ± 130 +2.8% 4,104 4,125 +0.5%
encode LIFECYCLE 820 ± 25 818 ± 26 -0.3% 3,107 3,064 -1.4%
encode NESTED 2,288 ± 37 2,248 ± 47 -1.7% 2,016 1,856 -7.9%
encode POLYMORPHIC 4,424 ± 204 4,232 ± 68 -4.4% 6,440 6,024 -6.5%
encode SIMPLE 797 ± 18 784 ± 11 -1.6% 1,248 1,216 -2.6%

critter

benchmark model before ns/op after ns/op Δ time before B/op after B/op Δ alloc
decode ARRAYS 66,642 ± 967 68,564 ± 1,786 +2.9% 77,841 77,770 -0.1%
decode COLLECTIONS 9,983 ± 248 9,403 ± 193 -5.8% 22,664 19,376 -14.5%
decode CONCRETE_COLLECTIONS 3,064 ± 149 3,082 ± 110 +0.6% 6,712 6,664 -0.7%
decode LAZY_REFERENCE 3,298 ± 70 3,381 ± 127 +2.5% 8,272 8,168 -1.3%
decode LIFECYCLE 1,225 ± 49 1,173 ± 14 -4.2% 3,099 3,003 -3.1%
decode NESTED 3,560 ± 107 3,416 ± 119 -4.0% 8,232 7,032 -14.6%
decode POLYMORPHIC 6,555 ± 120 6,384 ± 89 -2.6% 15,056 12,656 -15.9%
decode SIMPLE 1,369 ± 84 1,279 ± 51 -6.6% 3,288 2,648 -19.5%
decodeInCursor ARRAYS 69,488 ± 2,984 67,707 ± 1,538 -2.6% 77,528 77,458 -0.1%
decodeInCursor COLLECTIONS 10,138 ± 287 9,751 ± 179 -3.8% 22,352 19,056 -14.7%
decodeInCursor CONCRETE_COLLECTIONS 3,230 ± 70 3,272 ± 104 +1.3% 6,424 6,368 -0.9%
decodeInCursor LAZY_REFERENCE 3,613 ± 147 3,465 ± 184 -4.1% 7,994 7,835 -2.0%
decodeInCursor LIFECYCLE 1,250 ± 31 1,235 ± 37 -1.2% 2,784 2,712 -2.6%
decodeInCursor NESTED 3,768 ± 90 3,574 ± 65 -5.2% 7,920 6,720 -15.2%
decodeInCursor POLYMORPHIC 6,797 ± 142 6,594 ± 179 -3.0% 14,744 12,344 -16.3%
decodeInCursor SIMPLE 1,443 ± 79 1,384 ± 30 -4.1% 2,976 2,336 -21.5%
encode ARRAYS 97,139 ± 2,522 95,051 ± 1,615 -2.1% 141,507 141,475 -0.0%
encode COLLECTIONS 7,681 ± 126 7,763 ± 99 +1.1% 7,688 7,336 -4.6%
encode CONCRETE_COLLECTIONS 2,039 ± 41 1,995 ± 43 -2.2% 3,157 3,104 -1.7%
encode LAZY_REFERENCE 1,720 ± 37 1,779 ± 141 +3.5% 4,104 4,125 +0.5%
encode LIFECYCLE 808 ± 12 821 ± 29 +1.6% 3,096 3,075 -0.7%
encode NESTED 2,370 ± 49 2,346 ± 39 -1.0% 2,016 1,856 -7.9%
encode POLYMORPHIC 4,432 ± 104 4,296 ± 51 -3.1% 6,376 5,640 -11.5%
encode SIMPLE 744 ± 13 754 ± 14 +1.3% 1,248 1,216 -2.6%

Reading it:

  • Decode allocation matches the review's estimate. SIMPLE drops 632 B per document (estimated ~640 B), −19.5%. NESTED, COLLECTIONS and POLYMORPHIC drop 14.5–16% (estimated 15–17%). This holds for both mappers and for decodeInCursor too.
  • Decode time is down 4–10% for those models. It's flat for ARRAYS, CONCRETE_COLLECTIONS and LAZY_REFERENCE, whose properties are mostly arrays, collections or references and still take the mark.
  • Encode allocation drops a little because encodeOrder is an array: 32 B on SIMPLE, up to 736 B on critter POLYMORPHIC. Encode time is flat.
  • An earlier run of 3.0 alone showed time flat and the same encode-allocation drop. A single +7.8% outlier (critter decodeInCursor SIMPLE) didn't reproduce on two reruns.

Decisions to review

  1. The plan holds the same, shared codecs as PropertyModel. As before, the first datastore to build a property's codec supplies it to every later one. Resolving codecs per datastore, and deleting PropertyModel.codec, is 4.1's job.
  2. The slot lives on PropertyModel. It's structural: the property's position in its own model, fixed once added and the same for every datastore. That makes lookups array indexes instead of identity-map lookups per field.
  3. The direct-type table is keyed on exact codec classes, a fixed list checked against bson 5.13.0. TestDirectBsonTypes fails if a driver upgrade changes one of these codecs' read behavior. I added BigDecimalCodec, which the review didn't list but which reads DECIMAL128 the same way Decimal128Codec does.
  4. I didn't take the plan's optional extra: sending a type a codec is known to reject straight to the fallback, without the throw. It would only speed up drifted documents. Let me know if you want it here.
  5. Benchmarks cover CodecBenchmark under reflection and critter (AOT) only. critter-runtime runs the same decode path.

MorphiaCodec builds an immutable CodecPlan when it's created: the model's
properties, their codecs by slot, and the properties to encode after the
id. Each PropertyModel records its slot, its position in its model, when
it's added, so a plan lookup is an array index. EntityEncoder,
EntityDecoder and LifecycleDecoder read codecs from the plan of the codec
for the model being encoded or decoded instead of from PropertyModel.

The plan holds the same codecs MorphiaCodec has always set on the
property models, so behavior is unchanged; it gives the Phase 3 items a
per-codec home for codec-derived state.

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

The scoped refactor preserves existing codec resolution behavior, and no blocking issues remain.

0 open findings

What changed in this PR

Adds the per-codec plan foundation for Morphia’s encoding and decoding optimizations while retaining existing codec-sharing behavior.

Changes:

  • Introduces property slots and a CodecPlan with ownership-checked codec lookup and precomputed encode order.
  • Routes encoding and decoding through plans, including lifecycle subtype decoding.
  • Adds slot-lookup coverage and marks plan item 3.0 complete.
File Description
design/​mapping-game-plan.md Marks item 3.0 complete.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​CodecHarnessTest.java Tests codec lookup and foreign-property rejection.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​PropertyModel.java Stores each property’s slot.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​MorphiaCodec.java Constructs and exposes its plan.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​LifecycleDecoder.java Selects the resolved subtype’s plan.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityModel.java Assigns slots when adding properties.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityEncoder.java Uses plan codecs and encode order.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​EntityDecoder.java Adds plan-aware decoding overloads.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​CodecPlan.java Defines the slot-indexed codec plan.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Decoding a property took a reader mark before even checking for null, so
a mismatched value could be rewound and decoded through the fallback. The
NULL check now comes first, and CodecPlan holds a per-slot bitmask of the
BSON types each property's codec reads directly: types it either reads
completely or rejects before reading anything. When the current type is
in the mask, the property decodes without a mark. The table is keyed on
exact codec classes, so subclasses and user codecs keep the mark.
@evanchooly evanchooly changed the title Add a per-codec CodecPlan (plan item 3.0) Per-codec CodecPlan; skip the reader mark for codecs that can't fail part-way (plan items 3.0 + 3.1) Oct 18, 2026
@evanchooly
evanchooly requested a balanced review from Copilot October 18, 2026 22:32

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.

🔵 Needs a closer look

The fallback-sensitive optimization depends on driver-specific failure behavior that remains independently unverified.

No open issues 🎉

Review effort: Balanced

TestDirectBsonTypes walks CodecPlan's table: every listed codec class must
be the one the codec registry resolves for its Java type, and must read
every listed BSON type (boundary values included) completely without
throwing. A driver upgrade that changes one of these codecs fails it.
@evanchooly

Copy link
Copy Markdown
Member Author

Re the review's note that the fallback-sensitive optimization depends on driver behavior that wasn't independently verified: agreed, and ca24509 adds that verification as a test.

TestDirectBsonTypes walks CodecPlan.DIRECT_BSON_TYPES itself. For every entry it checks two things:

  • The class matches. The codec registry resolves exactly that class for its Java type (String → StringCodec, LocalDateTime → MorphiaLocalDateTimeCodec, and so on), so the table can't drift from what Morphia actually uses.
  • The codec reads every listed type completely without throwing. For each BSON type in the codec's mask, it decodes boundary samples (Integer.MIN_VALUE/MAX_VALUE, Long.MIN_VALUE/MAX_VALUE, NaN, −0.0, Double.MAX_VALUE, empty strings, large Decimal128s) and checks the reader is left at the end of the document.

A table entry with no samples fails the test. I confirmed it catches a wrong entry: adding INT64 to IntegerCodec's mask fails with "IntegerCodec should read BsonInt64{value=-9223372036854775808} directly". A driver upgrade that changed one of these codecs' read behavior would fail the same way.

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 identified no blocking defects in plan construction, subtype routing, or fallback preservation; tests were not executed during review.

No open issues 🎉

Review effort: Balanced

@evanchooly
evanchooly merged commit 67dc8c4 into master Oct 20, 2026
5 checks passed
@evanchooly
evanchooly deleted the codec-plan branch October 20, 2026 02:05
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