vamsizzz opened a new issue, #7125:
URL: https://github.com/apache/incubator-kie/issues/7125

   
   ---
   
   ## 1. Symptom (production)
   
   ```
   java.lang.NullPointerException: Cannot invoke "java.util.Map.remove(Object)" 
because "<local18>" is null
        at 
org.drools.core.phreak.PhreakFromNode.doLeftUpdates(PhreakFromNode.java:141)
        at org.drools.core.phreak.PhreakFromNode.doNode(PhreakFromNode.java:97)
        at 
org.drools.core.phreak.RuleNetworkEvaluator.evalNode(RuleNetworkEvaluator.java:403)
        at 
org.drools.core.phreak.RuleNetworkEvaluator.innerEval(RuleNetworkEvaluator.java:349)
        at 
org.drools.core.phreak.RuleNetworkEvaluator.outerEval(RuleNetworkEvaluator.java:185)
        at 
org.drools.core.phreak.RuleNetworkEvaluator.evaluateNetwork(RuleNetworkEvaluator.java:143)
        at 
org.drools.core.phreak.RuleExecutor.evaluateNetwork(RuleExecutor.java:221)
        at 
org.drools.core.phreak.RuleExecutor.evaluateNetworkIfDirty(RuleExecutor.java:231)
        at 
org.drools.core.phreak.RuleExecutor.evaluateNetworkAndFire(RuleExecutor.java:76)
        at 
org.drools.kiesession.agenda.DefaultAgenda.fireLoop(DefaultAgenda.java:624)
        ...
        at 
org.drools.kiesession.session.StatelessKnowledgeSessionImpl.execute(StatelessKnowledgeSessionImpl.java:271)
   ```
   
   It does **not** reproduce when replaying the same request directly against a 
freshly-cold-loaded KJAR. It **only** reproduces when the running 
`KieContainer` reaches that KJAR version via a chain of 
`KieContainer.updateToVersion()` hot-reloads (e.g. v8 → v14 → v16 → v17), and 
it reproduces **100% of the time** once that history exists.
   
   ---
   
   ## 2. The immediate defect: missing null-guard
   
   `org.drools.core.phreak.PhreakFromNode.java` (stock 8.44.0.Final), method 
`doLeftUpdates`, line 141:
   
   ```java
   public void doLeftUpdates(FromNode fromNode,
                             FromMemory fm,
                             LeftTupleSink sink,
                             ReteEvaluator reteEvaluator,
                             TupleSets<LeftTuple> srcLeftTuples,
                             TupleSets<LeftTuple> trgLeftTuples,
                             TupleSets<LeftTuple> stagedLeftTuples) {
       ...
       for (LeftTuple leftTuple = srcLeftTuples.getUpdateFirst(); leftTuple != 
null; ) {
           LeftTuple next = leftTuple.getStagedNext();
           PropagationContext propagationContext = 
leftTuple.getPropagationContext();
   
           final Map<Object, RightTuple> previousMatches = (Map<Object, 
RightTuple>) leftTuple.getContextObject();  // <-- can be null
           final Map<Object, RightTuple> newMatches = new HashMap<>();
           leftTuple.setContextObject( newMatches );
           ...
           for (... it.hasNext(); ) {
               ...
               RightTuple rightTuple = previousMatches.remove(object);   // <-- 
NPE here
               ...
           }
           for (RightTuple rightTuple : previousMatches.values()) { ... }  // 
<-- would also NPE
           ...
       }
   }
   ```
   
   There is **zero defensive check** on `previousMatches`. The method assumes 
unconditionally that whatever inserted this `LeftTuple` also called 
`leftTuple.setContextObject(matches)`. That assumption is false whenever the 
node was built with left-tuple memory disabled — see below.
   
   Compare with `doLeftInserts` in the same file, which explicitly branches on 
