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;
+            }
+        }
+    }
+}

Reply via email to