This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-plugins.git
The following commit(s) were added to refs/heads/trunk by this push:
new db54dcfd8 Adding support of entitydef/entitygroup hot-reload to
devreload component (#328)
db54dcfd8 is described below
commit db54dcfd86b95faa7a99faf9875d21e2dc88d9c9
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sat Jul 11 22:53:57 2026 +0530
Adding support of entitydef/entitygroup hot-reload to devreload component
(#328)
Add support for another important entitydef/entitygroup hot-reload to
devreload, with opt-in schema sync.
---
devreload/README.md | 72 ++-
devreload/build.gradle | 24 +-
.../apache/ofbiz/devreload/DevReloadContainer.java | 152 +++++-
.../ofbiz/devreload/EntityModelReloader.java | 564 +++++++++++++++++++++
4 files changed, 783 insertions(+), 29 deletions(-)
diff --git a/devreload/README.md b/devreload/README.md
index 9df8d5243..1ff6fa815 100644
--- a/devreload/README.md
+++ b/devreload/README.md
@@ -1,8 +1,8 @@
# devreload
Development-only hot-reload for OFBiz. Edit a Java service/event method, a
-`services.xml` file, or add a brand-new method, and the change is live in
under a
-second — no restart, ever.
+`services.xml` file, an `entitydef` (entity/view-entity/entity-group) file, or
add a
+brand-new method, and the change is live in under a second — no restart, ever.
The plugin is entirely self-contained: dropping this directory into a checkout
(or
removing it) has zero effect on the rest of OFBiz either way.
@@ -38,7 +38,8 @@ else to set.
This is the only supported command — always run it exactly like this. It boots
OFBiz
and everything hot-swaps live, no restart: method-body edits, `services.xml`
changes,
-and structural changes (new/removed methods or fields, changed signatures)
alike.
+entitydef (entity/view-entity/entity-group) changes, and structural changes
+(new/removed methods or fields, changed signatures) alike.
`--no-watch-fs` disables Gradle's own file-system watching, which otherwise
competes
with this plugin's `WatchService` for the same macOS per-process
directory-watch
@@ -59,9 +60,9 @@ Scope to specific components for a faster startup:
# devreload — Dev Notes
`devreload` is a development-only OFBiz plugin that removes the restart step
-from the edit → test loop. Save a `.java` file or a `services.xml` file and
-the change is live in well under a second, in the already-running OFBiz
-process.
+from the edit → test loop. Save a `.java` file, a `services.xml` file, or an
+`entitydef` file and the change is live in well under a second, in the
+already-running OFBiz process.
It lives at `plugins/devreload` and is completely self-contained: dropping the
folder into a checkout adds the feature, deleting it removes the feature,
@@ -82,6 +83,7 @@ With `devreload` running (`./gradlew ofbizDev --no-watch-fs`):
```
edit .java → save → compiled automatically → live in < 1s → test
edit *services.xml → save → picked up automatically → live in < 1s → test
+edit entitydef/*.xml → save → picked up automatically → live in < 1s → test
```
No restart, no re-login, no second terminal.
@@ -92,7 +94,7 @@ No restart, no re-login, no second terminal.
| Question | Answer |
|---|---|
-| Who benefits? | Any developer actively writing/debugging OFBiz Java
services, event handlers, or `services.xml` files |
+| Who benefits? | Any developer actively writing/debugging OFBiz Java
services, event handlers, `services.xml` files, or entity/view-entity
definitions |
| Does it affect production? | No. It is completely inert unless started with
a special flag (`-Dofbiz.hotreload=true`). Safe to have installed anywhere |
| Does it change OFBiz core code? | No. The current design needs zero changes
to `ofbiz-framework` — it's a plugin only |
| What do I run? | One command: `./gradlew ofbizDev --no-watch-fs` |
@@ -102,14 +104,15 @@ No restart, no re-login, no second terminal.
## 3. How it works (technical overview)
-Four small classes do all the work:
+Five small classes do all the work:
| Class | Role |
|---|---|
| `DevReloadContainer` | Watches source/config directories, compiles changed
Java in-process, and triggers reload |
| `HotSwapAgent` | A tiny self-attaching Java agent that gives
`DevReloadContainer` access to `Instrumentation.redefineClasses` — the same
mechanism an IDE debugger uses for HotSwap |
-| `Debouncer<T>` | Coalesces rapid-fire change notifications (a burst of
file-system events, or one compile producing several `.class` files) into a
single batched action; shared by all three watch paths below |
+| `Debouncer<T>` | Coalesces rapid-fire change notifications (a burst of
file-system events, or one compile producing several `.class` files) into a
single batched action; shared by all four watch paths below |
| `RecordingFileManager` | Wraps the in-process compiler's file manager to
record exactly which `.class` files it wrote, so the reload step hot-swaps
precisely what was compiled instead of guessing from the source file's name |
+| `EntityModelReloader` | Reflectively resets the already-running
`ModelReader`/`ModelGroupReader` singletons so an `entitydef/*.xml` edit
re-parses on the next access, clears delegator data caches afterwards, and
(opt-in) creates any missing table/column the change needs |
At startup, `DevReloadContainer` self-attaches `HotSwapAgent` to the running
JVM (via the Attach API — no `-javaagent` flag needed). This grants access to
@@ -119,13 +122,14 @@ reference to it (including OFBiz's own internal caches)
automatically starts
running the new code on the very next call — no framework code needs to know
this plugin exists.
-### The three watch paths
+### The four watch paths
| Path | Watches | On change | Result |
|---|---|---|---|
| 1. Java source | Every component's `src/main/java` | Compiles the file
in-process, then hot-swaps the resulting class(es) | New method bodies live in
< 1s |
| 2. `services.xml` | Every component's `servicedef/` directory | Clears the
`service.ModelServiceMapByModel` cache | OFBiz re-reads service definitions on
the next call |
-| 3. Build output (opt-in) | `build/classes/java/main` | Same hot-swap as Path
1 | Picks up `./gradlew -t classes` run in a second terminal |
+| 3. `entitydef/*.xml` | Every component's `entitydef/` directory
(`entity-resource type="model"`/`"group"` only — not seed/demo data) |
Reflectively resets and eagerly rebuilds the affected
`ModelReader`/`ModelGroupReader` singleton(s), then clears delegator data
caches (see `EntityModelReloader`) | Entity/view-entity/entity-group definition
changes live in < 1s |
+| 4. Build output (opt-in) | `build/classes/java/main` | Same hot-swap as Path
1 | Picks up `./gradlew -t classes` run in a second terminal |
Changes are debounced for 300ms so a burst of related file writes (e.g. one
compile producing several `.class` files) is handled as a single batch.
@@ -149,6 +153,30 @@ class's redefinition genuinely can't be applied, only that
class is rejected (lo
by name); every other valid change saved in the same batch still applies. A
rejected
class stays "stuck" against that diff until OFBiz restarts.
+### Entity/view-entity/entity-group reload
+
+Unlike `services.xml` (clear a cache the service engine looks up by name on
every
+call), entity reload can't work that way: every `GenericDelegator` grabs a
direct
+object reference to a `ModelReader` at construction time and keeps it, so
clearing
+`ModelReader`'s own cache would only affect a brand-new delegator, not the one
+already serving traffic. `EntityModelReloader` instead reflectively nulls the
+already-running `ModelReader`/`ModelGroupReader` singleton's private
parsed-model
+field and eagerly rebuilds it in place — every delegator sharing a reader name
+(almost always `"main"`) picks up the change for free. See
+`ENTITY_HOTRELOAD_DESIGN.md` at the repo root for the full rationale,
including why
+this needed reflection and a latent framework edge case it works around.
+
+A broken save (invalid XML, a view-entity referencing a non-existent member
entity)
+is caught and logged clearly, and self-heals: the very next save (or any
access, at
+reparse cost) retries the parse instead of staying stuck until a restart.
+
+Optionally, `-Dofbiz.hotreload.autoUpdateSchema=true` (or
+`-Photreload.autoUpdateSchema=true`) also creates any missing table/column a
changed
+entity needs, via the same non-destructive `DatabaseUtil.checkDb(...,
addMissing=true)`
+call webtools' "Update Database" screen uses — it only ever adds, never alters
or
+drops. Off by default: it's the one thing in this plugin that writes to the
database
+automatically.
+
---
## 4. What does and doesn't hot-reload
@@ -158,7 +186,10 @@ class stays "stuck" against that diff until OFBiz restarts.
| Java method body changes | New OFBiz components (discovered at startup only)
|
| New services / parameter changes in `services.xml` | `*UiLabels.xml` (loaded
at startup) |
| New/removed methods, fields, changed signatures (DCEVM only) | `web.xml`,
`*.properties` files |
-| `controller.xml` (re-read every ~10s automatically — not tied to devreload
at all) | `entitymodel*.xml` changes |
+| `controller.xml` (re-read every ~10s automatically — not tied to devreload
at all) | A brand-new `entitydef/*.xml` file not yet declared via
`entity-resource` in `ofbiz-component.xml` (same restriction as new components
— see `ENTITY_HOTRELOAD_DESIGN.md` §8) |
+| `entitymodel*.xml` changes: new/edited entities, view-entities, fields,
relations, on already-declared files | `fieldtype*.xml` (DB-specific SQL type
mapping) |
+| `entitygroup*.xml` changes (entity → datasource-group mapping) | Destructive
schema changes: a changed field's SQL type, a removed field, a changed primary
key |
+| New/missing tables/columns for a changed entity, only with
`-Dofbiz.hotreload.autoUpdateSchema=true` (off by default) | Seed/demo data XML
(`entity-resource type="data"`/`"data-security"`) — a different concern, not
covered here |
| | Changed class hierarchy (unreliable — often works for static-only classes
with no instances, but isn't guaranteed) |
---
@@ -170,6 +201,7 @@ class stays "stuck" against that diff until OFBiz restarts.
| `./gradlew ofbizDev --no-watch-fs` | Start OFBiz with hot-reload enabled
(the only supported way to run it) |
| `-Photreload.components=compA,compB` | Only watch these components — useful
on a large checkout to stay under the OS's directory-watch limit |
| `-Photreload.watchBuildOutput=true` | Also watch Gradle's own build output
for externally-compiled classes |
+| `-Photreload.autoUpdateSchema=true` | Also create any missing table/column
an entitydef save needs (off by default — see "Entity/view-entity/entity-group
reload" above) |
| `-PdcevmHome=/path/to/jvm` or `DCEVM_HOME` env var | Points at the
DCEVM-patched JVM (not auto-detected, by design) |
`--no-watch-fs` disables Gradle's own file-watching, which otherwise competes
@@ -198,6 +230,7 @@ Everything `devreload` reads or writes, in one place.
| `-Djdk.attach.allowAttachSelf=true` | `ofbizDev` task (automatic) | unset |
Lets `HotSwapAgent` self-attach via the Attach API |
| `-Dofbiz.hotreload.components` | `-Photreload.components=compA,compB` |
unset (watch every component) | Comma-separated component names to scope
watching to, so the total watched-directory count fits under the OS's
per-process ceiling |
| `-Dofbiz.hotreload.watchBuildOutput` | `-Photreload.watchBuildOutput=true` |
`false` | Whether `build/classes/java/main` is also watched, to pick up
externally-produced `.class` files (e.g. `./gradlew -t classes` in a second
terminal) |
+| `-Dofbiz.hotreload.autoUpdateSchema` | `-Photreload.autoUpdateSchema=true` |
`false` | Whether an entitydef save also creates any missing table/column the
change needs, via `DatabaseUtil.checkDb(..., addMissing=true)` —
non-destructive (never alters/drops), but the one property in this plugin that
writes to the database automatically, so it's opt-in |
| `-Dofbiz.hotreload.outputDir` | `ofbizDev` task (automatic) |
`build/devreload/classes` | This plugin's own compiled-output directory.
Cleared and recreated on every start; placed ahead of Gradle's own output on
the runtime classpath |
| `-XX:+AllowEnhancedClassRedefinition` | `ofbizDev` task, only once a DCEVM
JVM is resolved | not set on a stock JVM | Lifts the stock-JVM restriction so
`redefineClasses` also accepts structural changes |
| `-PdcevmHome=/path/to/jvm` | manual, per invocation | unset | One-off
override pointing at a DCEVM-patched JVM's home directory |
@@ -212,8 +245,8 @@ Everything `devreload` reads or writes, in one place.
| Repo | Contains | Why |
|---|---|---|
-| `plugins/devreload` (a separate repo, dropped into a local checkout) |
`DevReloadContainer.java`, `HotSwapAgent.java`, `Debouncer.java`,
`RecordingFileManager.java`, `ofbiz-component.xml`, `build.gradle` (the
`ofbizDev` task), `README.md` — everything | OFBiz plugins are conventionally
distributed as separate repos; `ofbiz-framework`'s `.gitignore` excludes
`/plugins/` for exactly this reason |
-| `ofbiz-framework` | Nothing — no files, no diffs | The current design
redefines already-loaded `Class` objects in place, so no framework code ever
needs to ask "is a fresher class available." (An earlier prototype *did* need a
small reflective bridge into `framework/base` — see the historical entries in
the bug-fix table below for why that approach was replaced.) |
+| `plugins/devreload` (a separate repo, dropped into a local checkout) |
`DevReloadContainer.java`, `HotSwapAgent.java`, `Debouncer.java`,
`RecordingFileManager.java`, `EntityModelReloader.java`, `ofbiz-component.xml`,
`build.gradle` (the `ofbizDev` task), `README.md` — everything | OFBiz plugins
are conventionally distributed as separate repos; `ofbiz-framework`'s
`.gitignore` excludes `/plugins/` for exactly this reason |
+| `ofbiz-framework` | Nothing — no files, no diffs | The current design
redefines already-loaded `Class` objects in place, so no framework code ever
needs to ask "is a fresher class available." (An earlier prototype *did* need a
small reflective bridge into `framework/base` — see the historical entries in
the bug-fix table below for why that approach was replaced.) Entity/view-entity
reload takes a related but separate approach: it reflects into
`framework/entity`'s already-running `Mode [...]
To use it in a checkout: `git clone <devreload-repo-url> plugins/devreload`,
then run `./gradlew ofbizDev --no-watch-fs`. Deleting `plugins/devreload/` at
@@ -229,9 +262,13 @@ OFBiz.
| `Instrumentation`-based class redefinition instead of a custom classloader |
Mutates the existing `Class` object in place — no second classloader to track,
no cache invalidation, no framework code changes needed |
| Own output directory (`build/devreload/classes`), separate from Gradle's |
Writing into Gradle's managed output could confuse its incremental-build cache
and leave stale bytecode behind after a later `git checkout` |
| Overlay directory always wins over Gradle's output when both exist | Simple,
predictable rule; matches its position on the runtime classpath |
-| One shared `Debouncer` class for all three watch paths | Avoids three
hand-rolled copies of the same concurrency logic that could drift out of sync |
+| One shared `Debouncer` class for all four watch paths | Avoids four
hand-rolled copies of the same concurrency logic that could drift out of sync |
| Explicit `-PdcevmHome`/`DCEVM_HOME` only, no auto-detection | Guessing IDE
install paths breaks silently whenever a vendor changes packaging; one explicit
input is easier to keep working |
| Failed directory watch = log a warning and skip it | No hidden fallback
(like polling); a clear, actionable warning instead of silent degraded behavior
|
+| Reflectively reset `ModelReader`/`ModelGroupReader`'s own parsed-model field
in place, instead of clearing the `UtilCache` that hands them out | Every
`GenericDelegator` grabs a direct object reference to a `ModelReader` at
construction time and keeps it — clearing the lookup cache (the `services.xml`
trick) would only affect a brand-new delegator, never the one already serving
traffic |
+| A failed entity/group rebuild re-nulls the field again instead of leaving it
half-populated | `ModelReader.getEntityCache()` populates its map field
directly as it parses rather than swapping in a finished copy at the end, so a
mid-parse failure would otherwise leave a broken, partial model served forever
with no automatic retry; re-nulling makes the next save (or any access) retry
instead |
+| Every entitydef save reloads both the entity model and the group model,
regardless of which file actually changed | Both operations are cheap and a
no-op in effect when nothing of that kind changed; same trade this plugin
already makes elsewhere (see bug fix #10) in exchange for simpler bookkeeping |
+| Schema auto-update (`-Dofbiz.hotreload.autoUpdateSchema`) is opt-in, off by
default, and additive-only (`checkDb(..., addMissing=true)`) | It's the one
thing in this plugin that writes to the database automatically; non-destructive
by construction (same call webtools' "Update Database" uses), but still a
bigger blast radius than reloading in-memory definitions, so it stays explicit
rather than bundled into entitydef reload unconditionally |
---
@@ -252,6 +289,11 @@ OFBiz.
| Same class keeps reporting a structural-change warning even after a pure
method-body edit | Once a stock JVM rejects one structural change on a class,
that class stays "stuck" until restart — every subsequent diff against its
still-loaded old bytecode still includes the pending change | Expected JVM
`redefineClasses` behavior, not a bug; restart to clear it |
| Log: "batch redefinition rejected (...) -- retrying each class
individually", but only some classes in the save show "Hot-reload complete" |
One class in a debounced batch has a change the JVM can't apply; the others
were only rejected as part of the same all-or-nothing batch call | Expected
fallback behavior, not a bug — every class the JVM *can* apply still succeeds
individually; only the named, rejected class needs a restart |
| `<attribute>`/`<override>` schema warning (`cvc-complex-type.2.4.a`) repeats
on every reload cycle | `<attribute>` elements must come before `<override>`
per `services.xsd` | Reorder the elements in the edited `services.xml` |
+| Log: "Hot-reload: failed to reload entity model '...' -- fix the entitydef
XML and save again" | Invalid XML, or a view-entity referencing a non-existent
member entity | Fix the reported error and save again — self-heals
automatically, no restart needed even for a broken intermediate save. In the
meantime, only the specifically-broken entity/view is unavailable (confirmed
live) — every other already-working entity keeps responding normally |
+| An `entitydef` change isn't picked up at all | Either a brand-new file not
yet declared via `entity-resource` in `ofbiz-component.xml` (needs a restart —
same as a new component), or it's seed/demo data (`entity-resource
type="data"`/`"data-security"`), which this plugin doesn't watch | Restart for
a new resource declaration; seed data reload is a different, unaddressed
concern |
+| Log: "could not resolve ModelReader's private fields
(READERS/entityCache/modelName) via reflection" | This OFBiz version's
`ModelReader` implementation changed in a way `EntityModelReloader`'s
reflection didn't account for | Entity/view-entity hot-reload is disabled for
the session (Java and services.xml hot-reload are unaffected); file an issue
against `devreload` naming the OFBiz version |
+| New field/entity shows up in the reloaded model but queries against it fail
(missing column/table) | The DB schema itself wasn't updated — entitydef reload
only ever changes the in-memory model | Run webtools' "Update Database", or set
`-Dofbiz.hotreload.autoUpdateSchema=true` (`-Photreload.autoUpdateSchema=true`)
to have it happen automatically on every entitydef save |
+| Log: "schema auto-update failed for delegator '...'" | The datasource for
that group/helper isn't reachable, or `checkDb` itself hit a DB-specific error
| `checkDb` only ever adds — it's safe to fix the underlying DB issue and save
again |
---
diff --git a/devreload/build.gradle b/devreload/build.gradle
index 148fcb902..316e69b2b 100644
--- a/devreload/build.gradle
+++ b/devreload/build.gradle
@@ -40,6 +40,18 @@ static List<String> hotreloadWatchBuildOutputArgs(Project
project) {
return watchBuildOutput ?
["-Dofbiz.hotreload.watchBuildOutput=${watchBuildOutput}".toString()] : []
}
+// Optional -Photreload.autoUpdateSchema=true, forwarded as
+// -Dofbiz.hotreload.autoUpdateSchema to DevReloadContainer. Off by default:
unlike the
+// rest of entitydef reload (which only ever touches in-memory definitions),
this
+// creates missing tables/columns in the actual dev database on every
entitydef save --
+// non-destructively (see EntityModelReloader#syncMissingSchema), but still
the one
+// thing in this plugin that writes to the database automatically, so it needs
an
+// explicit opt-in rather than being bundled into entitydef reload
unconditionally.
+static List<String> hotreloadAutoUpdateSchemaArgs(Project project) {
+ Object autoUpdateSchema =
project.findProperty('hotreload.autoUpdateSchema')
+ return autoUpdateSchema ?
["-Dofbiz.hotreload.autoUpdateSchema=${autoUpdateSchema}".toString()] : []
+}
+
// DevReloadContainer's own in-process-compile output directory. Deliberately
separate
// from Gradle's own build/classes/java/main (see DevReloadContainer's
hotReloadOutputDir
// field javadoc for why) and placed ahead of the normal runtime classpath
below, so a
@@ -60,6 +72,7 @@ static List<String> commonHotReloadJvmArgs(Project project) {
"-Dofbiz.hotreload.outputDir=${hotReloadOutputDir(project).absolutePath}".toString()]
args += hotreloadComponentArgs(project)
args += hotreloadWatchBuildOutputArgs(project)
+ args += hotreloadAutoUpdateSchemaArgs(project)
return args
}
@@ -133,11 +146,12 @@ def resolveDcevmJavaExecutable = {
rootProject.tasks.register('ofbizDev', JavaExec) {
group = 'OFBiz Server Commands'
description = 'Start OFBiz with hot-reload (requires a DCEVM-patched JVM
-- see README). Java source, ' +
- 'services.xml, and structural changes (new/removed methods or
fields, changed signatures) all ' +
- 'reload live with no restart. Point -PdcevmHome=/path/to/jvm or
set DCEVM_HOME to enable; fails ' +
- 'fast with setup instructions if neither resolves. Optionally
scope to specific components with ' +
- '-Photreload.components=compA,compB, or watch
build/classes/java/main too (for externally-produced ' +
- '.class files) with -Photreload.watchBuildOutput=true.'
+ 'services.xml, entitydef, and structural changes (new/removed
methods or fields, changed ' +
+ 'signatures) all reload live with no restart. Point
-PdcevmHome=/path/to/jvm or set DCEVM_HOME to ' +
+ 'enable; fails fast with setup instructions if neither resolves.
Optionally scope to specific ' +
+ 'components with -Photreload.components=compA,compB, watch
build/classes/java/main too (for ' +
+ 'externally-produced .class files) with
-Photreload.watchBuildOutput=true, or auto-create missing ' +
+ 'tables/columns on an entitydef save with
-Photreload.autoUpdateSchema=true.'
classpath = rootProject.files(hotReloadOutputDir(project)) +
rootProject.sourceSets.main.runtimeClasspath
mainClass = rootProject.application.mainClass
List<String> jvmArgsList =
rootProject.application.applicationDefaultJvmArgs +
commonHotReloadJvmArgs(project)
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
index 9de78a691..8bcbb8cdc 100644
--- a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
@@ -65,8 +65,9 @@ import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.cache.UtilCache;
/**
- * Development-only container that watches Java sources/classes and {@code
services.xml}
- * files and applies changes to a running OFBiz instance without a restart.
+ * Development-only container that watches Java sources/classes, {@code
services.xml}
+ * files, and {@code entitydef} (entity/view-entity/entity-group) files, and
applies
+ * changes to a running OFBiz instance without a restart.
*
* <h2>Activation</h2>
* Add {@code -Dofbiz.hotreload=true -Djdk.attach.allowAttachSelf=true} to
your JVM
@@ -129,6 +130,18 @@ import org.apache.ofbiz.base.util.cache.UtilCache;
* {@code service.ModelServiceMapByModel} {@link UtilCache} entry is cleared
directly, so
* the new/edited definition is re-read on the next service call.
*
+ * <h2>How entitydef changes are handled</h2>
+ * Every component's {@code entitydef/} directory is watched too, but this
can't use the
+ * same "clear a cache looked up by name" trick as services.xml: every
+ * {@code GenericDelegator} grabs a direct object reference to a {@code
ModelReader} at
+ * construction time and keeps it, so clearing {@code ModelReader}'s own
lookup cache
+ * would only affect a brand-new delegator, never the one already serving
traffic. See
+ * {@link EntityModelReloader}'s class javadoc for the full mechanism: it
reflectively
+ * resets the already-running {@code ModelReader}/{@code ModelGroupReader}
singleton's
+ * parsed-model field and eagerly rebuilds it in place, then clears delegator
data
+ * caches. Optionally, with {@code -Dofbiz.hotreload.autoUpdateSchema=true},
it also
+ * creates any missing table/column a changed entity needs, non-destructively.
+ *
* <h2>Directory watch limits</h2>
* The OS may refuse to watch a directory once a process-wide ceiling is
reached (most
* commonly hit on macOS on a full checkout). This container does not try to
work around
@@ -166,6 +179,7 @@ public class DevReloadContainer implements Container {
private final Debouncer<String> classReloadDebouncer = new Debouncer<>(()
-> debounceExecutor, this::applyReload);
private final Debouncer<Path> xmlReloadDebouncer = new Debouncer<>(() ->
debounceExecutor, this::applyServiceXmlReload);
private final Debouncer<Path> compileDebouncer = new Debouncer<>(() ->
debounceExecutor, this::applyCompile);
+ private final Debouncer<Path> entitydefReloadDebouncer = new
Debouncer<>(() -> debounceExecutor, this::applyEntitydefReload);
// Counts across
registerServicedefDirs()/registerSourceDirs()/registerAll(), so
// start() can emit one aggregated warning instead of leaving individual
failures
@@ -194,6 +208,20 @@ public class DevReloadContainer implements Container {
private final Set<Path> servicedefDirs = new HashSet<>();
private final Set<Path> sourceRootDirs = new HashSet<>();
+ /**
+ * Directories containing an {@code entity-resource type="model"} or
+ * {@code type="group"} file (an {@code entitydef/entitymodel*.xml} or
+ * {@code entitygroup*.xml}). Deliberately not split into two separate
sets keyed by
+ * resource type: a save anywhere in one of these directories triggers both
+ * {@link EntityModelReloader#resetAndRebuildEntityModels()} and
+ * {@link EntityModelReloader#resetAndRebuildGroupModels()} (see
+ * {@code applyEntitydefReload}), same as this container already accepts
some
+ * harmless redundant work elsewhere (e.g. a WatchService event storm
re-triggering
+ * unrelated classes) in exchange for simpler bookkeeping -- both reload
calls are
+ * cheap and a no-op in effect when nothing of that kind actually changed.
+ */
+ private final Set<Path> entitydefDirs = new HashSet<>();
+
/**
* Component names to watch, from {@code -Dofbiz.hotreload.components};
{@code null}
* means watch every component. Set this property to a comma-separated
list of
@@ -219,6 +247,18 @@ public class DevReloadContainer implements Container {
*/
private boolean watchBuildOutput;
+ /**
+ * Whether an {@code entitydef} reload should also create any missing
tables/columns
+ * the new/changed entities need, from {@code
-Dofbiz.hotreload.autoUpdateSchema};
+ * defaults to {@code false}. Off by default because this is the one thing
in this
+ * plugin that writes to the database automatically -- non-destructively
(only
+ * creates missing tables/columns, exactly like webtools' "Update
Database" screen;
+ * see {@link EntityModelReloader#syncMissingSchema}), but still a
meaningfully
+ * bigger blast radius than reloading in-memory definitions, so it needs
an explicit
+ * opt-in rather than being bundled into entitydef reload unconditionally.
+ */
+ private boolean autoUpdateSchema;
+
@Override
public void init(List<StartupCommand> ofbizCommands, String name, String
configFile) throws ContainerException {
this.name = name;
@@ -245,8 +285,9 @@ public class DevReloadContainer implements Container {
}
/**
- * Reads {@code -Dofbiz.hotreload.components} and {@code
-Dofbiz.hotreload.watchBuildOutput},
- * populating {@link #allowedComponents} and {@link #watchBuildOutput}.
+ * Reads {@code -Dofbiz.hotreload.components}, {@code
-Dofbiz.hotreload.watchBuildOutput},
+ * and {@code -Dofbiz.hotreload.autoUpdateSchema}, populating {@link
#allowedComponents},
+ * {@link #watchBuildOutput}, and {@link #autoUpdateSchema}.
*/
private void parseHotReloadProperties() {
String componentsProperty =
System.getProperty("ofbiz.hotreload.components");
@@ -260,6 +301,13 @@ public class DevReloadContainer implements Container {
}
watchBuildOutput =
"true".equalsIgnoreCase(System.getProperty("ofbiz.hotreload.watchBuildOutput"));
+
+ autoUpdateSchema =
"true".equalsIgnoreCase(System.getProperty("ofbiz.hotreload.autoUpdateSchema"));
+ if (autoUpdateSchema) {
+ Debug.logInfo("Hot-reload: schema auto-update enabled (set via
-Dofbiz.hotreload.autoUpdateSchema) -- "
+ + "an entitydef save will create any missing table/column
it needs. This never alters or "
+ + "drops anything that already exists.", MODULE);
+ }
}
/**
@@ -391,6 +439,7 @@ public class DevReloadContainer implements Container {
return true; // disabled
}
registerServicedefDirs();
+ registerEntitydefDirs();
registerSourceDirs();
if (watchDirsFailed > 0) {
Debug.logWarning("Hot-reload: " + watchDirsFailed + " of " +
watchDirsAttempted + " directory watch "
@@ -401,7 +450,8 @@ public class DevReloadContainer implements Container {
watchThread = new Thread(this::watchLoop, "ofbiz-hot-reload-watcher");
watchThread.setDaemon(true);
watchThread.start();
- Debug.logInfo("DevReloadContainer started. Edit any Java or
services.xml file and changes go live without a restart.", MODULE);
+ Debug.logInfo("DevReloadContainer started. Edit any Java,
services.xml, or entitydef file and changes go "
+ + "live without a restart.", MODULE);
return true;
}
@@ -437,6 +487,50 @@ public class DevReloadContainer implements Container {
}
}
+ /**
+ * Registers every directory that contains a component entity-definition
XML file
+ * ({@code entity-resource type="model"}, i.e. {@code entitymodel*.xml}, or
+ * {@code type="group"}, i.e. {@code entitygroup*.xml}) with the
WatchService so that
+ * edits to those files are detected. Called once from {@link #start()},
before the
+ * watch thread launches. Deliberately does <em>not</em> watch {@code
type="data"}/
+ * {@code "data-security"}/etc. resources that also live under a
component's
+ * {@code entitydef/} directory -- those are seed/demo data, a different
concern
+ * from schema, and out of scope here (see the design notes at
+ * {@code ENTITY_HOTRELOAD_DESIGN.md}).
+ */
+ private void registerEntitydefDirs() {
+ registerEntitydefResourceDirs("model");
+ registerEntitydefResourceDirs("group");
+ }
+
+ /** Shared by {@link #registerEntitydefDirs()} for both the "model" and
"group" resource types. */
+ private void registerEntitydefResourceDirs(String type) {
+ for (ComponentConfig.EntityResourceInfo eri :
ComponentConfig.getAllEntityResourceInfos(type)) {
+ if
(!isAllowedComponent(eri.getComponentConfig().getComponentName())) {
+ continue;
+ }
+ try {
+ URL url = eri.createResourceHandler().getURL();
+ if (!"file".equals(url.getProtocol())) {
+ continue; // skip non-filesystem resources (classpath
jars, etc.)
+ }
+ Path dir = Paths.get(new URI(url.toString())).getParent();
+ if (dir != null && Files.isDirectory(dir) &&
entitydefDirs.add(dir) && registerWatch(dir)) {
+ Debug.logInfo("Hot-reload: watching entitydef (" + type +
") directory " + dir, MODULE);
+ }
+ } catch (GenericConfigException | URISyntaxException e) {
+ Debug.logWarning("Hot-reload: could not register entitydef dir
for "
+ + eri.getLocation() + ": " + e.getMessage(), MODULE);
+ } catch (Throwable t) {
+ // Defensive: a single component's entitydef registration must
not be able
+ // to abort the loop and leave every subsequent component's
entitydef
+ // directory unwatched.
+ Debug.logError(t, "Hot-reload: unexpected error registering
entitydef dir for "
+ + eri.getLocation(), MODULE);
+ }
+ }
+ }
+
/**
* Registers every component's {@code src/main/java} directory with the
WatchService
* so that saving a {@code .java} file triggers in-process compilation via
@@ -549,10 +643,10 @@ public class DevReloadContainer implements Container {
/**
* Routes a single created/modified regular file through the correct
reload pipeline --
* a {@code .class} file under {@link #classesDir}, a {@code .xml} file
under a
- * registered servicedef directory, or a {@code .java} file under a
registered source
- * root. Shared between {@link #watchLoop}'s live WatchService events and
- * {@link #registerAllAndSeed}'s seeding of files that already existed
when a new
- * directory was first discovered.
+ * registered servicedef directory, a {@code .xml} file under a registered
entitydef
+ * directory, or a {@code .java} file under a registered source root.
Shared between
+ * {@link #watchLoop}'s live WatchService events and {@link
#registerAllAndSeed}'s
+ * seeding of files that already existed when a new directory was first
discovered.
*/
private void dispatchChangedFile(Path dir, Path changed) {
String name = changed.toString();
@@ -560,6 +654,8 @@ public class DevReloadContainer implements Container {
reloadClassFile(classesDir, changed);
} else if (name.endsWith(".xml") &&
servicedefDirs.stream().anyMatch(dir::startsWith)) {
xmlReloadDebouncer.add(changed);
+ } else if (name.endsWith(".xml") &&
entitydefDirs.stream().anyMatch(dir::startsWith)) {
+ entitydefReloadDebouncer.add(changed);
} else if (name.endsWith(".java") &&
sourceRootDirs.stream().anyMatch(dir::startsWith)) {
compileDebouncer.add(changed);
}
@@ -716,6 +812,44 @@ public class DevReloadContainer implements Container {
.anyMatch(arg ->
arg.contains("AllowEnhancedClassRedefinition"));
}
+ /**
+ * Reloads entity/view-entity and entity-group definitions after an
+ * {@code entitydef/*.xml} save. Unlike {@link #applyServiceXmlReload},
this can't
+ * just clear a {@code UtilCache} keyed by name -- see {@link
EntityModelReloader}'s
+ * class javadoc for why entity definitions need to be reset on the
already-running
+ * {@code ModelReader}/{@code ModelGroupReader} singletons directly
instead.
+ *
+ * <p>Both {@link EntityModelReloader#resetAndRebuildEntityModels()} and
+ * {@link EntityModelReloader#resetAndRebuildGroupModels()} run on every
entitydef
+ * save regardless of which specific file changed (see {@link
#entitydefDirs}'s
+ * javadoc for why that's an acceptable simplification). Delegator data
caches are
+ * only cleared if at least one of the two actually succeeded -- clearing
them after
+ * a fully-failed reload would just discard still-good cached data for no
benefit.
+ *
+ * <p>If {@link #autoUpdateSchema} is on, a successful entity-model
rebuild is also
+ * followed by {@link EntityModelReloader#syncMissingSchema}, which
creates any
+ * missing table/column the entities defined in {@code batch} need.
Skipped entirely
+ * when {@code entityOk} is {@code false}: with a broken rebuild, there's
no reliable
+ * new/changed model to check the database against yet.
+ */
+ private void applyEntitydefReload(Set<Path> batch) {
+ Debug.logInfo("Hot-reload: entitydef changed " + batch, MODULE);
+ boolean entityOk = EntityModelReloader.resetAndRebuildEntityModels();
+ boolean groupOk = EntityModelReloader.resetAndRebuildGroupModels();
+ if (entityOk || groupOk) {
+ EntityModelReloader.clearAllDelegatorCaches();
+ }
+ if (entityOk && autoUpdateSchema) {
+ EntityModelReloader.syncMissingSchema(batch);
+ }
+ if (entityOk && groupOk) {
+ Debug.logInfo("Hot-reload: entitydef reload complete for " +
batch, MODULE);
+ } else {
+ Debug.logWarning("Hot-reload: entitydef reload for " + batch + "
completed with errors -- see the "
+ + "Hot-reload log lines above for which reader failed.",
MODULE);
+ }
+ }
+
private void applyServiceXmlReload(Set<Path> batch) {
Debug.logInfo("Hot-reload: service XML changed " + batch + " —
clearing service model cache", MODULE);
try {
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
new file mode 100644
index 000000000..b1d60f018
--- /dev/null
+++
b/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
@@ -0,0 +1,564 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.devreload;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.function.BiConsumer;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.cache.UtilCache;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.DelegatorFactory;
+import org.apache.ofbiz.entity.config.model.DelegatorElement;
+import org.apache.ofbiz.entity.config.model.EntityConfig;
+import org.apache.ofbiz.entity.datasource.GenericHelperInfo;
+import org.apache.ofbiz.entity.jdbc.DatabaseUtil;
+import org.apache.ofbiz.entity.model.ModelEntity;
+import org.apache.ofbiz.entity.model.ModelGroupReader;
+import org.apache.ofbiz.entity.model.ModelReader;
+
+/**
+ * Forces already-running {@link ModelReader}/{@link ModelGroupReader}
singletons to
+ * forget their parsed entity model/group mapping and re-read it from disk, so
edits to
+ * an {@code entitydef/*.xml} file take effect in the already-running JVM
without a
+ * restart.
+ *
+ * <h2>Why this needs reflection</h2>
+ * Unlike {@code services.xml} reload (which just clears a {@code UtilCache}
that the
+ * service engine looks up <em>by name on every call</em>), entity reload
can't work
+ * that way: every {@code GenericDelegator} grabs a direct object reference to
a
+ * {@link ModelReader} at construction time and keeps it
+ * (see {@code GenericDelegator.modelReader}), and {@code ModelReader} itself
has no
+ * public API to make it forget its parsed {@code entityCache} once built —
it's a
+ * {@code private volatile} field populated once via double-checked locking
and never
+ * reset. Clearing the {@code ModelReader.READERS} cache (the {@code
services.xml}
+ * trick) would therefore only affect a brand-new delegator; every delegator
already
+ * serving traffic would keep using its already-fetched, stale {@code
ModelReader}
+ * forever. {@link ModelGroupReader} (entitygroup.xml → datasource-group
mapping) has
+ * the exact same shape: a static {@code READERS} cache plus a private,
build-once
+ * {@code groupCache} field.
+ *
+ * <p>The only way to make the model already in use forget itself, without
changing
+ * {@code framework/entity}, is to reflectively null out that private field on
the
+ * singleton(s) already sitting in each class's {@code READERS} cache — every
delegator
+ * sharing a reader name (almost always {@code "main"}) picks up the change
for free,
+ * since they share the same object. The underlying XML itself needs no
separate cache
+ * invalidation: {@code MainResourceHandler.getDocument()} re-reads its
+ * {@code InputStream} fresh on every call.
+ *
+ * <p>All reflective {@link Field} handles are resolved once, the first time
they're
+ * needed, and cached: a resolution failure (e.g. a future OFBiz version
renaming one
+ * of these private fields) is logged once with the exact class/field name and
+ * disables that half (entity or group) of the reload for the rest of the
session,
+ * rather than failing on every save or crashing the container.
+ */
+final class EntityModelReloader {
+
+ private static final String MODULE = EntityModelReloader.class.getName();
+
+ private static volatile boolean resolutionAttempted;
+
+ private static volatile boolean entityReloadAvailable;
+ private static Field modelReaderReadersField;
+ private static Field modelReaderEntityCacheField;
+ private static Field modelReaderModelNameField;
+
+ private static volatile boolean groupReloadAvailable;
+ private static Field modelGroupReaderReadersField;
+ private static Field modelGroupReaderGroupCacheField;
+ private static Field modelGroupReaderModelNameField;
+
+ private static volatile boolean delegatorCacheClearAvailable;
+ private static Field delegatorFactoryDelegatorsField;
+
+ private EntityModelReloader() { }
+
+ /**
+ * Reflectively resets every currently-cached {@link ModelReader}'s parsed
entity
+ * model and eagerly rebuilds it in place (rather than leaving the rebuild
for the
+ * next unrelated request to trigger lazily), so a broken {@code
entitydef} save is
+ * caught and logged right here instead of surfacing as a random failure
later.
+ *
+ * <p>{@code ModelReader.getEntityCache()} populates its map field
directly as it
+ * parses, rather than building into a local variable and swapping it in
at the
+ * end. That means a parse failure partway through (bad XML, a view-entity
+ * referencing a non-existent member entity, etc.) would normally leave
the field
+ * non-null but incomplete — and since the reader's own
double-checked-locking guard
+ * only rebuilds when the field is {@code null}, that broken, partial
model would
+ * otherwise be served forever, even after the mistake is fixed. Because
this class
+ * controls the field directly, a failed rebuild re-nulls it before
returning, so
+ * the very next save (or even the next unrelated access, at reparse cost)
retries
+ * the parse instead of staying poisoned until a restart.
+ *
+ * @return {@code true} if every cached {@link ModelReader} rebuilt
cleanly.
+ */
+ static boolean resetAndRebuildEntityModels() {
+ ensureFieldsResolved();
+ if (!entityReloadAvailable) {
+ return false;
+ }
+
+ UtilCache<String, ModelReader> readers;
+ try {
+ readers = castCache(modelReaderReadersField.get(null));
+ } catch (ReflectiveOperationException | ClassCastException e) {
+ Debug.logError(e, "Hot-reload: could not read ModelReader.READERS;
entity model reload is disabled "
+ + "for this session.", MODULE);
+ entityReloadAvailable = false;
+ return false;
+ }
+
+ boolean allSucceeded = true;
+ for (ModelReader reader : readers.values()) {
+ allSucceeded &= rebuildOneEntityReader(reader);
+ }
+ return allSucceeded;
+ }
+
+ /**
+ * Reflectively resets every currently-cached {@link ModelGroupReader}'s
parsed
+ * entity-group mapping and eagerly rebuilds it in place. Same rationale
and
+ * self-healing behavior as {@link #resetAndRebuildEntityModels()}; kept
as a
+ * separate entry point (and a separate availability flag) so a reflection
failure
+ * against one class never disables the other.
+ *
+ * @return {@code true} if every cached {@link ModelGroupReader} rebuilt
cleanly.
+ */
+ static boolean resetAndRebuildGroupModels() {
+ ensureFieldsResolved();
+ if (!groupReloadAvailable) {
+ return false;
+ }
+
+ UtilCache<String, ModelGroupReader> readers;
+ try {
+ readers = castCache(modelGroupReaderReadersField.get(null));
+ } catch (ReflectiveOperationException | ClassCastException e) {
+ Debug.logError(e, "Hot-reload: could not read
ModelGroupReader.READERS; entitygroup.xml reload is "
+ + "disabled for this session.", MODULE);
+ groupReloadAvailable = false;
+ return false;
+ }
+
+ boolean allSucceeded = true;
+ for (ModelGroupReader reader : readers.values()) {
+ allSucceeded &= rebuildOneGroupReader(reader);
+ }
+ return allSucceeded;
+ }
+
+ /**
+ * Clears every already-created {@link Delegator}'s data caches,
local-only (no
+ * distributed-cache-clear broadcast — this is a single-process dev loop,
and
+ * distributing would mean depending on {@code DistributedCacheClear} being
+ * configured/reachable at all, which is beside the point here).
+ *
+ * <p>Needed because a {@link org.apache.ofbiz.entity.GenericEntity}
caches its
+ * {@code ModelEntity} reference in a transient field the first time it's
asked
+ * (see {@code GenericEntity.getModelEntity()}) and never re-asks after
that. A
+ * long-lived cached {@code GenericValue} created before this reload would
otherwise
+ * keep pointing at a stale field/relation set forever. Called after every
successful
+ * entity-model rebuild, unscoped (every delegator, every entity) rather
than
+ * diffing which entities actually changed — simpler, and this only runs
on a dev
+ * save, never a hot request path.
+ *
+ * <p>{@link DelegatorFactory} keeps every delegator it has ever created
in a
+ * private static map, keyed by name, as a {@code Future} (each delegator
is built
+ * asynchronously). Reflection is needed to enumerate that map at all; only
+ * {@link Future#isDone()} entries are touched, so this never blocks on, or
+ * accidentally triggers, a delegator that's still starting up.
+ */
+ static void clearAllDelegatorCaches() {
+ forEachLiveDelegator((name, delegator) -> {
+ delegator.clearAllCaches(false);
+ Debug.logInfo("Hot-reload: cleared data caches for delegator '" +
name + "'", MODULE);
+ });
+ }
+
+ /**
+ * Opt-in schema auto-sync (design doc "Part B"): for every
entity/view-entity whose
+ * definition file is in {@code changedFiles}, create any table/column it
needs that
+ * doesn't exist yet. Off unless {@code DevReloadContainer} calls this at
all (gated
+ * there by {@code -Dofbiz.hotreload.autoUpdateSchema=true}) -- creating
tables/
+ * columns is a meaningfully bigger blast radius than anything else this
plugin does
+ * automatically, so it stays opt-in rather than always-on like the rest
of entitydef
+ * reload.
+ *
+ * <p>Uses only public {@code framework/entity} API, no reflection: {@code
+ * DatabaseUtil(GenericHelperInfo).checkDb(Map, List, addMissing=true)} is
the exact
+ * call {@code EntityDataServices}/webtools' "Update Database" screen use,
and per
+ * its own implementation only ever adds -- it never drops, renames, or
alters the
+ * type of anything that already exists. Anything beyond that (a changed
field's SQL
+ * type, a removed field, a changed primary key) still requires that same
manual
+ * flow, on purpose.
+ */
+ static void syncMissingSchema(Set<Path> changedFiles) {
+ Set<String> changedEntityNames =
resolveChangedEntityNames(changedFiles);
+ if (changedEntityNames.isEmpty()) {
+ return;
+ }
+ forEachLiveDelegator((name, delegator) ->
syncMissingSchemaForDelegator(name, delegator, changedEntityNames));
+ }
+
+ /**
+ * Resolves {@code changedFiles} to the entity/view-entity names actually
defined in
+ * them, so schema sync only ever touches what changed in this save -- not
the whole
+ * data model, which is what keeps it fast enough to run on every save
instead of
+ * only at startup. {@code ModelEntity.getLocation()} is set fresh on
every entity
+ * object every time {@code ModelReader.getEntityCache()} runs (see {@code
+ * buildEntity()} in {@code framework/entity}), so comparing it against
the changed
+ * paths is reliable even across repeated reloads within the same session
-- unlike
+ * {@code ModelReader.getResourceHandlerEntities()}, whose backing
collections are
+ * never cleared between reloads and would otherwise need extra
bookkeeping here to
+ * stay accurate.
+ */
+ private static Set<String> resolveChangedEntityNames(Set<Path>
changedFiles) {
+ ensureFieldsResolved();
+ if (!entityReloadAvailable) {
+ return Set.of();
+ }
+ Set<String> entityNames = new HashSet<>();
+ try {
+ UtilCache<String, ModelReader> readers =
castCache(modelReaderReadersField.get(null));
+ for (ModelReader reader : readers.values()) {
+ for (Map.Entry<String, ModelEntity> entry :
reader.getEntityCache().entrySet()) {
+ if (locationMatches(entry.getValue().getLocation(),
changedFiles)) {
+ entityNames.add(entry.getKey());
+ }
+ }
+ }
+ } catch (Exception e) {
+ Debug.logError(e, "Hot-reload: could not determine which entities
changed for schema auto-update",
+ MODULE);
+ }
+ return entityNames;
+ }
+
+ private static boolean locationMatches(String location, Set<Path>
changedFiles) {
+ if (location == null) {
+ return false;
+ }
+ try {
+ return changedFiles.contains(Paths.get(new URI(location)));
+ } catch (URISyntaxException | IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Resolves the datasource group(s) {@code changedEntityNames} belong to on
+ * {@code delegator} -- resolved per delegator, not shared the way the
+ * {@code ModelEntity} objects themselves are, since a delegator's
group→datasource
+ * mapping (and, for a multi-tenant setup, which physical database that
resolves to)
+ * is delegator-specific -- then runs one non-destructive {@code checkDb}
per group.
+ *
+ * <p>Each {@code checkDb} call is passed the <em>entire</em> group's
entities via
+ * {@link Delegator#getModelEntityMapByGroup}, not just {@code
changedEntityNames}.
+ * This was originally scoped to just the changed entities to keep each
call cheap,
+ * but live testing surfaced why that's wrong: {@code checkDb} treats any
DB table
+ * without a matching entry in the map it's given as orphaned and logs a
warning for
+ * it (both via its own internal {@code Debug.logWarning} and via the
{@code messages}
+ * list) -- passing a handful of changed entities out of an entire
datasource's
+ * worth of tables made every other real, legitimate table in that
datasource look
+ * orphaned, flooding the log on every single save. Passing the full group
avoids
+ * that false-positive path entirely, matches how {@code checkDb} is used
everywhere
+ * else in the framework, and is still far cheaper than checking the whole
+ * multi-datasource data model on every save, since only the group(s) the
changed
+ * entities actually belong to are checked.
+ */
+ private static void syncMissingSchemaForDelegator(String delegatorName,
Delegator delegator,
+ Set<String> changedEntityNames) {
+ Set<String> affectedGroups = new HashSet<>();
+ for (String entityName : changedEntityNames) {
+ try {
+ String groupName =
delegator.getModelGroupReader().getEntityGroupName(entityName,
+ delegator.getDelegatorBaseName());
+ if (groupName != null) {
+ affectedGroups.add(groupName);
+ }
+ } catch (Exception e) {
+ Debug.logWarning("Hot-reload: could not resolve a datasource
group for entity '" + entityName
+ + "' on delegator '" + delegatorName + "' -- skipping
its schema auto-update: "
+ + e.getMessage(), MODULE);
+ }
+ }
+
+ for (String groupName : affectedGroups) {
+ try {
+ GenericHelperInfo helperInfo =
delegator.getGroupHelperInfo(groupName);
+ if (helperInfo == null) {
+ continue;
+ }
+ Map<String, ModelEntity> groupEntities =
delegator.getModelEntityMapByGroup(groupName);
+ List<String> messages = new ArrayList<>();
+ new DatabaseUtil(helperInfo).checkDb(groupEntities, messages,
true);
+ for (String message : messages) {
+ if (isNoiseMessage(message)) {
+ continue;
+ }
+ Debug.logInfo("Hot-reload: [schema sync " + delegatorName
+ "/" + groupName + "] "
+ + message, MODULE);
+ }
+ } catch (Throwable t) {
+ Debug.logError(t, "Hot-reload: schema auto-update failed for
delegator '" + delegatorName
+ + "', group '" + groupName + "'", MODULE);
+ }
+ }
+ }
+
+ /**
+ * Filters out the two {@code checkDb} message shapes that are routine
narration,
+ * not something a save-time schema sync should surface, found empirically
while
+ * validating this feature against a real group with over a thousand
entities
+ * (framework's own {@code "org.apache.ofbiz"} group):
+ * <ul>
+ * <li>{@code "(Xms) Checking #N/M Entity NAME with table TABLE"} / the
+ * {@code "NOT Checking"} variant for views and never-check entities
--
+ * {@code checkDb} logs one of these per entity in the map it's given
+ * ({@code Debug.logVerbose} on the framework side, deliberately not
surfaced
+ * here at {@code INFO} for every entity in the whole group on every
save).</li>
+ * <li>{@code "... has no corresponding entity"} -- would only ever fire
here if a
+ * table genuinely has no entity anywhere in the group ({@link
+ * #syncMissingSchemaForDelegator} always passes the full group's
entities, so
+ * this isn't the false-positive scoping artifact an earlier,
narrower version
+ * of this method produced; see that method's javadoc). Still
filtered:
+ * {@code addMissing} never acts on it (it only adds, never drops),
and an
+ * automatic save-time reload repeating the same unaddressable
warning on every
+ * future save is just noise -- a deliberate "Update Database" run
in webtools
+ * is the place to review genuinely-orphaned tables.</li>
+ * </ul>
+ * Every other {@code checkDb} message (created/added, could-not-add,
size/type
+ * mismatches, FK/index messages) passes through, so a future message
shape this
+ * class hasn't seen before is surfaced by default rather than silently
dropped.
+ */
+ private static boolean isNoiseMessage(String message) {
+ return message.contains("Checking #") || message.contains("has no
corresponding entity");
+ }
+
+ /**
+ * Enumerates every already-created {@link Delegator} (see {@link
+ * #clearAllDelegatorCaches()}'s original javadoc for how/why) and invokes
+ * {@code action} for each one that's finished starting up. Shared by
{@link
+ * #clearAllDelegatorCaches()} and {@link #syncMissingSchema(Set)} so the
+ * reflection/iteration/error-handling boilerplate exists in exactly one
place.
+ *
+ * <p>{@link DelegatorFactory} keeps every delegator it has ever created
in a
+ * private static map, keyed by name, as a {@code Future} (each delegator
is built
+ * asynchronously). Reflection is needed to enumerate that map at all; only
+ * {@link Future#isDone()} entries are touched, so this never blocks on, or
+ * accidentally triggers, a delegator that's still starting up.
+ */
+ private static void forEachLiveDelegator(BiConsumer<String, Delegator>
action) {
+ ensureFieldsResolved();
+ if (!delegatorCacheClearAvailable) {
+ return;
+ }
+
+ Map<String, Future<Delegator>> delegators;
+ try {
+ delegators = castMap(delegatorFactoryDelegatorsField.get(null));
+ } catch (ReflectiveOperationException | ClassCastException e) {
+ Debug.logError(e, "Hot-reload: could not read
DelegatorFactory.DELEGATORS; entity data-cache "
+ + "invalidation/schema auto-update is disabled for this
session (definitions still reload).",
+ MODULE);
+ delegatorCacheClearAvailable = false;
+ return;
+ }
+
+ for (Map.Entry<String, Future<Delegator>> entry :
delegators.entrySet()) {
+ if (!entry.getValue().isDone()) {
+ continue; // still starting up -- nothing to act on yet
+ }
+ try {
+ action.accept(entry.getKey(), entry.getValue().get());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ Debug.logError(e, "Hot-reload: delegator action failed for '"
+ entry.getKey() + "'", MODULE);
+ }
+ }
+ }
+
+ /** Nulls {@code reader}'s {@code entityCache} field, then eagerly
rebuilds it, self-healing on failure. */
+ private static boolean rebuildOneEntityReader(ModelReader reader) {
+ String readerName = readField(modelReaderModelNameField, reader);
+ long start = System.currentTimeMillis();
+ try {
+ modelReaderEntityCacheField.set(reader, null);
+ int entityCount = reader.getEntityCache().size();
+ Debug.logInfo("Hot-reload: entity model '" + readerName + "'
reloaded (" + entityCount
+ + " entities/view-entities) in " +
(System.currentTimeMillis() - start) + "ms", MODULE);
+ return true;
+ } catch (Throwable t) {
+ // Re-null rather than leaving a partially-populated map in place
-- see this
+ // class's javadoc for why a half-built entityCache would
otherwise be served
+ // forever instead of retried on the next save.
+ reNullQuietly(modelReaderEntityCacheField, reader, "entityCache",
readerName);
+ Debug.logError(t, "Hot-reload: failed to reload entity model '" +
readerName
+ + "' -- fix the entitydef XML and save again.", MODULE);
+ return false;
+ }
+ }
+
+ /** Nulls {@code reader}'s {@code groupCache} field, then eagerly rebuilds
it, self-healing on failure. */
+ private static boolean rebuildOneGroupReader(ModelGroupReader reader) {
+ String readerName = readField(modelGroupReaderModelNameField, reader);
+ String delegatorName = resolveDelegatorNameForGroupReader(readerName);
+ if (delegatorName == null) {
+ Debug.logWarning("Hot-reload: could not find a delegator whose
entity-group-reader is '" + readerName
+ + "' in entityengine.xml -- skipping entitygroup reload
for this reader.", MODULE);
+ return false;
+ }
+ long start = System.currentTimeMillis();
+ try {
+ modelGroupReaderGroupCacheField.set(reader, null);
+ int groupCount = reader.getGroupCache(delegatorName).size();
+ Debug.logInfo("Hot-reload: entity group model '" + readerName + "'
reloaded (" + groupCount
+ + " entity-group mappings) in " +
(System.currentTimeMillis() - start) + "ms", MODULE);
+ return true;
+ } catch (Throwable t) {
+ reNullQuietly(modelGroupReaderGroupCacheField, reader,
"groupCache", readerName);
+ Debug.logError(t, "Hot-reload: failed to reload entity group model
'" + readerName
+ + "' -- fix the entitygroup XML and save again.", MODULE);
+ return false;
+ }
+ }
+
+ /**
+ * {@code ModelGroupReader.getGroupCache(String delegatorName)} takes a
delegator
+ * name purely to validate each group against {@code entityengine.xml}
while
+ * rebuilding (see its javadoc in {@code framework/entity}) -- it's not
stored on
+ * the reader itself, so this class has no instance-local way to recover
one.
+ * Resolved instead straight from parsed config: the first configured
delegator
+ * whose {@code entity-group-reader} matches this reader's model name. Pure
+ * {@link EntityConfig} lookups, no reflection needed for this part.
+ */
+ private static String resolveDelegatorNameForGroupReader(String
groupReaderModelName) {
+ try {
+ List<DelegatorElement> delegators =
EntityConfig.getInstance().getDelegatorList();
+ for (DelegatorElement delegator : delegators) {
+ if
(groupReaderModelName.equals(delegator.getEntityGroupReader())) {
+ return delegator.getName();
+ }
+ }
+ } catch (Exception e) {
+ Debug.logError(e, "Hot-reload: could not read delegator config
while resolving a delegator name for "
+ + "entity-group-reader '" + groupReaderModelName + "'",
MODULE);
+ }
+ return null;
+ }
+
+ /** Best-effort read of a private {@code String} field, purely for log
messages. */
+ private static String readField(Field field, Object target) {
+ try {
+ Object value = field.get(target);
+ return value != null ? value.toString() : "?";
+ } catch (ReflectiveOperationException e) {
+ return "?";
+ }
+ }
+
+ /** Best-effort re-null of {@code field} on {@code target}, logging if
even that fails. */
+ private static void reNullQuietly(Field field, Object target, String
fieldLabel, String readerName) {
+ try {
+ field.set(target, null);
+ } catch (ReflectiveOperationException inner) {
+ Debug.logError(inner, "Hot-reload: could not re-null " +
fieldLabel + " for '" + readerName
+ + "' after a failed rebuild -- it may now be serving a
broken, partial model until OFBiz is "
+ + "restarted.", MODULE);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> UtilCache<String, T> castCache(Object value) {
+ return (UtilCache<String, T>) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Future<Delegator>> castMap(Object value) {
+ return (Map<String, Future<Delegator>>) value;
+ }
+
+ /** Resolves and caches every {@link Field} handle this class needs,
exactly once. */
+ private static void ensureFieldsResolved() {
+ if (resolutionAttempted) {
+ return;
+ }
+ synchronized (EntityModelReloader.class) {
+ if (resolutionAttempted) {
+ return;
+ }
+ resolutionAttempted = true;
+
+ try {
+ modelReaderReadersField =
ModelReader.class.getDeclaredField("READERS");
+ modelReaderReadersField.setAccessible(true);
+ modelReaderEntityCacheField =
ModelReader.class.getDeclaredField("entityCache");
+ modelReaderEntityCacheField.setAccessible(true);
+ modelReaderModelNameField =
ModelReader.class.getDeclaredField("modelName");
+ modelReaderModelNameField.setAccessible(true);
+ entityReloadAvailable = true;
+ } catch (ReflectiveOperationException | SecurityException e) {
+ Debug.logError(e, "Hot-reload: could not resolve ModelReader's
private fields (READERS/entityCache"
+ + "/modelName) via reflection -- this OFBiz version's
ModelReader implementation may have "
+ + "changed. Entity/view-entity hot-reload is disabled
for this session; Java and "
+ + "services.xml hot-reload are unaffected.", MODULE);
+ entityReloadAvailable = false;
+ }
+
+ try {
+ modelGroupReaderReadersField =
ModelGroupReader.class.getDeclaredField("READERS");
+ modelGroupReaderReadersField.setAccessible(true);
+ modelGroupReaderGroupCacheField =
ModelGroupReader.class.getDeclaredField("groupCache");
+ modelGroupReaderGroupCacheField.setAccessible(true);
+ modelGroupReaderModelNameField =
ModelGroupReader.class.getDeclaredField("modelName");
+ modelGroupReaderModelNameField.setAccessible(true);
+ groupReloadAvailable = true;
+ } catch (ReflectiveOperationException | SecurityException e) {
+ Debug.logError(e, "Hot-reload: could not resolve
ModelGroupReader's private fields (READERS/"
+ + "groupCache/modelName) via reflection -- this OFBiz
version's ModelGroupReader "
+ + "implementation may have changed. entitygroup.xml
hot-reload is disabled for this "
+ + "session; entity/view-entity hot-reload is
unaffected.", MODULE);
+ groupReloadAvailable = false;
+ }
+
+ try {
+ delegatorFactoryDelegatorsField =
DelegatorFactory.class.getDeclaredField("DELEGATORS");
+ delegatorFactoryDelegatorsField.setAccessible(true);
+ delegatorCacheClearAvailable = true;
+ } catch (ReflectiveOperationException | SecurityException e) {
+ Debug.logError(e, "Hot-reload: could not resolve
DelegatorFactory's private DELEGATORS field via "
+ + "reflection -- this OFBiz version's DelegatorFactory
implementation may have changed. "
+ + "Entity/view-entity definitions will still reload
live, but already-cached data may keep "
+ + "pointing at a stale model until OFBiz is
restarted.", MODULE);
+ delegatorCacheClearAvailable = false;
+ }
+ }
+ }
+}