whether memory is enabled:
   
   ```java
   public void doLeftInserts(FromNode fromNode, FromMemory fm, LeftTupleSink 
sink,
                             ReteEvaluator reteEvaluator,
                             TupleSets<LeftTuple> srcLeftTuples, 
TupleSets<LeftTuple> trgLeftTuples) {
       ...
       for (LeftTuple leftTuple = srcLeftTuples.getInsertFirst(); leftTuple != 
null; ) {
           ...
           Map<Object, RightTuple> matches = null;
           boolean useLeftMemory = RuleNetworkEvaluator.useLeftMemory(fromNode, 
leftTuple);
   
           if (useLeftMemory) {
               fm.getBetaMemory().getLeftTupleMemory().add(leftTuple);
               matches = new LinkedHashMap<>();
               leftTuple.setContextObject( matches );   // <-- only set when 
useLeftMemory == true
           }
           ...
       }
   }
   ```
   
   So `doLeftInserts` correctly *skips* populating the context map when 
`useLeftMemory == false`. `doLeftUpdates` never checks for that case at all.
   
   ---
   
   ## 3. Why `useLeftMemory` is false: `sequential="true"` bakes it in at 
compile time
   
   `RuleNetworkEvaluator.useLeftMemory()`:
   
   ```java
   public static boolean useLeftMemory(LeftTupleSource tupleSource, Tuple 
tuple) {
       boolean useLeftMemory = true;
       if (!tupleSource.isLeftTupleMemoryEnabled()) {
           Object object = tuple.getRootTuple().getFactHandle().getObject();
           if (!(object instanceof DroolsQueryImpl) || !((DroolsQueryImpl) 
object).isOpen()) {
               useLeftMemory = false;
           }
       }
       return useLeftMemory;
   }
   ```
   
   `isLeftTupleMemoryEnabled()` on a `FromNode` traces back to a plain field 
baked in once, at node-construction time, by `FromBuilder`:
   
   ```java
   // org.drools.core.reteoo.builder.FromBuilder#build
   FromNode fromNode = from.isReactive()
       ? nodeFactory.buildReactiveFromNode(context.getNextNodeId(), 
from.getDataProvider(),
           context.getTupleSource(), alphaNodeFieldConstraints, betaConstraints,
           context.isTupleMemoryEnabled(),   // <-- baked in permanently here
           context, from)
       : nodeFactory.buildFromNode(context.getNextNodeId(), 
from.getDataProvider(),
           context.getTupleSource(), alphaNodeFieldConstraints, betaConstraints,
           context.isTupleMemoryEnabled(),   // <-- and here
           context, from);
   ```
   
   And `context.isTupleMemoryEnabled()` is decided **once per rule compile, 
purely from the KieBase's `sequential` flag** — nothing to do with whether that 
specific pattern needs memory for joins:
   
   ```java
   // org.drools.core.reteoo.builder.ReteooRuleBuilder#addRule, line ~130
   if (kBase.getRuleBaseConfiguration().isSequential()) {
       context.setTupleMemoryEnabled( false );
   } else {
       context.setTupleMemoryEnabled( true );
   }
   ```
   
   Confirmed against the actual production KJAR (`uswm-v17.kjar`, extracted 
`kmodule.xml`):
   
   ```xml
   <kbase name="Substantiation.Direct Spend or Exclusion list only"
          packages="rules.substantiation.direct_spend" sequential="true">
     <ksession name="Substantiation.Direct Spend or Exclusion list only" 
type="stateless"/>
   </kbase>
   <!-- every other kbase in the module is also sequential="true" -->
   ```
   
   **Sequential mode's entire premise is "insert once, fire in salience order, 
