Performance Fixes for Large Batch ORM Workloads
Investigating an OOM report from a user processing 3.8M entities nightly, we profiled the extension against the Ortus fork and found 5.6.15.11 was 32% slower with 33% more memory usage — despite identical per-entity retained heap.
The root cause was transient allocation churn during Hibernate flush dirty-checking, not a memory leak.
What changed in 5.6.15.12-SNAPSHOT
-
String.replace()instead ofUtil.replace()inHibernateCaster.toHibernateType()— the old code allocated a new StringBuilder + String on every call, even when no replacement was needed (which was almost always). At scale this created ~360 million unnecessary objects per 50k entity run. -
SQL type caching in
CFCGetter— Hibernate’s property accessor was resolving the SQL type from a string name on every property read (toLowerCase + string matching + if/else chain). The type never changes for a given property, so we resolve it once in the constructor and cache it.
Results (50k entity insert, no session clear)
| 5.6.15.11 | 5.6.15.12 | Ortus 6.5.4 | |
|---|---|---|---|
| Time | 138 sec | 55 sec | 102 sec |
| Rate | 361 ent/sec | 903 ent/sec | 487 ent/sec |
5.6.15.12 is now 46% faster than Ortus on the same workload. Heap dumps confirm identical per-entity memory between both extensions (155 KB difference on 459 MB).
Results (50k entity insert, with session clear — the recommended pattern)
| 5.6.15.11 | 5.6.15.12 | |
|---|---|---|
| Time | 5.3 sec | 3.5 sec |
| Rate | 9,489 ent/sec | 14,204 ent/sec |
| Heap growth | 309 MB | 244 MB |
With proper flush+clear batching (the realistic production pattern), .12 is 33% faster with 21% less heap. This is the LDEV-6252 Lucee core optimisation making a direct impact on the per-property-read cost across 500k property reads per run.
Batch ORM best practice: flush + clear
If you’re processing large numbers of entities (bulk imports, nightly jobs, migrations), always call ormClearSession() after each ormFlush(). Without it, every entity you’ve ever saved stays attached to the Hibernate session, and each subsequent flush dirty-checks all of them — not just the new batch. This makes total work O(N²) and eventually leads to OOM.
for ( var i = 1; i <= totalRecords; i++ ) {
var entity = entityNew( "MyEntity" );
// ... set properties ...
entitySave( entity );
if ( i mod 50 == 0 ) {
ormFlush();
ormClearSession(); // detach processed entities, free memory
}
}
ormFlush(); // flush any remainder
With clear, 50k entities inserts in 3.5 seconds (14,204 ent/sec). Without clear, the same 50k takes 55 seconds due to the accumulated dirty-checking overhead.
The LDEV-6252 core optimisations specifically improved the clear path — 33% faster and 21% less heap compared to the previous snapshot — because with small batches, the per-property-read overhead is a larger proportion of total work. 500k property reads per run (1000 flushes × 50 entities × 10 properties) add up when each one was doing three unnecessary string comparisons and a double map lookup.
Lucee 7.1.0.86-SNAPSHOT
In addition to the extension improvements, based on the findings, I also made some additional performance improvements for Lucee 7.1.
Lucee 6.2.5.48+ is supported by the Lucee Hibernate 5.6.15 extension, it’s just that the newer Lucee versions bring additional fixes, features and performance improvements, upgrades are worth it!
The profiling also identified two optimisations in Lucee core’s CFC property access path — LDEV-6252. These benefit any CFC-heavy workload, not just ORM:
-
Single-lookup
getOrDefaultinConcurrentHashMapNullSupport— the old implementation did twoConcurrentHashMaplookups (get + containsKey) when a value was null. The fix uses an absent sentinel to resolve it in a single atomic call, also eliminating a TOCTOU race condition. -
Map-first reorder in
ComponentScopeShadow.get()— every property read was checkingequalsIgnoreCasefor SUPER, THIS, and STATIC before hitting the map. Normal property access (99%+ of calls) never matches these. The map is now checked first, with special keys as a fallback.
On the ORM clear-path benchmark (the realistic batch pattern with ormClearSession() after each flush), this gives a 33% speed improvement and 21% less heap growth.
Documentation
- ORM Sessions & Transactions — includes new Batch Processing section
- ORM Troubleshooting — performance tips
- ORM Configuration
- Full ORM docs