Skip to content

Start and manage an embedded MongoDB so generated apps run without one installed - #16095

Open
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feature/mongodb-embedded-8.0.x
Open

Start and manage an embedded MongoDB so generated apps run without one installed#16095
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feature/mongodb-embedded-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

An application generated by Forge with MongoDB selected does not start:

com.mongodb.MongoSocketOpenException: Exception opening socket
Caused by: java.net.ConnectException: Connection refused

Nothing starts a MongoDB, so the application only works if one happens to be listening on the configured url. There is currently no embedded option on 8.0.x at all — the embedded-mongodb feature that existed on 7.0.x was dropped, and it was test-scoped and pinned to MongoDB 3.2.1 in any case.

Scope

Providing a database the application did not ask for means owning its lifecycle, so this covers starting it, stopping it, and every point in between where the process expects it to still be there:

  • Starting one, so a generated application runs with nothing installed.
  • Keeping it available across a CRaC checkpoint and restore, which is where an in-JVM server and a connected driver otherwise stop the process being snapshotted at all.

These are one change rather than two because they are the same commitment. An embedded MongoDB that a generated application cannot checkpoint is a database that works until the application does something ordinary with it, and the fix belongs in the module that started the server rather than in every application that uses one.

Approach

A new module, grails-data-mongodb-embedded, contributes an ApplicationContextInitializer that starts a server and publishes its url into whichever properties the application reads. It is an initializer rather than an auto configuration because the url has to be in the Environment before the datastore bean that reads it is created.

Two backends sit behind one interface:

in-memory (mongo-java-server) flapdoodle
Ships as api dependency of the module compileOnly; applications add it
Start milliseconds, in-JVM ~1s, downloads mongod once
Fidelity no transactions, change streams, $text real mongod
Persistence none database-dir

Flapdoodle is deliberately not a dependency of the module: it pulls in org.jgrapht:jgrapht-core, offered under LGPL-2.1 or EPL-2.0, which an Apache release should not require. An application that wants a real mongod adds it itself, the same way it picks a SQL driver. The published pom contains only spring-context, mongo-java-server and slf4j-api.

The target property is configuration (embedded.mongodb.property-names, defaulting to grails.mongodb.url) rather than hardcoded, so the module is not limited to Grails Data. Spring Data users remain better served by flapdoodle's own de.flapdoodle.embed.mongo.spring3x auto configuration, which this does not duplicate.

Surviving a checkpoint

CRaC refuses to checkpoint a process holding an open socket, and an application using this module holds several. The in-memory backend runs the server inside the JVM, so the image would have to contain its listening socket, its event loops and the server side of every connection; the driver adds one socket per pooled connection plus its server monitors. Every one of them is reported:

CheckpointOpenSocketException: Socket[addr=localhost/127.0.0.1,port=27025,localport=41396]
CheckpointOpenSocketException: sun.nio.ch.ServerSocketChannelImpl[]
CheckpointOpenSocketException: EPoll Event FD 75 left open in EPollSelectorImpl@... with registered keys

Both ends now take part in Spring's lifecycle, which stops beans before a checkpoint and starts them again after a restore. That needs no dependency on org.crac, and covers an ordinary shutdown too.

EmbeddedMongoLifecycle stops the server and binds it again afterwards, at a phase below the datastore's so it outlives the client that talks to it. MongoDatastore closes its MongoClient, which is what releases the sockets and stops the monitor threads — draining the pool does not, the monitors stay connected — and builds a replacement from the same factory and configuration on restore. A client the application supplied is left alone, since its lifecycle belongs to whoever created it.

One wrinkle is worth calling out for review. MongoServer.shutdownNow() closes its backend, and AbstractMongoBackend.close() clears every database. That is right for a server that is finished with, and wrong when the server is being stopped only to release its sockets, so the in-memory backend keeps its data across a restart. Without it the checkpoint succeeds and the restored process silently comes back with an empty database, which is a worse failure than not checkpointing.