never re-evaluate."** Left-tuple memory (the map that tracks "what matched last 
time so I can diff on the next update") is a bookkeeping cost Drools 
deliberately skips in that mode, because sequential mode's contract says 
nothing should ever need to be revisited. `update()`/`modify()` calls violate 
that contract for a `from`-bound fact — and Drools does not validate or reject 
the combination anywhere at compile time or session time. It just leaves a 
landmine.
   
   ---
   
   ## 4. Instrumented, reproducible proof (not inference)
   
   We patched `PhreakFromNode.java` with diagnostic logging (see full patch 
below), rebuilt a patched `drools-core-8.44.0.Final.jar`, put it first on the 
classpath ahead of the real one, and replayed the known-poisoned hot-reload 
chain `v8 → v14 → v16 → v17` against the exact production request that fails.
   
   ### Patch used for instrumentation
   
   ```java
   public class PhreakFromNode {
       private static final boolean DIAG =
           Boolean.parseBoolean(System.getProperty("phreak.fromnode.diag", 
"true"));
   
       private static void diag(String fmt, Object... args) {
           if (DIAG) System.out.println("[PHREAK-FROMNODE-DIAG] " + 
String.format(fmt, args));
       }
   
       // ... doLeftInserts(): log node identity + useLeftMemory decision on 
every insert
       diag("INSERT node=%s tuple=%s useLeftMemory=%s existingContextObject=%s",
            nodeId(fromNode), tupleId(leftTuple), useLeftMemory,
            leftTuple.getContextObject() == null ? "null" : 
leftTuple.getContextObject().getClass().getSimpleName());
   
       // ... doLeftUpdates(): log + dump full diagnostic BEFORE the original 
NPE fires
       Object rawContextObject = leftTuple.getContextObject();
       if (rawContextObject == null) {
           // dumps node id, tuple, rule, dataProvider, resultClass, 
reteEvaluator identity
           // then falls through to the *original* unmodified cast+dereference 
so the
           // stack trace / behavior is unchanged for apples-to-apples 
comparison with prod
       }
       final Map<Object, RightTuple> previousMatches = (Map<Object, 
RightTuple>) rawContextObject;
   ```
   
   ### Result
   
   | KJAR version | Executions on shared `FromNode` (JVM identity 
`FromNode@1234d9f6`, node id `17236`) | Inserts | Updates | Result |
   |---|---|---|---|---|
   | v8 (cold load) | 200 | 200 | **0** | OK — never touches update path |
   | v14 (`updateToVersion`) | 200 | 200 | **0** | OK |
   | v16 (`updateToVersion`) | 200 | 200 | **0** | OK |
   | v17 (`updateToVersion`) | 100 | 100 | **100** | **100/100 NPE** |
   
   Every single insert across all four versions logs:
   ```
   [PHREAK-FROMNODE-DIAG] INSERT node=FromNode@1234d9f6 id=17236 
tuple=JoinNodeLeftTuple@... useLeftMemory=false existingContextObject=null
   [PHREAK-FROMNODE-DIAG] INSERT node=FromNode@1234d9f6 id=17236 
tuple=JoinNodeLeftTuple@... -> contextObject NOT SET (useLeftMemory=false); 
leftTuple.contextObject stays null
   ```
   
   The very first `UPDATE` this node has ever seen across the whole run happens 
immediately after the `updateToVersion()` jump to v17, and crashes on the first 
try:
   
   ```
   UPDATE -> com.walmart.tender:tender-rules-kjar:1.0.17-USWM
   results=[]
   [PHREAK-FROMNODE-DIAG] UPDATE node=FromNode@1234d9f6 id=17236 
tuple=JoinNodeLeftTuple@1d0d4478 rawContextObject=NULL (class=n/a) ...
   [PHREAK-FROMNODE-DIAG] *** previousMatches IS NULL - about to NPE ***
     node=FromNode@1234d9f6 id=17236
     tuple=JoinNodeLeftTuple@1d0d4478
     leftTuple.toString=[fact 
0:1:774893764:774893764:4:DEFAULT:NON_TRAIT:...RuleContext{programApplied=true, 
decisionGroup='', hasRequest=true, hasResponse=true}]
     
fromNode.getDataProvider=org.drools.mvel.dataproviders.MVELDataProvider@3b453e37
     fromNode.getResultClass=class ...ApplyOffersRequest
     reteEvaluator=KieSession[403]
   
   FAILED com.walmart.tender:tender-rules-kjar:1.0.17-USWM#1: 
java.lang.NullPointerException: Cannot invoke "java.util.Map.remove(Object)" 
because "<local18>" is null
   ```
   
   **Key finding:** `useLeftMemory=false` is present on *every single insert in 
