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

   Memory leak: facts inserted with insert(obj, true) are never removed from 
NamedEntryPoint.dynamicFacts on delete
   
   Component: drools-kiesession / drools-base
   Affected version: 10.2.0 (same code on main as of Sept 2026)
   
   Summary
   
   When a fact is inserted with the per-instance dynamic flag (insert(obj, 
true) from a rule consequence), NamedEntryPoint adds its handle to 
dynamicFacts. When the fact is later deleted, the handle is removed from 
dynamicFacts only if the fact's type is declared dynamic. If it isn't, the 
handle, the fact object and the property-change listener registration are kept 
until the session is disposed.
   
   In a long-running stateful session that repeatedly inserts dynamic facts and 
deletes them, memory grows with every insert/delete cycle until 
OutOfMemoryError.
   
   A related problem makes this easy to hit: a class-level 
@org.kie.api.definition.type.PropertyChangeSupport annotation does not make the 
type dynamic. Only a DRL declare ... @propertyChangeSupport does. So a class 
annotated as property-change-aware still leaks.
   
   Root cause
   
   1. Insert and delete use different conditions 
(drools-kiesession/.../entrypoints/NamedEntryPoint.java):
   
   Insert (around line 230) registers the listener and records the handle when 
either the instance flag or the type flag is set:
   ```
   if ( dynamic || typeConf.isDynamic() ) {
       addPropertyChangeListener( handle, dynamic );   // adds to dynamicFacts 
when dynamic == true
   }
   ```
   
   Delete, in deleteStated (around line 482), checks only the type flag:
   ```
   if( typeConf.isDynamic() ) {
       removePropertyChangeListener( handle, true );   // only path that 
removes from dynamicFacts
   }
   ```
   
   So for insert(obj, true) on a type that isn't declared dynamic, the handle 
goes into dynamicFacts and is never removed. Apart from this branch, the only 
code that clears dynamicFacts is dispose(). The bean also keeps a listener that 
points back to the entry point.
   
   2. Class-level @PropertyChangeSupport is ignored for TypeDeclaration.dynamic:
   - ClassObjectTypeConf.isDynamic() returns typeDecl != null && 
typeDecl.isDynamic().
   - The only place that sets TypeDeclaration.setDynamic(...) is 
TypeDeclarationFactory.processAnnotations (drools-compiler, around lineĀ 105). 
It reads the annotation from the DRL type descriptor: 
typeDescr.hasAnnotation(PropertyChangeSupport.class).
   - For a plain POJO with no DRL declare, the declaration comes from 
