This is an automated email from the ASF dual-hosted git repository.

mridulpathak 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 e379514a7 Reload the entity readers through their reload methods
e379514a7 is described below

commit e379514a72cfbaae8df180bf2f14024b3d73fdb7
Author: Mridul Pathak <[email protected]>
AuthorDate: Sun Nov 1 14:19:08 2026 +0530

    Reload the entity readers through their reload methods
    
    EntityModelReloader reset the private entityCache and groupCache fields of 
ModelReader and ModelGroupReader by reflection and rebuilt them, re-nulling 
them on failure. It now calls the readers' new reload methods for the reader of 
every configured delegator, which keep the previous model in use if a save is 
broken, and no longer reflects into the readers. The entry points are renamed 
reloadEntityModels and reloadGroupModels, and the README describes the new 
behaviour.
---
 devreload/README.md                                |  26 +--
 .../apache/ofbiz/devreload/DevReloadContainer.java |  26 +--
 .../ofbiz/devreload/EntityModelReloader.java       | 253 ++++++---------------
 3 files changed, 90 insertions(+), 215 deletions(-)

diff --git a/devreload/README.md b/devreload/README.md
index 26d42bd4c..1c3e861a1 100644
--- a/devreload/README.md
+++ b/devreload/README.md
@@ -140,7 +140,7 @@ sixth class, since it's just one more `UtilCache` clear on 
`DevReloadContainer`
 | `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 five 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 |
+| `EntityModelReloader` | Reloads the already-running 
`ModelReader`/`ModelGroupReader` instances through their `reload()` methods so 
an `entitydef/*.xml` edit takes effect at once, 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
@@ -156,7 +156,7 @@ this plugin exists.
 |---|---|---|---|
 | 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. `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 |
+| 3. `entitydef/*.xml` | Every component's `entitydef/` directory 
(`entity-resource type="model"`/`"group"` only — not seed/demo data) | Reloads 
the affected `ModelReader`/`ModelGroupReader` instance(s) through their 
`reload()` methods, then clears delegator data caches (see 
`EntityModelReloader`) | Entity/view-entity/entity-group definition changes 
live in < 1s |
 | 4. `config/*Labels.xml` / `*.properties` | Every component's `config/` 
directory (scoped to files ending `Labels.xml` or `.properties` — not other 
`config/*.xml` files like `entityengine.xml`) | Clears 
`UtilCache.clearCachesThatStartWith("properties.")` | Label/message bundle and 
property lookups re-read on the next call |
 | 5. Build output (opt-in) | `build/classes/java/main` | Same hot-swap as Path 
1 | Picks up `./gradlew -t classes` run in a second terminal |
 
@@ -188,14 +188,15 @@ Unlike `services.xml` (clear a cache the service engine 
looks up by name on ever
 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.
+already serving traffic. `EntityModelReloader` instead calls `reload()` on the
+already-running `ModelReader`/`ModelGroupReader` instances — every delegator 
sharing a
+reader name (almost always `"main"`) picks up the change for free. The readers 
build
+the new model on the side and swap it in only once it is complete, so requests 
keep
+being served from the previous model while the reload runs.
 
 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.
+is caught and logged clearly, and the previous model stays in use: the next 
save
+retries the reload, with no restart needed.
 
 Optionally, `-Dofbiz.hotreload.autoUpdateSchema=true` (or
 `-Photreload.autoUpdateSchema=true`) also creates any missing table/column a 
changed
@@ -309,7 +310,7 @@ 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`, `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`, since replaced.) 
Entity/view-entity reload takes a related but separate approach: it reflects 
into `framework/entity`'s already-running `ModelReader`/`ModelGroupReader` 
instances rather than changing them. |
+| `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`, since replaced.) 
Entity/view-entity reload uses the public `reload()` method of 
`framework/entity`'s already-running `ModelReader`/`ModelGroupReader` 
instances. |
 
 To use it in a checkout: `git clone <devreload-repo-url> plugins/devreload`,
 then run `./gradlew ofbizDev --no-watch-fs`. Deleting `plugins/devreload/` at
@@ -328,8 +329,8 @@ OFBiz.
 | One shared `Debouncer` class for all five watch paths | Avoids five 
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 |
+| Reload `ModelReader`/`ModelGroupReader` in place through `reload()`, 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 reload keeps the previous model | `reload()` builds 
the new model on the side and swaps it in only once complete, so a broken save 
never replaces a working model with a partial one, and the next save retries |
 | 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 |
 | Label/properties reload copies the changed file into `hotReloadOutputDir`, 
then calls `UtilCache.clearCachesThatStartWith("properties.")` on 
`DevReloadContainer` itself — no new `XxxReloader` class | Nothing holds a 
direct reference to a parsed bundle the way `GenericDelegator` holds a 
`ModelReader`, so no reflection is needed — but a cache clear alone re-reads 
Gradle's stale `build/resources/main` copy, not the live source file (confirmed 
live); the overlay-copy step reuses the exact  [...]
@@ -355,9 +356,8 @@ 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 |
+| Log: "Hot-reload: failed to reload entity model '...' -- the previous model 
stays in use; 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 — no restart needed even for a broken intermediate save. In the 
meantime the previous model, from before the broken save, keeps serving every 
entity |
 | 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) 
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 |
 | A `*Labels.xml` or `.properties` edit under `config/` isn't picked up | 
Either the file doesn't end in `Labels.xml` (e.g. a component using the older 
singular convention, if any), or it's one of the two static-snapshot exceptions 
(`debug.properties` log levels, `security.properties`'s 
`password.encrypt.pbkdf2.iterations`) | Rename to match the `Labels.xml` 
convention, or restart for the two named exceptions — every other property and 
label reloads live |
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 d489992f9..5a1c0854e 100644
--- a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
@@ -138,11 +138,11 @@ import org.apache.ofbiz.base.util.cache.UtilCache;
  * {@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.
+ * {@link EntityModelReloader}'s class javadoc for the full mechanism: it 
eagerly reloads
+ * the already-running {@code ModelReader}/{@code ModelGroupReader} instances 
in place
+ * through their {@code reload()} methods, 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>How config (label/properties) changes are handled</h2>
  * Every component's {@code config/} directory is also watched, scoped to 
files ending
@@ -243,8 +243,8 @@ public class DevReloadContainer implements Container {
      * {@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
+     * {@link EntityModelReloader#reloadEntityModels()} and
+     * {@link EntityModelReloader#reloadGroupModels()} (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
@@ -893,11 +893,11 @@ public class DevReloadContainer implements Container {
      * 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.
+     * class javadoc for why entity definitions need to be reloaded on the 
already-running
+     * {@code ModelReader}/{@code ModelGroupReader} instances directly instead.
      *
-     * <p>Both {@link EntityModelReloader#resetAndRebuildEntityModels()} and
-     * {@link EntityModelReloader#resetAndRebuildGroupModels()} run on every 
entitydef
+     * <p>Both {@link EntityModelReloader#reloadEntityModels()} and
+     * {@link EntityModelReloader#reloadGroupModels()} 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
@@ -911,8 +911,8 @@ public class DevReloadContainer implements Container {
      */
     private void applyEntitydefReload(Set<Path> batch) {
         Debug.logInfo("Hot-reload: entitydef changed " + batch, MODULE);
-        boolean entityOk = EntityModelReloader.resetAndRebuildEntityModels();
-        boolean groupOk = EntityModelReloader.resetAndRebuildGroupModels();
+        boolean entityOk = EntityModelReloader.reloadEntityModels();
+        boolean groupOk = EntityModelReloader.reloadGroupModels();
         if (entityOk || groupOk) {
             EntityModelReloader.clearAllDelegatorCaches();
         }
diff --git 
a/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java 
b/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
index e0ea8f3bf..031e5cb4b 100644
--- 
a/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
+++ 
b/devreload/src/main/java/org/apache/ofbiz/devreload/EntityModelReloader.java
@@ -25,14 +25,15 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Future;
 import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 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;
@@ -44,39 +45,35 @@ 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.
+ * Makes the already-running {@link ModelReader}/{@link ModelGroupReader} 
instances re-read
+ * their entity model/group mapping 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>
+ * <h2>Why the readers are reloaded in place</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.
+ * (see {@code GenericDelegator.modelReader}), so dropping the reader from its 
lookup
+ * cache would 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.
  *
- * <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>Instead, {@link ModelReader#reload()} and {@link 
ModelGroupReader#reload()} are
+ * called on the reader of every configured delegator -- every delegator 
sharing a
+ * reader name (almost always {@code "main"}) picks up the change for free, 
since they
+ * share the same object. Both readers build the new model on the side and 
swap it in
+ * only once it is complete, so requests keep being served from the previous 
model
+ * while the reload runs, and a broken save leaves that previous model in 
place. 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.
+ * <p>Enumerating already-created delegators (to clear their data caches) 
still needs
+ * reflection into {@link DelegatorFactory}; that {@link Field} handle is 
resolved once,
+ * the first time it's needed, and a resolution failure is logged once and 
disables
+ * only that part for the rest of the session.
  */
 final class EntityModelReloader {
 
@@ -84,95 +81,38 @@ final class EntityModelReloader {
 
     private static volatile boolean resolutionAttempted;
 
-    private static volatile boolean entityReloadAvailable;
-    private static Field modelReaderReadersField;
-    private static Field modelReaderEntityCacheField;
-
-    private static volatile boolean groupReloadAvailable;
-    private static Field modelGroupReaderReadersField;
-    private static Field modelGroupReaderGroupCacheField;
-
     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.
+     * Reloads the {@link ModelReader} of every configured delegator, eagerly 
(rather
+     * than leaving it for the next unrelated request), so a broken {@code 
entitydef}
+     * save is caught and logged right here instead of surfacing as a random 
failure
+     * later. A failed reload keeps the previous model, and the next save 
retries.
      *
-     * @return {@code true} if every cached {@link ModelReader} rebuilt 
cleanly.
+     * @return {@code true} if every {@link ModelReader} reloaded 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;
-        }
-
+    static boolean reloadEntityModels() {
         boolean allSucceeded = true;
-        for (String readerName : readers.getCacheLineKeys()) {
-            ModelReader reader = readers.get(readerName);
-            if (reader != null) {
-                allSucceeded &= rebuildOneEntityReader(readerName, reader);
-            }
+        for (Map.Entry<String, String> entry : 
readerDelegators(DelegatorElement::getEntityModelReader).entrySet()) {
+            allSucceeded &= reloadOneEntityReader(entry.getKey(), 
entry.getValue());
         }
         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.
+     * Reloads the {@link ModelGroupReader} of every configured delegator. 
Same rationale
+     * as {@link #reloadEntityModels()}; kept as a separate entry point so a 
failure in
+     * one never skips the other.
      *
-     * @return {@code true} if every cached {@link ModelGroupReader} rebuilt 
cleanly.
+     * @return {@code true} if every {@link ModelGroupReader} reloaded 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;
-        }
-
+    static boolean reloadGroupModels() {
         boolean allSucceeded = true;
-        for (String readerName : readers.getCacheLineKeys()) {
-            ModelGroupReader reader = readers.get(readerName);
-            if (reader != null) {
-                allSucceeded &= rebuildOneGroupReader(readerName, reader);
-            }
+        for (Map.Entry<String, String> entry : 
readerDelegators(DelegatorElement::getEntityGroupReader).entrySet()) {
+            allSucceeded &= reloadOneGroupReader(entry.getKey(), 
entry.getValue());
         }
         return allSucceeded;
     }
@@ -235,22 +175,17 @@ final class EntityModelReloader {
      * 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
+     * object every time {@code ModelReader} loads its entity cache (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.
+     * {@code ModelReader.getResourceHandlerEntities()}, which is not part of 
the model a
+     * reload swaps in atomically.
      */
     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 (String delegatorName : 
readerDelegators(DelegatorElement::getEntityModelReader).values()) {
+                ModelReader reader = ModelReader.getModelReader(delegatorName);
                 for (Map.Entry<String, ModelEntity> entry : 
reader.getEntityCache().entrySet()) {
                     if (locationMatches(entry.getValue().getLocation(), 
changedFiles)) {
                         entityNames.add(entry.getKey());
@@ -409,87 +344,55 @@ final class EntityModelReloader {
         }
     }
 
-    /** Nulls {@code reader}'s {@code entityCache} field, then eagerly 
rebuilds it, self-healing on failure. */
-    private static boolean rebuildOneEntityReader(String readerName, 
ModelReader reader) {
+    /** Reloads the entity model reader named {@code readerName}, found 
through {@code delegatorName}. */
+    private static boolean reloadOneEntityReader(String readerName, String 
delegatorName) {
         long start = System.currentTimeMillis();
         try {
-            modelReaderEntityCacheField.set(reader, null);
+            ModelReader reader = ModelReader.getModelReader(delegatorName);
+            reader.reload();
             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);
+                    + "' -- the previous model stays in use; 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(String readerName, 
ModelGroupReader 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;
-        }
+    /** Reloads the entity group reader named {@code readerName}, found 
through {@code delegatorName}. */
+    private static boolean reloadOneGroupReader(String readerName, String 
delegatorName) {
         long start = System.currentTimeMillis();
         try {
-            modelGroupReaderGroupCacheField.set(reader, null);
+            ModelGroupReader reader = 
ModelGroupReader.getModelGroupReader(delegatorName);
+            reader.reload();
             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);
+                    + "' -- the previous mapping stays in use; 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.
+     * Maps each reader name {@code readerOf} returns for the configured 
delegators to the
+     * first delegator that uses it, so a reader shared by several delegators 
is reloaded
+     * once.
      */
-    private static String resolveDelegatorNameForGroupReader(String 
groupReaderModelName) {
+    private static Map<String, String> 
readerDelegators(Function<DelegatorElement, String> readerOf) {
+        Map<String, String> readerDelegators = new LinkedHashMap<>();
         try {
-            List<DelegatorElement> delegators = 
EntityConfig.getInstance().getDelegatorList();
-            for (DelegatorElement delegator : delegators) {
-                if 
(groupReaderModelName.equals(delegator.getEntityGroupReader())) {
-                    return delegator.getName();
-                }
+            for (DelegatorElement delegator : 
EntityConfig.getInstance().getDelegatorList()) {
+                readerDelegators.putIfAbsent(readerOf.apply(delegator), 
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 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);
+            Debug.logError(e, "Hot-reload: could not read the delegator 
config", MODULE);
         }
-    }
-
-    @SuppressWarnings("unchecked")
-    private static <T> UtilCache<String, T> castCache(Object value) {
-        return (UtilCache<String, T>) value;
+        return readerDelegators;
     }
 
     @SuppressWarnings("unchecked")
@@ -497,7 +400,7 @@ final class EntityModelReloader {
         return (Map<String, Future<Delegator>>) value;
     }
 
-    /** Resolves and caches every {@link Field} handle this class needs, 
exactly once. */
+    /** Resolves and caches the {@link Field} handle this class needs, exactly 
once. */
     private static void ensureFieldsResolved() {
         if (resolutionAttempted) {
             return;
@@ -508,34 +411,6 @@ final class EntityModelReloader {
             }
             resolutionAttempted = true;
 
-            try {
-                modelReaderReadersField = 
ModelReader.class.getDeclaredField("READERS");
-                modelReaderReadersField.setAccessible(true);
-                modelReaderEntityCacheField = 
ModelReader.class.getDeclaredField("entityCache");
-                modelReaderEntityCacheField.setAccessible(true);
-                entityReloadAvailable = true;
-            } catch (ReflectiveOperationException | SecurityException e) {
-                Debug.logError(e, "Hot-reload: could not resolve ModelReader's 
private fields (READERS/entityCache)"
-                        + " 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);
-                groupReloadAvailable = true;
-            } catch (ReflectiveOperationException | SecurityException e) {
-                Debug.logError(e, "Hot-reload: could not resolve 
ModelGroupReader's private fields (READERS/"
-                        + "groupCache) 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);

Reply via email to