every version*, including v8/v14/v16 which never crash. The crash isn't caused 
by the flag suddenly flipping — it's caused by v17's newly patched-in rule 
content being the first version whose consequence chain actually calls 
`update()` in a way that routes back through this exact, 
already-memory-disabled, **shared** `FromNode` object. A from-scratch cold 
compile of v17 alone does not reproduce it for the same request — only the 
incremental `updateToVersion()` history does. That strongly suggests Drools' 
incremental node-sharing/reuse logic during hot-reload wires new rule 
consequences to pre-existing shared nodes differently (and more dangerously) 
than a batch/from-scratch compile does, on top of the pre-existing 
sequential-mode landmine.
   
   ### Node-sharing path checked (for completeness)
   
   `org.drools.core.reteoo.builder.BuildUtils#attachNode` is where Drools 
decides whether to reuse an existing compatible node or build a new one:
   
   ```java
   } else if ( isSharingEnabledForNode(context, candidate) ) {
       if ( (context.getTupleSource() != null) && 
NodeTypeEnums.isLeftTupleSink(candidate) ) {
           node = 
context.getTupleSource().getSinkPropagator().getMatchingNode(candidate);
       }
       ...
   }
   if ( node != null && !areNodesCompatibleForSharing(context, node) ) {
       node = null;
   }
   ```
   
   Because `tupleMemoryEnabled` is a **KieBase-wide** setting (driven purely by 
`sequential`), every `FromNode` built anywhere in this kbase — whether during a 
fresh compile or an incremental patch — computes the same `false` value. So 
`areNodesCompatibleForSharing` never sees a mismatch to reject; sharing a 
memory-disabled node across rule versions is "correct" by Drools' own 
compatibility check, it's just that the underlying premise (no rule will ever 
call `update()` through it) turns out to be false once v17 adds a consequence 
that does.
   
   ---
   
   ## 5. Suggested fix (for the upstream ticket)
   
   Two independent, complementary fixes:
   
   1. **Defensive null-check in `PhreakFromNode.doLeftUpdates()`** — if 
`previousMatches` is null (i.e., this node was built without left-tuple 
memory), either:
      - treat it as an empty map (degrade gracefully — matches 
sequential-mode's "don't bother" intent), or
      - throw a clear, actionable exception (`IllegalStateException: 
update()/modify() is not supported for 'from' bindings in a sequential-mode 
KieBase — node <id>, rule <rule>`) instead of a bare NPE.
   2. **Compile-time or build-time validation** that rejects (or at least warns 
on) `update()`/`modify()` calls whose propagation reaches a `from` node in a 
`sequential="true"` kbase, so this is caught during `kieBuilder.buildAll()` 
instead of at runtime under specific hot-reload histories.
   
   ---
   
   ## 6. Public-safe minimal repro (use this when filing the GitHub issue — do 
not paste our internal package names)
   
   ```java
   // kmodule.xml
   // <kbase name="repro" sequential="true"><ksession name="repro" 
type="stateless"/></kbase>
   
   // Repro.drl
   rule "insert-holder"
       when
           $ctx : Holder()
           $x : String() from $ctx.items   // from-binding on a mutable field
       then
           // no-op
   end
   
   rule "mutate-and-update"
       salience 100
       when
           $ctx : Holder(applied == false)
       then
           $ctx.setApplied(true);
           update($ctx);   // <-- triggers doLeftUpdates() on the shared 
FromNode above
   end
   ```
   
   ```java
   StatelessKieSession session = container.newStatelessKieSession("repro");
   session.execute(new Holder());
   // NPE: java.lang.NullPointerException at 
org.drools.core.phreak.PhreakFromNode.doLeftUpdates
   ```
   
   Note in the ticket that in our real-world case the crash was only observed 
after several `KieContainer.updateToVersion()` hot-reloads of a growing ruleset 
— a from-scratch compile of the final ruleset alone did not reproduce it, which 
may point to a secondary defect in incremental node-sharing during 
`updateToVersion()`.
   
   ---


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to