Forge

GrailsDataMongoDB wires this in by default rather than offering it as a separate feature, since the starter not working was the problem. Development and test use the in-memory backend; production uses flapdoodle and keeps its database in ./prodDb, mirroring how the H2 database is wired for the Grails website application. No source is generated into the application — only the dependency and the environments: configuration.

Verifying

A generated MongoDB application, with no MongoDB installed and no Docker:

EmbeddedMongoInitializer : Embedded MongoDB started at mongodb://localhost:27017/foo using the in-memory backend
Application              : Started Application in 1.686 seconds
Grails application running at http://localhost:8080 in environment: development

GET / returns 200, a driver round-trips a document, and no mongod process exists — port 27017 is held by the application JVM.

The same application on Azul Zulu CRaC 25, in Docker:

warp: Checkpoint successful!
warp: Restore successful!
DefaultLifecycleProcessor : Spring-managed lifecycle restart completed (restored JVM running for 54 ms)

54ms to restore against 3.4s to start cold, and a document written before the checkpoint is read back after it. Checked against a run that was never checkpointed, since a restored process that comes back empty otherwise looks like a success.

The in-memory backend is also what makes this path testable without external infrastructure: the run above needs a CRaC JDK and a container to checkpoint in, but no MongoDB beyond the one inside the JVM.

EmbeddedMongoInitializerSpec covers both backends against real servers: a driver round-trip through each, backend selection and its error messages, publishing into several properties, reusing a server that is already listening (the devtools restart path), and the in-memory backend refusing database-dir rather than silently discarding data.

EmbeddedMongoLifecycleSpec drives the checkpoint cycle itself, since that is where the subtle failure lives: the in-memory server keeping its collections across a stop taken only to release its sockets, and a real mongod keeping a persistent database-dir across the same stop. It fails without the backend that declines to clear itself on shutdown, which is the bug described above. MongoDatastoreLifecycleSpec covers the other end — the client GORM owns closed on stop, rebuilt on start, and the replacement rather than the client it replaced released by close(), with an application-supplied client left alone throughout. A closed driver is told from an open one by the server selection it refuses, so none of this needs a MongoDB to observe.

Notes for review

  • SbomPlugin: mongo-java-server declares only "The BSD License", which CycloneDX maps to BSD-4-Clause. Its LICENSE has three numbered clauses and no advertising clause, so it is BSD-3-Clause. Mapped alongside the org.jline entries that need the identical correction.
  • publish-root-config.gradle: a module missing from publishedProjects fails configuration with Extension of type 'GrailsPublishExtension' does not exist, which does not point at the allowlist.
  • compileOnly 'org.apache.groovy:groovy' in the module: its main sources are Java so that a plain Spring Boot application can use it, but every published module still produces a groovydoc jar, and groovydoc cannot infer its classpath without Groovy present.
  • MongoDatastore is the one change outside the new module. mongo becomes volatile rather than final so a restore can replace it; every read other than construction already went through getMongoClient().
  • The in-memory backend reports buildInfo.version as 5.0.0, so development and test report 5.0 while production under flapdoodle reports 8.0. Worth documenting.
  • Three tests reach for the package-private EmbeddedMongoInitializer(List<EmbeddedMongoBackend>) constructor, because "this backend's library is not on the classpath" cannot be produced any other way when both jars are on the test classpath.
  • Two branches in EmbeddedMongoInitializer are left uncovered: a defensive null check that cannot be reached, and the port default, which could only be observed by binding 27017 and would collide with any locally installed MongoDB.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.98995% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.4386%. Comparing base (4bc5bd8) to head (8b8bea0).
