Promotion Engine Performance: Ten Fixes for a Slow Drools Rule Base
Why cart calculation slows down as promotions accumulate, and the field-proven remedies: rule base statistics, discount rows over rules, virtual categories, KIE module splits, expired-rule cleanup jobs, and eliminating redundant evaluations.
The promotion engine has a distinctive failure curve: it launches fast, and then every campaign, every seasonal rule, every "quick coupon for the newsletter" deposits another Drools rule into the working memory that evaluates on every cart calculation. Two years later, add-to-cart takes 900 ms, checkout drags, and nobody can say why, because no single promotion did it. This guide is the diagnostic and the cure list, distilled from the recurring engagements that all find the same ten problems.
Mechanically: promotion source rules compile into Drools rules, grouped into KIE modules and bases; cart calculation fires the engine, which evaluates the RETE network of conditions against rule-aware objects (RAOs) representing the cart. Cost therefore scales with the number and complexity of published rules, the size of the cart, and how often calculation is triggered. Every fix below attacks one of those three factors.
1. Measure the Rule Base Before Touching Anything#
The first move is statistics, not opinions. A Groovy script in HAC answers the questions that direct everything else (trimmed to its core; extend per your database, the string functions differ between SQL Server and Oracle):
import de.hybris.platform.servicelayer.search.FlexibleSearchQuery
def fss = spring.getBean("flexibleSearchService")
def count = { q ->
def query = new FlexibleSearchQuery(q)
query.setResultClassList([Integer.class])
fss.search(query).result[0]
}
println "Total promotions: " + count("select count(*) from {PromotionSourceRule}")
println "Published: " + count("""select count(*) from {PromotionSourceRule as a
join rulestatus as b on {a.status}={b.pk}} where {b.code}='PUBLISHED'""")
println "Published but expired: " + count("""select count(*) from {PromotionSourceRule as a
join rulestatus as b on {a.status}={b.pk}}
where {b.code}='PUBLISHED' and {a.endDate} is not null and getDate() > {a.endDate}""")
println "Active Drools rules: " + count(
"select count(*) from {droolsrule} where {active}=1 and {currentversion}=1")
println "Redundant Drools rule versions: " + count("""select count(r.pk) from
({{select {pk} as pk,{code} as code,{kieBase} as kieBase,{version} as version from {DroolsRule}}}) r
join ({{select max({version}) as version,{code} as code,{kieBase} as kieBase
from {DroolsRule} group by {code},{kieBase}}}) m
on r.code=m.code and r.kieBase=m.kieBase and r.version <> m.version""")
Extend it with per-website published counts and the top rules by rulecontent size. Then read the results like a clinician:
- Published but expired > 0: dead rules still evaluating on every cart (fix 7).
- Redundant rule versions > 0: old versions bloating the rule base (fix 7).
- Published count far above what the business thinks is live: governance problem first, engine problem second.
- Huge individual rule content: almost always hundreds of products or users attached directly to a condition (fix 5).
For genuinely gnarly individual rules, the Drools tooling can render the RETE network of a rule's conditions; a visualization that looks like spaghetti evaluates like spaghetti.
2. Take the Free Wins: Patch Level#
Promotion engine optimizations ship continuously in update releases, and three landmark ones transformed the engine's footprint: the replacement of RRD tracking objects with RuleAndRuleGroupExecutionTracker (large memory reduction, default via ruleengineservices.use.deprecated.rrd.objects=false), the RAO model simplification (ProductRAO, CategoryRAO, ProductConsumedRAO folded into OrderEntryRAO, so far fewer objects per evaluation), and Drools syntax generation improvements (code == "A" || code == "B" instead of collection-based in conditions).
The catch everyone misses: these apply to rules compiled after the upgrade. Republish all published rules after patching, or you run new engine code over old generated syntax and wonder where the improvement went.
3. Stop Writing Promotions That Should Be Discount Rows#
The classic modeling mistake: "product X costs 20 percent less this month" implemented as a promotion rule. A simple product-plus-fixed-discount arrangement needs no rule engine at all; the platform's discount rows do exactly this natively, evaluate during ordinary price calculation, import trivially via ImpEx, and cost nothing in Drools working memory.
The trade-off stated honestly: discount rows are less flexible (no complex conditions, and the cart calculation semantics differ slightly), so they do not replace real conditional promotions. But every "promotion" that is actually a plain markdown moved to discount rows removes a rule from every cart evaluation forever. On catalogs where business teams created thousands of per-product "promotions", this single change is the difference between a sick engine and a healthy one.
4. Virtual Categories and User Groups, Not Attached Lists#
Backoffice happily lets a business user attach 400 individual products or 2,000 individual customers to a promotion condition. Each becomes literal content in the generated Drools rule, and the statistics script exposes them as the top-10 largest rules. Evaluation time and publication time both degrade.
The fix is modeling hygiene: create a category holding those products (a "virtual category" used purely for promotion targeting) or a user group holding those customers, and condition the promotion on the group. One membership lookup instead of hundreds of literal comparisons, and the business gains reusable targeting objects as a bonus.
5. The Right Coupon Type#
Two coupon models exist for different jobs: single-code coupons (one code, redeemable by many customers) and multi-code coupons (a code series generated from one coupon definition, each code single-use). The anti-pattern is generating thousands of single-code coupons where one multi-code coupon belongs, which multiplies coupon objects and load on the engine. If each customer gets a unique code, that is by definition a multi-code coupon; use it.
6. Cleanup Jobs: The Engine's Retention Rules#
Two accumulations need scheduled removal, in the same spirit as the platform-wide data maintenance guide:
Inactive Drools rule versions. Every republish leaves the previous version behind. The shipped DroolsRulesMaintenanceCleanupJob deletes all inactive versions; enable it and schedule it. The redundant-version count from the statistics script tells you how overdue you are.
Expired published rules. This one is subtle and expensive: a promotion past its end date still sits in the rule base, and its date condition is evaluated by Drools on every cart calculation. Expiry does not unpublish; it just makes the rule evaluate to false, at full cost, forever. The remedy is a small custom maintenance job that finds published rules past their end date and triggers undeployment through the rule engine's job launcher:
public class CleanupExpiredRulesStrategy
implements MaintenanceCleanupStrategy<SourceRuleModel, CronJobModel> {
private static final String EXPIRED_RULES =
"select {ar.pk} from {AbstractRule as ar}, {RuleStatus as rs} " +
"where {ar.enddate} < getDate() and {ar.status} = {rs.pk} and {rs.code} = 'PUBLISHED'";
// getDate() is Azure SQL / SQL Server; adapt for other databases
@Override
public FlexibleSearchQuery createFetchQuery(final CronJobModel cronJob) {
return new FlexibleSearchQuery(EXPIRED_RULES);
}
@Override
public void process(final List<SourceRuleModel> expired) {
if (!expired.isEmpty()) {
ruleEngineCronJobLauncher.triggerUndeployRules(expired, "promotions-module");
}
}
}
wired as a MaintenanceCleanupJob with a nightly trigger:
INSERT_UPDATE MaintenanceCleanupJob; code[unique=true]; springId[unique=true]; active[default=true]
; expiredRulesMaintenanceCleanupPerformable ; expiredRulesMaintenanceCleanupJob ; true
INSERT_UPDATE CronJob; code[unique=true]; job(code); sessionLanguage(isoCode)[default=en]
; expiredRulesMaintenanceCleanupJob ; expiredRulesMaintenanceCleanupPerformable
INSERT_UPDATE Trigger; cronJob(code)[unique=true]; second; minute; hour; day; month; year; relative; active; maxAcceptableDelay
; expiredRulesMaintenanceCleanupJob ; 0 ; 0 ; 3 ; -1 ; -1 ; -1 ; false ; true ; -1
7. Split KIE Modules per Site#
Multi-site installations with one shared KIE module evaluate every site's promotions on every site's carts. If carts never mix products across sites, give each site its own KIE module, and each cart calculation sees only its own site's rules. A five-site setup where each site runs a fifth of the promotions just quintupled its evaluation efficiency, for a configuration change.
While in there: preview KIE modules (used for rule preview tooling) hold memory whether used or not. If your team does not use preview, remove those modules and reclaim the heap.
8. Calculate Less Often#
The cheapest promotion evaluation is the one that never runs. Three audits:
- Count
evaluate()calls per user action. Trace an add-to-cart through the code and count invocations ofDefaultPromotionEngineService.evaluate(). More than one per request means layered code each defensively recalculating; consolidate to a single recalculation at the end of the mutation. This is one of the most common findings and one of the most profitable. - Skip evaluation when the cart cannot have changed. Recalculation triggered from flows that did not touch entries, quantities, or the customer's groups is pure waste; guard those paths.
- Bound the cart. Evaluation cost grows with cart entries (more RAOs, more matches). B2B carts with 500 lines will be slow in any rule engine; if the business needs them, set expectations and consider order-splitting flows rather than tuning around physics.
9. Give the Engine the Memory Behaviour It Needs#
A large rule base plus per-cart working memory produces allocation pressure, and the symptom is long GC pauses during traffic peaks, felt as periodic checkout stalls. On modern JDK 17/21 platforms with G1, the defaults are decent, but promotion-heavy installations still benefit from GC log review under load test: if pause times correlate with evaluation bursts, the fixes are (in order) shrinking the rule base with everything above, then heap sizing, then GC tuning. Tuning the collector to accommodate a bloated rule base is treating the symptom; the statistics script keeps you honest about the cause.
10. Govern the Pipeline That Creates the Problem#
Every fix above decays without governance, because the input (business users creating promotions) never stops. The lightweight regime that keeps the engine healthy:
- The statistics script runs monthly, and its output goes to whoever owns promotions in the business, not just engineering
- Campaign end means unpublish, enforced by the cleanup job as backstop
- Promotion requests specifying individual product lists get translated to categories at intake
- Load tests (see the performance engineering guide) include realistic active promotion counts, so the rule base's cost is visible before production feels it
The promotion engine is a genuinely capable rule system that gets blamed for what is usually accumulation and misuse. Measure first, take the patch-level wins, move the markdowns out, clean up on a schedule, and evaluation stops being the mystery tax on every cart.