Skip to content

Strengthen critter mapper tests: tier assertions and weak-cache release - #4368

Merged
evanchooly merged 1 commit into
masterfrom
critter-test-gaps
Oct 6, 2026
Merged

evanchooly merged 1 commit into
masterfrom
critter-test-gaps

Conversation

@evanchooly

@evanchooly evanchooly commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #4359

Test-only changes in TestCritterMapper:

  • Tier assertions. New assertRuntimeGenerated(EntityModel) helper checks the model is a CritterEntityModel whose class was loaded by a CritterClassLoader. It's used in every test meant to exercise runtime generation, so they fail loudly instead of quietly testing AOT models if dev.morphia.mapping is ever added to the test morphia.packages. It isn't used in the reflection-fallback, imported-model (register), or null/non-entity tests.
  • Weak-cache release. testRuntimeClassLoaderReleasedWithItsMappers maps an entity through two sharing mappers (plus a copy) inside a helper that returns only a WeakReference to the generated loader, then runs System.gc() in a bounded loop (up to 10s) and asserts the reference clears. The config uses a discriminator key no other test uses, so no other mapper in the JVM can share and pin that RuntimeModels. This covers both CritterMapper.RuntimeModels.SHARED and NestmateAccessorRegistry. No leak found: the loader is collected. I checked the test can fail by temporarily holding the mapper in a static field, and the test then failed as expected.
  • Concurrency. testConcurrentMappingAcrossSharingMappers: 8 threads, each with its own CritterMapper using the same config (a fresh key, so generation actually races), released together by a CountDownLatch. All succeed, all models share one generated class, each mapper registers its own model instance.

Full core suite: 1301 tests, 0 failures, 0 errors, 16 skipped, with both -Dmorphia.mapper=critter and -Dmorphia.mapper=reflection.

Merge order

This PR is one of a batch that came out of the critter review issues (#4352–#4359). Suggested merge order:

  1. Run each JDK test leg on its own JDK #4361, Add a non-blocking CI leg that denies final field mutation (JEP 500) #4360 (CI only, independent)
  2. Don't re-set properties already passed to an entity's constructor #4362, then Test record entities with both mappers #4366 (Test record entities with both mappers #4366 depends on Don't re-set properties already passed to an entity's constructor #4362)
  3. Wrap failures in the woven final-field writer like the runtime accessor #4364, Give mapper copies with different mapping settings their own runtime models #4363, Name the cause when critter runtime generation can't access an entity #4365 (+ Document critter runtime generation's class loader requirement morphia-docs#20), Critter clean-ups: CritterClassLoader.findClass ordering and stale docs #4367 (independent, any order)
  4. Strengthen critter mapper tests: tier assertions and weak-cache release #4368 last (conflicts with Give mapper copies with different mapping settings their own runtime models #4363 and Name the cause when critter runtime generation can't access an entity #4365 in TestCritterMapper.java; rebase after they merge)

This PR: merge last. It conflicts with #4363 and #4365 in TestCritterMapper.java (all three add tests there); rebase onto master after they merge and re-run the suite.

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.

Copilot review overview

🟢 Approval recommended

The test-only changes correctly cover the identified runtime generation and cache lifecycle gaps.

Review effort: Balanced
Findings: None

What changed in this PR

Strengthens Critter runtime-generation tests and validates shared runtime model lifecycle behavior.

Changes:

  • Adds reusable runtime-tier assertions.
  • Tests concurrent model generation across sharing mappers.
  • Verifies generated class loaders can be garbage-collected.
File Description
core/​src/​test/​java/​dev/​morphia/​mapping/​TestCritterMapper.java Adds tier, concurrency, and weak-cache release coverage.

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

evanchooly added a commit that referenced this pull request Oct 5, 2026
The `JdkTests` job's matrix lists `java: [ 17, 21, 25 ]`, but it passed
a hard-coded `java: 17` to the shared workflow. So all three legs (times
both mappers) ran on JDK 17, and 21 and 25 were never tested. It now
passes `${{ matrix.java }}`.

The legs still use the build compiled on JDK 17 (`reuseBuild: true`),
which is what we want: JDK 17 bytecode running on newer JVMs.

Expect this PR's CI to be the first real run on 21 and 25, so any
failures there are newly visible, not newly introduced.

Found while working on #4360.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. Merge any time.
evanchooly added a commit that referenced this pull request Oct 5, 2026
…4360)