⚠️ Report is 41 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...grails/datastore/mapping/mongo/MongoDatastore.java 90.0000% 0 Missing and 2 partials ⚠️
...orm/mongodb/embedded/EmbeddedMongoInitializer.java 98.0000% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16095        +/-   ##
==================================================
+ Coverage     52.3426%   52.4386%   +0.0960%     
- Complexity      18293      18371        +78     
==================================================
  Files            2036       2041         +5     
  Lines           96346      96572       +226     
  Branches        16831      16863        +32     
==================================================
+ Hits            50430      50641       +211     
- Misses          38492      38501         +9     
- Partials         7424       7430         +6     
Files with missing lines Coverage Δ
.../gorm/mongodb/embedded/EmbeddedMongoLifecycle.java 100.0000% <100.0000%> (ø)
...e/gorm/mongodb/embedded/EmbeddedMongoSettings.java 100.0000% <100.0000%> (ø)
.../gorm/mongodb/embedded/FlapdoodleMongoBackend.java 100.0000% <100.0000%> (ø)
...re/gorm/mongodb/embedded/InMemoryMongoBackend.java 100.0000% <100.0000%> (ø)
...grails/datastore/mapping/mongo/MongoDatastore.java 71.5812% <90.0000%> (+0.8223%) ⬆️
...orm/mongodb/embedded/EmbeddedMongoInitializer.java 98.0000% <98.0000%> (ø)

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A Forge application with MongoDB selected fails on startup unless a MongoDB
happens to be listening on the configured url, because nothing starts one.

Add grails-data-mongodb-embedded, an ApplicationContextInitializer that starts
a server before Grails Data connects and publishes its url into the properties
the application reads. It runs before the context refreshes because the url has
to exist before the datastore bean that reads it is created.

Two backends sit behind one interface. mongo-java-server reimplements the wire
protocol in Java, starts in milliseconds and downloads nothing, so it is the
default and ships with the module. Flapdoodle runs a real mongod, so it is the
only backend that supports transactions, change streams and persistence; it is
compileOnly here and added by applications that want it, because it pulls in
jgrapht under LGPL-2.1 or EPL-2.0 which an Apache release should not require.

Forge now wires this into the MongoDB feature by default, with the in-memory
backend for development and test and flapdoodle for production, where the
database is kept in ./prodDb. The generated application carries no source for
this, only configuration, in the same way the H2 database is wired.

mongo-java-server declares only "The BSD License", which CycloneDX maps to
BSD-4-Clause. Its LICENSE has three numbered clauses and no advertising clause,
so map it to BSD-3-Clause alongside the org.jline entries that need the same
correction.
@codeconsole
codeconsole force-pushed the feature/mongodb-embedded-8.0.x branch from 777785a to fff5f3e Compare August 5, 2026 00:31
…document

Production no longer enables the embedded MongoDB. The initializer publishes its
url ahead of every other property source, so an application deployed with
MONGO_HOST and MONGO_PORT set would have ignored them and quietly served an empty
local database. Development and test keep it; an application that wants one in
production enables it for that environment itself. This also removes flapdoodle
from generated applications, since only production used it.

Reuse is now limited to a server this JVM started, tracked by port. Probing the
socket treated any listener as a reusable MongoDB, so an unrelated service on the
port produced a url pointing at it and a misleading protocol error later. Anything
else holding the port now fails the start with an error naming the port. This
class ships in a jar and so survives a devtools restart in the base classloader,
which is what made tracking possible without the probe.

Document the module in the MongoDB manual: the two backends and their trade-offs,
every configuration property, restart and port behaviour, why production should
stay external, and use outside Grails.
Asking a backend whether it is available means holding one, and holding one means
loading its class -- which resolves the types named in its methods. So
constructing the flapdoodle backend without flapdoodle fails before it can answer
that it is unavailable.

Flapdoodle is deliberately not a dependency of this module, so that is the
ordinary case: the initializer could not be created at all, and an application
that wanted the in-memory server got

    Unable to instantiate factory class [EmbeddedMongoInitializer]
    Caused by: NoClassDefFoundError: de/flapdoodle/reverse/Transition