TypeDeclaration.createT-base). That method processes @Expires and 
@PropertyReactive/@ClassReactive only, so@PropertyChangeSupport on the class is 
never read.
   
   Reproducer
   
   Bean.java:
   ```
   package org.example;
   
   import java.beans.PropertyChangeListener;
   import java.beans.PropertyChangeSupport;
   
   @org.kie.api.definition.type.PropertyChangeSupport
   public class Bean {
       private final PropertyChangeSupport pcs = new 
PropertyChangeSupport(this);
       private final String id;
       public Bean(String id) { this.id = id; }
       public String getId() { return id; }
       public void addPropertyChangeListener(PropertyChangeListener l) { 
pcs.addPropertyChang
       public void removePropertyChangeListener(PropertyChangeListener l) { 
pcs.removePropert
   }
   ```
   
   Trigger.java:
   ```
   package org.example;
   
   public class Trigger {
       private final String id;
       public Trigger(String id) { this.id = id; }
       public String getId() { return id; }
   }
   ```
   
   DynamicFactsLeakRepro.java:
   ```
   import java.lang.reflect.Field;
   import java.util.Set;
   
   import org.drools.kiesession.entrypoints.NamedEntryPoint;
   import org.example.Trigger;
   import org.kie.api.KieServices;
   import org.kie.api.builder.KieBuilder;
   import org.kie.api.builder.KieFileSystem;
   import org.kie.api.builder.Message;
   import org.kie.api.runtime.KieSession;
   
   public class DynamicFactsLeakRepro {
   
       static final String RULES =
           "package org.example;\n" +
           "%s\n" +
           "rule \"insert dynamic\" when\n" +
           "    $t : Trigger()\n" +
           "then\n" +
           "    delete($t);\n" +
           "    insert(new Bean($t.getId()), true);\n" +
           "end\n" +
           "rule \"delete bean\" when\n" +
           "    $b : Bean()\n" +
           "then\n" +
           "    delete($b);\n" +
           "end\n";
   
       public static void main(String[] args) throws Exception {
           run("class annotation only", "");
           run("DRL declare @propertyChangeSupport", "declare Bean 
@propertyChangeSupport end
       }
   
       static void run(String label, String declare) throws Exception {
           KieServices ks = KieServices.Factory.get();
           KieFileSystem kfs = 
ks.newKieFileSystem().write("src/main/resources/org/example/ru, declare));
           KieBuilder kb = ks.newKieBuilder(kfs).buildAll();
           if (kb.getResults().hasMessages(Message.Level.ERROR)) throw new 
IllegalStateExcept));
           KieSession session = 
ks.newKieContainer(ks.getRepository().getDefaultReleaseId()).
   
           int n = 10_000;
           for (int i = 0; i < n; i++) {
               session.insert(new Trigger("t" + i));
               session.fireAllRules();
           }
   
           Field field = NamedEntryPoint.class.getDeclaredField("dynamicFacts");
           field.setAccessible(true);
           Set<?> dynamicFacts = (Set<?>) 
field.get(session.getEntryPoint("DEFAULT"));
           System.out.printf("%-38s factCount=%d dynamicFacts=%d%n", label, 
session.getFactCo
               dynamicFacts == null ? 0 : dynamicFacts.size());
           session.dispose();
       }
   }
   ```
   
   Output on 10.2.0:
   class annotation only                  factCount=0 dynamicFacts=10000
   DRL declare @propertyChangeSupport     factCount=0 dynamicFacts=0
   
   The working memory is empty in both cases, but in the first one all 10,000 
deleted beans are still referenced from dynamicFacts.
   
   Expected behaviour
   
   - Deleting a fact always removes it from dynamicFacts and unregisters its 
property-change came dynamic (per-insert flag or type declaration).
   - A class-level @PropertyChangeSupport annotation is honoured the same way 
as declare ... @propertyChangeSupport.
   
   Actual behaviour
   
   - Facts inserted with insert(obj, true) whose type isn't declared dynamic in 
DRL are kept n until dispose().
   - Class-level @PropertyChangeSupport has no effect on typeConf.isDynamic().
   
   Impact
   
   We hit this in production: long-running stateful sessions OOM-restarted. 
Model rules insert DTOs with insert(x, true), and a filtering rule deletes the 
facts that aren't relevant. Each delete leaked one handle
   plus the fact graph. A heap dump showed about 67% of the heap held by 
NamedEntryPoint..dynamicFacts, and heap use grew in proportion to the number of 
events processed, not the number of live facts.
   
   Suggested fix
   
   1. In NamedEntryPoint.deleteStated, clean up whenever the handle is tracked, 
not only when not only when the type is dynamic.
   Exmple:
   if ( typeConf.isDynamic() || ( dynamicFacts != null && 
dynamicFacts.contains( handle ) ) )
       removePropertyChangeListener( handle, true );
   }
      Or make the condition match the insert side by checking the same 
per-handle dynamic state. Any other path that destroys a handle (e.g. 
logical/TMS retraction, expiration) should be checked for the same asymmetry.
   2. In TypeDeclaration.createTypeDeclarationForBean / processTypeAnnotations, 
read @PropertyChangeSupport from the class and call setDynamic(true), as is 
already done for @PropertyReactive.
   
   Workaround
   
   Declare the type as dynamic in DRL, even if the class already has the 
annotation:
   drl
   declare Bean @propertyChangeSupport end
   With this, typeConf.isDynamic() is true and delete removes the handle from 
dynamicFacts.


-- 
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