Refs #4353

## What

- **`pom.xml`**: new `final-field-mutation` profile. It activates only
when **both** the JDK is 26+ (`<jdk>[26,)</jdk>`) **and**
`-Dfinal.field.mutation=<mode>` is set. It sets the `argLine` property
to `--illegal-final-field-mutation=<mode>`, so surefire passes it to the
forked test JVM. On older JDKs the profile stays off, because those JVMs
reject the option with `Unrecognized option` and won't start. It uses
the `argLine` property instead of surefire `<configuration>`, so it
still works with jacoco's `prepare-agent` in the `coverage` profile.
- **`.github/workflows/build.yml`**: new `FinalFieldMutationTests` job.
It runs `reflection` and `critter` on JDK 26 with
`-Dfinal.field.mutation=deny`, using `reuseBuild` like the other legs.
It is **`optional: true`** (`continue-on-error`) and is not in
`Release.needs`.

Option and values come from [JEP 500](https://openjdk.org/jeps/500):
`--illegal-final-field-mutation=allow|warn|debug|deny`. The JDK 26
default is `warn`. With `deny`, `Field::set` on a final field throws
`IllegalAccessException`.

## Why non-blocking

This leg is **expected to fail** for now. Morphia still mutates final
fields in the reflection mapper's `FieldAccessor`, critter's runtime
nestmate accessors, critter's AOT `__writeXxx` for final fields, and
`ConstructorCreator`'s re-set after construction (#4352). The leg's job
is to list every remaining dependency and catch new ones. Once those
paths are removed or put behind an explicit opt-in, drop `optional:
true` so it blocks. This PR uses `Refs` rather than `Fixes` because
#4353 also asks for a decision on entities whose finals can only be set
reflectively.

## Reproduce locally (JDK 26+)

```
./mvnw install -DskipTests
cd core && ../mvnw surefire:test -Dmorphia.mapper=critter -Dfinal.field.mutation=deny
```

## Notes

- I had no JDK 26 locally, so I haven't yet seen which tests fail under
`deny`. The first CI run of this leg will be the first list.
- Separate issue, not changed here: the existing `JdkTests` matrix
defines `java: [17, 21, 25]` but passes `java: 17` to the reusable
workflow, so every JDK leg currently runs on 17.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. The new JDK 26 leg is non-blocking and
expected to fail until the reflective final-field writes are removed
(#4362 removes the most common one).
evanchooly added a commit that referenced this pull request Oct 5, 2026
)

Fixes #4352

## Change
`ConstructorCreator.set()` used to queue every decoded property into
`pendingModels`/`pendingValues`, and `getInstance()` re-set all of them
after construction, including the ones the constructor had just
received. For an immutable, constructor-mapped entity that meant a
redundant reflective `Field.set` on each `final` field for every decoded
document. JDK 26 warns about that under [JEP
500](https://openjdk.org/jeps/500).

Now only properties with no matching constructor parameter are queued.
Properties the chosen constructor doesn't take, like the extra fields
when `bestConstructor` picks a partial constructor, are still set after
construction, as before.

## Behavior change
If a constructor transforms its argument (normalizes, copies, wraps it
in an unmodifiable collection, etc.), the constructor's result is kept.
Before, the raw decoded value silently overwrote it. This is almost
certainly what users expect, but it is a visible change for any entity
that relied on the overwrite.

## Tests
- New
`ConstructorCreatorTest#constructorArgumentsAreNotResetAfterConstruction`:
an immutable entity whose constructor lowercases/trims `name` and sorts
`tags` into an unmodifiable list, plus a non-constructor field `note`.
It checks that the normalized values survive the decode and that `note`
is still set. With the fix reverted it fails under both mappers
(`expected: <mixed case> but was: < MiXeD Case >`).
- Full `morphia-core` suite:
  - critter: 1300 run, 0 failures, 0 errors, 16 skipped
  - reflection: 1300 run, 0 failures, 0 errors, 16 skipped


## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4366 depends on it**; merge this first.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…models (#4363)

`CritterMapper(CritterMapper other, MorphiaConfig config)` reused
`other.runtimeModels` even when `config` changed settings that runtime
generation bakes into bytecode (collection/property naming,
discriminator function/key, property discovery, annotation providers).
Entities first mapped through the copy got classes generated under the
original's settings.

The copy now keeps the original's runtime models when the config is the
same instance or both configs have equal generation keys. Otherwise it
uses `RuntimeModels.forConfig(config, classLoader)`, which means
unshared models when the key is null (custom strategies) or the parent
is a `CritterClassLoader`. Entities already mapped are still cloned from
the original as before. The common copy paths (`copy()`, the datastore
copy, and copies that change only the database name) still share
generated classes.

Tests:
- `testCopyWithDifferentNamingGeneratesItsOwnModels`: fails without the
fix (`expected: <Name> but was: <name>`)
- `testCopyWithDifferentDatabaseSharesRuntimeModels`: a copy that
changes only the database still reuses the generated class

Core suite: 1301 tests, 0 failures, 16 skipped, with both critter and
reflection mappers.

Fixes #4356

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4368 conflicts with it** in
`TestCritterMapper.java`, so merge this before #4368.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…or (#4364)

Fixes #4355

The `__writeXxx` method that `AddFieldAccessorMethods#writeFinalField`
weaves for final fields called `Field.set` without handling its checked
`IllegalAccessException`, so a failure surfaced as an undeclared checked
exception.

The `Field.set` call now sits in a `trying(...)` block that catches
`Exception` and rethrows `new RuntimeException("Failed to set final
field '<name>'", e)`. This is the same type, message and catch scope as
the runtime path in `NestmateAccessorGenerator`, so both tiers fail the
same way. The catch covers all `Exception`s, not only
`IllegalAccessException`, to match the runtime tier, and every failure
names the field. The Field lookup and caching stay outside the try.

**Test:** `TestGeneration#testGeneratorFinalFieldWriteFailureIsWrapped`
puts a Field that was never made accessible into the woven class's
`__fieldCode` cache. That makes `Field.set` throw
`IllegalAccessException` through the AOT accessor. The test then checks
the exception type, message and cause. With the fix reverted, the test
fails because the raw `IllegalAccessException` reaches the caller.

**Results:** core 1300 tests (16 skipped), 0 failures under both critter
and reflection. critter-maven verify passes (6 unit tests and 3 invoker
tests).

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies; merges cleanly with the rest of the batch.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…#4365)

Fixes #4357

Runtime critter generation defines property accessors as hidden
nestmates (`privateLookupIn(...).defineHiddenClass(..., NESTMATE)`),
which needs full privilege access to the entity, i.e. the entity must be
in Morphia's module (on the classpath: the same class loader). In
app-server shared-lib, isolated-loader, or JPMS layouts this failed with
a raw `IllegalAccessException` message in the fallback warning.

- `CritterGenerator.defineNestmate` wraps an `IllegalAccessException`
from `privateLookupIn`/`defineHiddenClass` (only those calls) in a new
internal `NestmateAccessException`.
- `CritterMapper.tryRuntimeGeneration` looks for it in the cause chain
and logs a clear warning: the entity isn't in Morphia's module/class
loader, falling back to reflection, pre-generate with critter-maven.
Other failures are logged as before.
- Tests: an entity compiled at test time and loaded by its own
`URLClassLoader` falls back to a reflective `EntityModel` and logs the
new message once; a unit test covers the cause-chain classification.
- Updated `.claude/skills/diagnose/SKILL.md`.

Docs: MorphiaOrg/morphia-docs#20

Core suite: critter 1301 run, 0 failures/errors, 16 skipped; reflection
1301 run, 0 failures/errors, 16 skipped.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4368 conflicts with it** in
`TestCritterMapper.java`, so merge this before #4368.
@evanchooly evanchooly added this to the 3.0.0 milestone Oct 6, 2026
evanchooly added a commit that referenced this pull request Oct 6, 2026
Adds round-trip tests (save, then find through the Datastore) for Java
records mapped as entities. Covers #4354.

**Depends on #4352.** These tests fail under every mapper and tier until
`ConstructorCreator` stops re-setting properties it already passed to
the constructor.

## What's covered
`dev.morphia.test.records.TestRecordEntities`:
- a record `@Entity` with an `@Id`, a `String`, an `int` renamed with
`@Property("years")`, and a `List<String>`. The test checks the stored
document's shape, then finds by `_id` and by the renamed component.
- a record embedded in a regular class entity, both as a single field
and in a list.

Each scenario runs against two sets of fixtures:
| Fixtures | Package | Critter tier |
|---|---|---|
| `AotRecordPerson` / `AotRecordAddress` / `AotRecordHolder` |
`dev.morphia.test.models.records` (under `morphia.packages`) | AOT,
generated by critter-maven `generate-test-models` |
| `RuntimeRecordPerson` / `RuntimeRecordAddress` / `RuntimeRecordHolder`
| `dev.morphia.test.records` (outside `morphia.packages`) | runtime
generation |

Every test asserts which tier built the model: a reflection
`EntityModel` under `reflection`, and under `critter` a
`CritterEntityModel` whose class loader is or isn't a
`CritterClassLoader`. If critter quietly falls back to another tier, the
test fails rather than passing against the wrong tier.

## Current failure (all 4 tests, both mappers)
Saving works. Decoding fails in `ConstructorCreator.getInstance()`. The
canonical constructor builds the record correctly, then the
`pendingModels` loop calls `PropertyModel.setValue` on every component
again. `Field.set` always throws for record fields, even after
`setAccessible(true)`:
- reflection: `FieldAccessor.set` -> `IllegalAccessException: Can not
set final ... AotRecordPerson.id`
- critter AOT: the generated `IdAccessor.set` -> the injected
`AotRecordPerson.__writeId` -> `Field.set` -> same exception
- critter runtime: `Failed to set final field 'id'`

I checked this locally. Queuing only the properties that have no
constructor parameter (the #4352 fix) makes all 4 tests pass under both
`critter` and `reflection`, with the tier assertions passing too. That
change is not in this PR.

Critter generation needed no fixes: AOT and runtime both generate models
for the records without complaint.

## Full core suite (on this branch, without the #4352 fix)
- critter: 1303 run, 0 failures, 4 errors (the new tests), 16 skipped
- reflection: 1303 run, 0 failures, 4 errors (the new tests), 16 skipped

Fixes #4354


## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** #4362 is merged and this branch includes it, so it's ready
to merge.

### CI note

Pull-request CI runs `install` without critter-maven's
`generate-test-models`, so there are no pre-generated models there and
the `Aot*` fixtures get runtime-generated models. The tier check expects
AOT exactly when the pre-generated model is on the classpath (push
builds and local runs that generate them) and runtime generation
otherwise. With the check forced to expect runtime, the AOT tests fail
when models are present, so it still catches a silent tier fallback.
Follow-up for the CI gap: #4369.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…cs (#4367)

Fixes #4358

- **`CritterClassLoader.findClass`**: the name is now added to
`definedTypes` only after `defineClass` succeeds, so a
`ClassFormatError`/`VerifyError` from bad bytes no longer leaves
`getResource` hiding the parent's `.class` resource. The removed bytes
are deliberately not restored: a retry would define the same bytes and
fail the same way.
- **`CritterMapper`**: the class-level and `tryRuntimeGeneration`
javadocs now say runtime generation uses hidden nestmate accessors, not
VarHandles.
- **`CritterClassLoader.shouldRegister`**: the comment now explains that
`NestmateAccessorRegistry`'s map, though keyed per `CritterClassLoader`,
is a static field, so the generator (which registers through the
parent's copy of the class) and the generated models (which look
accessors up) must see the same `Class`.

New test `CritterClassLoaderTest.failedDefinitionsDoNotHideResources`
registers invalid bytes for `dev.morphia.critter.Critter`, expects
`ClassFormatError` on load, then asserts the `.class` resource is
visible again. It fails without the fix.

Core suite: 1300 tests, 0 failures, 16 skipped with both
`-Dmorphia.mapper=critter` and `reflection`.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies; merges cleanly with the rest of the batch.
… concurrent sharing

Assert that runtime-generation tests really get runtime-generated models, add a
test showing a generated CritterClassLoader is collectable once its mappers are
gone, and add a concurrent mapping test across mappers sharing runtime models.

Fixes #4359
@evanchooly
evanchooly merged commit a0266da into master Oct 6, 2026
57 of 59 checks passed
@evanchooly
evanchooly deleted the critter-test-gaps branch October 6, 2026 03:49
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.

Strengthen critter mapper tests: tier assertions and weak-cache release

2 participants