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]