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

tkobayas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-drools.git


The following commit(s) were added to refs/heads/main by this push:
     new 143b6ece668 [incubator-kie-drools-6765] Park no-loop/lock-on-active 
blocked tuples in dormantMatches (#6768)
143b6ece668 is described below

commit 143b6ece66841c1f88de396e149f4336d15fd147
Author: Toshiya Kobayashi <[email protected]>
AuthorDate: Fri Jun 26 16:27:44 2026 +0900

    [incubator-kie-drools-6765] Park no-loop/lock-on-active blocked tuples in 
dormantMatches (#6768)
    
    * [incubator-kie-drools-6765] Revisit removeDormantTuple issue
    - Test cases
    
    * [incubator-kie-drools-6765] Park no-loop/lock-on-active blocked tuples in 
dormantMatches
    
    When doLeftTupleInsert returns early due to no-loop or lock-on-active
    guards, the tuple was left as an orphan — not in any RuleExecutor list
    but still linked at the join node. A subsequent UPDATE or DELETE would
    call removeDormantTuple on the orphan, causing NPE in LinkedList.remove().
    
    Park blocked tuples in dormantMatches before returning, consistent with
    the existing activationsFilter path that already parks never-fired tuples
    in dormant.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * temporal change for draft test CI
    
    * Revert "temporal change for draft test CI"
    
    This reverts commit 2183c46ba36978c29020df148a83c0e166efa285.
    
    * comment
    
    * make a comment precise
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
 .../drools/core/phreak/PhreakRuleTerminalNode.java |   4 +
 .../java/org/drools/core/phreak/RuleExecutor.java  |   5 +-
 .../RemoveDormantTupleNPETest.java                 | 259 +++++++++++++++++++++
 3 files changed, 266 insertions(+), 2 deletions(-)

diff --git 
a/drools-core/src/main/java/org/drools/core/phreak/PhreakRuleTerminalNode.java 
b/drools-core/src/main/java/org/drools/core/phreak/PhreakRuleTerminalNode.java
index 703f23e7c75..ce75adef6db 100644
--- 
a/drools-core/src/main/java/org/drools/core/phreak/PhreakRuleTerminalNode.java
+++ 
b/drools-core/src/main/java/org/drools/core/phreak/PhreakRuleTerminalNode.java
@@ -113,6 +113,7 @@ public class PhreakRuleTerminalNode {
         // most recent PropagationContext is required to maintain the right 
recency which triggers the match
         PropagationContext pctx = leftTuple.findMostRecentPropagationContext();
         if ( rtnNode.getRule().isNoLoop() && sameRules(rtnNode, 
pctx.getTerminalNodeOrigin()) ) {
+            executor.addDormantTuple(leftTuple);
             return;
         }
 
@@ -127,6 +128,7 @@ public class PhreakRuleTerminalNode {
             InternalAgendaGroup agendaGroup = 
executor.getRuleAgendaItem().getAgendaGroup();
             if (blockedByLockOnActive(rtnNode.getRule(), pctx, agendaGroup)) {
                 
activationsManager.getAgendaEventSupport().fireActivationCancelled(leftTuple, 
reteEvaluator, MatchCancelledCause.FILTER );
+                executor.addDormantTuple(leftTuple);
                 return;
             }
         }
@@ -278,6 +280,8 @@ public class PhreakRuleTerminalNode {
             // Expiration propagations should not be removed from the list, as 
they still need to fire
             executor.removeActiveTuple( leftTuple );
         } else if ( leftTuple.getStagedType() == Tuple.DELETE && 
!leftTuple.isQueued() ) {
+            // if Tuple.DELETE, it's not in activeMatches nor dormantMatches. 
Basically, unlinked from parent nodes, so will be GC'ed.
+            // Note: there could be a case where such a tuple will be retained 
and activated again. (e.g., accPropCtx.getResultLeftTuple())
             executor.removeDormantTuple( leftTuple );
         }
 
diff --git a/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java 
b/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
index 7839ea8813b..46f0de6064b 100644
--- a/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
+++ b/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
@@ -314,7 +314,7 @@ public class RuleExecutor {
         if (tuple.getPrevious() != null || dormantMatches.getFirst() == tuple) 
{
             dormantMatches.remove(tuple);
         } else {
-            log.debug("Skipping removeDormantTuple for orphan tuple {} on rule 
\"{}\" — tuple is not present in dormantMatches",
+            log.warn("Skipping removeDormantTuple for orphan tuple {} on rule 
\"{}\" — tuple is not present in dormantMatches",
                       tuple, ruleAgendaItem.getRule().getName());
         }
         if (DEBUG_DORMANT_TUPLE) {
@@ -345,7 +345,8 @@ public class RuleExecutor {
         activeMatches.remove(tuple);
         if (tuple.getStagedType() != Tuple.DELETE) {
             addDormantTuple(tuple);
-        }
+        } // if Tuple.DELETE, it's not in activeMatches nor dormantMatches. 
Basically, unlinked from parent nodes, so will be GC'ed.
+          // Note: there could be a case where such a tuple will be retained 
and activated again. (e.g., accPropCtx.getResultLeftTuple())
         if (queue != null) {
             removeQueuedLeftTuple(tuple);
         }
diff --git 
a/drools-test-coverage/test-compiler-integration/src/test/java/org/drools/mvel/integrationtests/RemoveDormantTupleNPETest.java
 
b/drools-test-coverage/test-compiler-integration/src/test/java/org/drools/mvel/integrationtests/RemoveDormantTupleNPETest.java
new file mode 100644
index 00000000000..36c88507b87
--- /dev/null
+++ 
b/drools-test-coverage/test-compiler-integration/src/test/java/org/drools/mvel/integrationtests/RemoveDormantTupleNPETest.java
@@ -0,0 +1,259 @@
+/*
+ * 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.drools.mvel.integrationtests;
+
+import java.util.stream.Stream;
+
+import org.drools.mvel.compiler.Cheese;
+import org.drools.mvel.compiler.Person;
+import org.drools.testcoverage.common.util.KieBaseTestConfiguration;
+import org.drools.testcoverage.common.util.KieBaseUtil;
+import org.drools.testcoverage.common.util.TestParametersUtil2;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.kie.api.KieBase;
+import org.kie.api.runtime.KieSession;
+import org.kie.api.runtime.rule.FactHandle;
+
+/**
+ * Reproducers for NPE in RuleExecutor.removeDormantTuple (issue #6422, PR 
#6707).
+ *
+ * Root cause: doLeftTupleInsert can return early (no-loop or lock-on-active 
guard)
+ * without adding the tuple to any RuleExecutor list. The tuple remains linked 
as
+ * a child at the join node. A subsequent fact modification triggers an UPDATE 
for
+ * the orphan, and modifyActiveTuple calls removeDormantTuple on a tuple that 
was
+ * never in dormantMatches — NPE in LinkedList.remove().
+ */
+public class RemoveDormantTupleNPETest {
+
+    public static Stream<KieBaseTestConfiguration> parameters() {
+        return 
TestParametersUtil2.getKieBaseCloudConfigurations(true).stream();
+    }
+
+    /**
+     * Orphan via no-loop, triggered by external ksession.update().
+     *
+     * R1 (no-loop) fires → inserts new Cheese → new match blocked by no-loop 
→ orphan.
+     * External update of Person → UPDATE on orphan → NPE.
+     */
+    @ParameterizedTest(name = "KieBase type={0}")
+    @MethodSource("parameters")
+    public void testNoLoopInsertThenExternalUpdate(KieBaseTestConfiguration 
kieBaseTestConfiguration) {
+        final String drl =
+                "package org.drools.reproducer\n" +
+                "import " + Cheese.class.getCanonicalName() + "\n" +
+                "import " + Person.class.getCanonicalName() + "\n" +
+                "\n" +
+                "rule R1\n" +
+                "    no-loop true\n" +
+                "when\n" +
+                "    $c : Cheese(price > 0)\n" +
+                "    $p : Person(age > 0)\n" +
+                "then\n" +
+                "    insert(new Cheese(\"inserted\", $c.getPrice() + 100));\n" 
+
+                "end\n";
+
+        KieBase kbase = KieBaseUtil.getKieBaseFromKieModuleFromDrl("test", 
kieBaseTestConfiguration, drl);
+        KieSession ksession = kbase.newKieSession();
+
+        try {
+            Cheese cheese = new Cheese("original", 1);
+            Person person = new Person("john", 1);
+
+            ksession.insert(cheese);
+            FactHandle personHandle = ksession.insert(person);
+
+            // R1 fires → inserts Cheese("inserted",101) → new match blocked 
by no-loop → orphan
+            ksession.fireAllRules();
+
+            // Modify Person externally → right-side UPDATE at R1's join node 
hits the orphan
+            person.setAge(2);
+            ksession.update(personHandle, person);
+
+            // doLeftTupleUpdate → modifyActiveTuple → removeDormantTuple → NPE
+            ksession.fireAllRules();
+        } finally {
+            ksession.dispose();
+        }
+    }
+
+    /**
+     * Orphan via no-loop, triggered by a second rule's modify — single 
fireAllRules.
+     *
+     * R1 (no-loop, higher salience) fires first → inserts new Cheese → orphan.
+     * R2 fires second → modifies Person → UPDATE on orphan → NPE.
+     * All within one fireAllRules() call.
+     */
+    @ParameterizedTest(name = "KieBase type={0}")
+    @MethodSource("parameters")
+    public void testNoLoopCrossRuleModify(KieBaseTestConfiguration 
kieBaseTestConfiguration) {
+        final String drl =
+                "package org.drools.reproducer\n" +
+                "import " + Cheese.class.getCanonicalName() + "\n" +
+                "import " + Person.class.getCanonicalName() + "\n" +
+                "\n" +
+                "rule R1\n" +
+                "    no-loop true\n" +
+                "    salience 10\n" +
+                "when\n" +
+                "    $c : Cheese(price > 0)\n" +
+                "    $p : Person(age > 0)\n" +
+                "then\n" +
+                "    insert(new Cheese(\"inserted\", $c.getPrice() + 100));\n" 
+
+                "end\n" +
+                "\n" +
+                "rule R2\n" +
+                "    no-loop true\n" +
+                "    salience 5\n" +
+                "when\n" +
+                "    $c : Cheese(type == \"original\")\n" +
+                "    $p : Person(age > 0)\n" +
+                "then\n" +
+                "    modify($p) { setAge($p.getAge() + 1) };\n" +
+                "end\n";
+
+        KieBase kbase = KieBaseUtil.getKieBaseFromKieModuleFromDrl("test", 
kieBaseTestConfiguration, drl);
+        KieSession ksession = kbase.newKieSession();
+
+        try {
+            ksession.insert(new Cheese("original", 1));
+            ksession.insert(new Person("john", 1));
+
+            // R1 fires (salience 10): inserts Cheese("inserted",101) → orphan 
at R1's terminal.
+            // R2 fires (salience 5): modifies Person → right-side UPDATE at 
R1's join node.
+            // R1 re-evaluates: doLeftTupleUpdate on orphan → 
modifyActiveTuple → NPE.
+            ksession.fireAllRules();
+        } finally {
+            ksession.dispose();
+        }
+    }
+
+    /**
+     * Orphan via lock-on-active, triggered by external ksession.update().
+     *
+     * R1 (lock-on-active, agenda-group) fires → inserts new Cheese → new match
+     * blocked by lock-on-active → orphan.
+     * After the group deactivates, external update of Person + re-focus → NPE.
+     */
+    @ParameterizedTest(name = "KieBase type={0}")
+    @MethodSource("parameters")
+    public void 
testLockOnActiveInsertThenExternalUpdate(KieBaseTestConfiguration 
kieBaseTestConfiguration) {
+        final String drl =
+                "package org.drools.reproducer\n" +
+                "import " + Cheese.class.getCanonicalName() + "\n" +
+                "import " + Person.class.getCanonicalName() + "\n" +
+                "\n" +
+                "rule R1\n" +
+                "    agenda-group \"group1\"\n" +
+                "    lock-on-active true\n" +
+                "when\n" +
+                "    $c : Cheese(price > 0)\n" +
+                "    $p : Person(age > 0)\n" +
+                "then\n" +
+                "    insert(new Cheese(\"inserted\", $c.getPrice() + 100));\n" 
+
+                "end\n";
+
+        KieBase kbase = KieBaseUtil.getKieBaseFromKieModuleFromDrl("test", 
kieBaseTestConfiguration, drl);
+        KieSession ksession = kbase.newKieSession();
+
+        try {
+            Cheese cheese = new Cheese("original", 1);
+            Person person = new Person("john", 1);
+
+            ksession.insert(cheese);
+            FactHandle personHandle = ksession.insert(person);
+
+            // Activate group and fire. R1 fires → inserts 
Cheese("inserted",101).
+            // New match blocked by lock-on-active → orphan.
+            ksession.getAgenda().getAgendaGroup("group1").setFocus();
+            ksession.fireAllRules();
+
+            // Modify Person while group is inactive (update recency < 
activation recency
+            // when group is re-focused, so lock-on-active won't block the 
update).
+            person.setAge(2);
+            ksession.update(personHandle, person);
+
+            // Re-focus and fire. doLeftTupleUpdate processes the UPDATE on 
the orphan.
+            // lock-on-active check passes (fact was modified before group 
re-activation).
+            // modifyActiveTuple → removeDormantTuple → NPE.
+            ksession.getAgenda().getAgendaGroup("group1").setFocus();
+            ksession.fireAllRules();
+        } finally {
+            ksession.dispose();
+        }
+    }
+
+    /**
+     * Orphan via no-loop, triggered by DELETE (retract) — single fireAllRules,
+     * no external update, no modify.
+     *
+     * R1 (no-loop) fires → inserts a new fact → new match blocked by no-loop 
→ orphan.
+     * R2 retracts the inserted fact → LEFT DELETE propagates to orphan →
+     * doLeftDelete → removeDormantTuple → NPE.
+     *
+     * This is the most realistic scenario: rules only use insert/retract (no 
modify),
+     * and the NPE occurs within a single fireAllRules() call.
+     */
+    @ParameterizedTest(name = "KieBase type={0}")
+    @MethodSource("parameters")
+    public void 
testNoLoopInsertRetractNoExternalUpdate(KieBaseTestConfiguration 
kieBaseTestConfiguration) {
+        // R1: inserts a new Cheese("big", price+100) when it finds a cheap 
Cheese with a Person.
+        //     The new Cheese re-matches R1 with the same Person → blocked by 
no-loop → orphan.
+        //
+        // R2: retracts any Cheese with price > 50. This covers the inserted 
Cheese("big", 101).
+        //     Retracting it propagates a LEFT DELETE through R1's join node, 
hitting the orphan.
+        final String drl =
+                "package org.drools.reproducer\n" +
+                "import " + Cheese.class.getCanonicalName() + "\n" +
+                "import " + Person.class.getCanonicalName() + "\n" +
+                "\n" +
+                "rule R1\n" +
+                "    no-loop true\n" +
+                "    salience 10\n" +
+                "when\n" +
+                "    $c : Cheese(price > 0)\n" +
+                "    $p : Person(age > 0)\n" +
+                "then\n" +
+                "    insert(new Cheese(\"big\", $c.getPrice() + 100));\n" +
+                "end\n" +
+                "\n" +
+                "rule R2\n" +
+                "    salience 1\n" +
+                "when\n" +
+                "    $c : Cheese(price > 50)\n" +
+                "then\n" +
+                "    retract($c);\n" +
+                "end\n";
+
+        KieBase kbase = KieBaseUtil.getKieBaseFromKieModuleFromDrl("test", 
kieBaseTestConfiguration, drl);
+        KieSession ksession = kbase.newKieSession();
+
+        try {
+            ksession.insert(new Cheese("original", 1));
+            ksession.insert(new Person("john", 1));
+
+            // R1 fires (salience 10): inserts Cheese("big",101) → orphan at 
R1's terminal.
+            // R2 fires (salience 1): retracts Cheese("big",101) → LEFT DELETE 
on orphan.
+            // doLeftDelete → removeDormantTuple on orphan → NPE.
+            ksession.fireAllRules();
+        } finally {
+            ksession.dispose();
+        }
+    }
+}


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

Reply via email to