naming a library it never asked for. Setting the backend to in-memory did not help
either, because the constructor runs before any property is read -- so the
documented default could never start.

The question is now asked of the class loader instead, by name rather than by
type, and only a backend whose library is present is offered. Asking for
flapdoodle without it on the classpath still says that its library is missing
rather than that the name means nothing.
Ahead-of-time processing refreshes a context to read its bean definitions and
write them out as code. Nothing in that context is meant to run, so a database
no one will query is of no use to it -- and starting one does not merely waste
time, it does not finish: the server listens on a non-daemon thread and is
stopped only by a JVM shutdown hook, so processAot completed its work and then
hung holding port 27017 until it was killed.

Found on an application that enabled the embedded server outside development,
which an application training an AOT cache has to do: the training run runs in
production, and needs a database it can actually reach.

(cherry picked from commit e9e8a6444f7cda17df7e15490a9a6fc155160ff8)

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once you fix the codecov the plan is sensible and approved

CRaC refuses to checkpoint a process holding an open socket, and an
application using this module holds several. The in-memory backend runs
the server inside the JVM, so the image would have to contain its
listening socket, its event loops and the server side of every
connection; the driver adds one socket per pooled connection plus its
server monitors. Checkpointing failed with a CheckpointOpenSocketException
per socket, so an embedded MongoDB could not be used with CRaC at all.

Both ends now take part in Spring's lifecycle, which stops beans before a
checkpoint and starts them again after a restore. That needs no
dependency on org.crac, and covers an ordinary shutdown too.

EmbeddedMongoLifecycle stops the server and binds it again afterwards,
below the datastore's phase so it outlives the client that talks to it.
MongoDatastore closes its MongoClient, which is what releases the sockets
and stops the monitor threads -- draining the pool does not, the monitors
stay connected -- and builds a replacement from the same factory and
configuration on restore. A client the application supplied is left
alone, since its lifecycle belongs to whoever created it.

MongoServer.shutdownNow() closes its backend, and closing a backend
clears every database, so the in-memory backend now keeps its data across
a restart. The data lives on the heap and the checkpoint image preserves
it, so a restored process comes back with the collections it had rather
than an empty database.

Verified against a generated MongoDB application on Zulu CRaC 25:
checkpoint and restore both succeed, restore completes in 54ms against
3.4s to start cold, and a document written before the checkpoint is
readable after it.
@codeconsole codeconsole changed the title Start an embedded MongoDB so generated apps run without one installed Start and manage an embedded MongoDB so generated apps run without one installed Aug 7, 2026
RunningInMemoryMongo keeps the bound port rather than the InetSocketAddress
it came from, so java.net.InetSocketAddress is no longer referenced.
The stop and restart that CRaC drives had no test, and it is where the
subtle failure lives: a server stopped only to release its sockets must
come back with the data it had, or a restored process silently returns an
empty database.

EmbeddedMongoLifecycleSpec drives that cycle against both backends -- the
in-memory server keeping its collections on the heap, and a real mongod
keeping a persistent database-dir -- and fails without the backend that
declines to clear itself on shutdown. MongoDatastoreLifecycleSpec covers
the other end: the client GORM owns is closed on stop, rebuilt on start,
and the replacement rather than the client it replaced is what close()
has to release. A client the application supplied is left alone
throughout. A closed driver is told from an open one by the server
selection it refuses, so no MongoDB is needed to observe it.

The remaining additions are the configuration and failure paths that were
described but never exercised: a backend named without its library, no
backend at all, a version flapdoodle does not know, a database directory
that cannot be created, blank settings, and the port following
server.port.

Line coverage of the new module goes from 68% to 100%; the branches left
are one defensive null check and a port default that could only be
observed by binding 27017.
@testlens-app

testlens-app Bot commented Aug 8, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8b8bea0
▶️ Tests: 55453 executed
⚪️ Checks: 60/60 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants