One of the most common questions on StackOverflow in the Drools tag is something along the lines of "how do I call my database from my rules?"
The short answer is "you shouldn't." The longer answer is "it's complicated." This is the second in a series of posts explaining why.
Last time, we discussed why you shouldn't be writing database access functions directly in your DRL files. Today we're going to talk about why even a properly designed and hardened database access layer shouldn't be invoked from Drools.
Before we begin, let me define what I mean by "properly designed and hardened database access layer." We're going to assume an application has been implemented that has:
- transaction management for all database access methods (for example, consider Hibernate's
@Transactional
annotation and similar mechanisms) - query sanitization of all inputs into queries
- connection pooling for all connections (eg. C3P0 or Apache Commons DBCP)
- appropriate security, access restrictions, credential management, and role management around database access
Further, we will assume that the application exposes a service class or interface through which we can interact with the database. This might be something like a Spring Data JPA repository, or some other form of DAO. For the purpose of this article, we'll assume that this class or interface exposes methods which return simple Java objects or collections of objects that model the results of the query. We make no assumptions about the design of those returned objects -- maybe they're annotated DTOs or entities; maybe they're just POJOs or JDK 14 Records.
Now that we've got that squared away, we'll get down to the meat of the problem ... namely:
Drools doesn't execute stuff like you think it does
Let's begin with a caveat. Technically if you take the time and effort to properly design your data access layer and address the concerns listed in the introduction and in the previous article, then you technically can go about calling your database from within your rules.
But you shouldn't, and this time, the reason has less to do with your database and more to do with Drools itself.
Namely this: most people don't properly understand the Drools lifecycle.
Why does this matter? Because unless you maintain extremely strict standards in your rule design forever, and familiarize yourself with how the Drools engine works in terms of execution and rule matching, you're going to be executing your queries a lot more often than you think. This leads to a bunch of potential issues:
- Latency. Database calls aren't "free". Every time a thread executes the rules and makes queries, they'll be using a connection and preparing a statement and executing it and parsing the results. If you have one thread, that's probably not so noticeable. But once you scale up, even with the best connection pooling, you're going to be seeing lag. You'll be seeing even more lag if you implement your rules naively (that is, without understanding how the Drools engine works.)
- Data consistency. A database is a shared resource. When you make query, you get a snapshot of the data at a specific point in time. Every time you repeat the query, you're adding the potential that the records have changed out from under you -- what was necessarily true at the time you made the previous query may no longer be true anymore.
There are other issues as well, but these are the top two.
Let's look at an example which illustrates the issue.
A simple example
Our example application provides order tracking and invoice generation via some rules. I'm going to use JDK14 Records to model the data, but you can easily imagine them as POJOs instead.
For the purpose of our rules, our application has three models:
record Customer( UUID id, Collection<Invoice> invoices, ... )
record Order( UUID id, UUID customerId, Float appliedTax, ... )
record Invoice( ... )
We've also abstracted all of our database logic into a service, CustomerOrdersService
. Our particular example will only make use of two methods:
public Order getLatestOrderForCustomer(UUID customerId);
public void updateOrder(Order order);
Note that updateOrder
will perform a database save. We'll assume that any auditing (eg. user/date updated) are out of scope and happen somewhere offscreen.
And finally, we have a utility class TaxUtils
for some tax-related logic. We also have a factory InvoiceFactory
for generating invoices.
So, for our application we've got the following use cases:
- If the latest order for the customer is missing tax information, calculate the applied tax and update the order.
- If the latest order for the customer has all required information (in this example, tax info), generate an invoice for the order and apply it to the customer account.
As a first pass, our rules might look something like this
rule "Customer Order is Missing Tax Information"
when
$svc: CustomerOrdersService()
Customer( $id: id != null )
$order: Order( appliedTax == null) from $svc.getLatestOrderForCustomer($id)
then
// calculate and apply the tax using some external utility
TaxUtils.applyTax($order)
// update the database record
$svc.updateOrder($order)
end
rule "Generate Invoice for Customer Order"
when
$svc: CustomerOrdersService()
$customer: Customer( $id: id != null )
$order: Order( appliedTax != null) from $svc.getLatestOrderForCustomer($id)
then
Invoice invoice = InvoiceFactory.generate($order)
$customer.addInvoice(invoice)
end
There are two big problems here.
Problem 1: we call getLatestOrderForCustomer
twice. We incur the latency of two database calls where one would have sufficed. Further, it is possible that a new Order may have been added to the database while Drools was evaluating these rules. That means that the first rule may be working on Order 5, and the second rule may be working with Order 6.
Problem 2: the order update is invisible. The first rule handles a use case where we're missing some necessary data (tax). On the left hand side we check that we're missing it, and on the right hand side we calculate it and save the results to the database. Now our data is good in the database, but what about the rule to generate invoices? It will never fire because we checked previously that there was tax information present when we were deciding whether or not to fire the rules -- and at that time there was no tax information. In order to actually get the second rule to fire in this case, we'd need to do an update
call to re-trigger the evaluation of all rules, which means we'll be calling the database two more times and thus exacerbating problem 1.
If you look at a Drools rule file, it's easy to fall into the trap of thinking that Drools will evaluate each rule sequentially: read the when, and execute the then; then read the next when, and execute its then. That's not actually how Drools works, however, and that's the trap that these example rules have fallen into.
The Drools lifecycle has various "phases", but we're specifically going to just talk about the 'Matching' phase. When you call "fire rules", Drools first gathers up all of the available rules, sorts them by salience and natural order, and then iterates across them evaluating the "when" clause. For a given rule, if all the statements in the "when" clause evaluate successfully then the rules engine considers this a "match" and it adds it to an ordered list of "matched" rules. (Hint: if you implement a rule listener, you can watch for the 'Match Created' event.)
Once all of the rules' left hand sides have been evaluated, the rules engine takes that list of matches and iterates over them, evaluating the right hand side.
This is why database access on the left hand side of the rule is so problematic -- the database queries are run during the 'match' phase, which means that any changes to the database values will be "invisible" unless the rule engine re-evaluates its previous matches. Drools does make such reevaluation possible through the use of some built-in functions -- update
, insert
, modify
, etc. Once you do call these methods, Drools will reevaluate the matches, redoing those database calls and incurring the inherent latency and danger of data inconsistency therein.
A simple refactor
Let's attempt to refactor our rules to address the problems we previously identified.
For data consistency and latency concerns, we'll want to call our getLatestOrderForCustomer
exactly one time. Further, we'll need to make sure our changes to the order
instance (the calculated tax applied) to be visible to all rules without requiring a new call to the database.
Here's what we get by applying these requirements:
rule "Get Latest Order"
when
$svc: CustomerOrdersService()
not( Order() )
Customer( $id: id != null )
then
Order $order = $svc.getLatestOrderForCustomer($id);
insert($order)
end
rule "Customer Order is Missing Tax Information - v2"
when
$svc: CustomerOrdersService()
// Check the order in working memory
$order: Order( appliedTax == null )
then
// calculate and apply the tax using some external utility
TaxUtils.applyTax($order)
// update the database record
$svc.updateOrder($order)
update($order)
end
rule "Generate Invoice for Customer Order - v2"
when
$customer: Customer( $id: id != null )
$order: Order( appliedTax != null )
then
Invoice invoice = InvoiceFactory.generate($order)
$customer.addInvoice(invoice)
end
This is a little more complex, but we should have addressed the biggest concerns.
First, we've added a new rule -- "Get Latest Order"
. This rule checks to see if there's an order in working memory; if there's not, it does a lookup and adds it. It calls insert
, which will tell Drools to re-evaluate subsequent rules to see if they're now matches.
You'll also notice that the other two rules have been updated to use the Order information in working memory instead of doing the database lookup themselves. This restructuring means that we should be calling the getLatestOrderForCustomer
method only once per execution, which should take care of most of our problems regarding latency and data integrity.
Problem 1. We only call getLatestOrderForCustomer
once, and that's on the right hand side (RHS) of the rules, so it's not going to be called extraneously during the matching phase. Only when this specific rule matches will the rule be called; the consequences in this rule also adjust working memory so that the rule will no longer be eligible to fire a second time.
Problem 2. Since we're doing work on an Order instance in working memory, we no longer need to be concerned about the tax changes being invisible to the invoice rule. Because the Order is in working memory, we can call update
on it after we make the changes and that will reevaluate the rules and trigger the invoice rule because of the newly non-null value.
So what problems do we have left?
Maintenance: the hidden debt
Technically, without adding any more color to the use cases and methods I've presented, there's nothing wrong with the rules as written. Without any more complexities or "gotachas", the rules as written would suffice, and we could use them as-is in our application. But should we?
I would argue that no, you shouldn't. Not because the rules are bad -- because they're really not -- but because they're fragile.
From now on, any engineer who you ask to maintain these rules will need to:
-
understand the intricacies of the Drools lifecycle, namely how the "matching" phase works and how different actions (eg.
update
,modify
,insert
, etc) cause the phase to be re-evaluated - be intimately familiar with your data access layer and models, especially regarding how data is fetched and modelled
- understand how the two interact with each other
This is further complicated by the fact that it's not really feasible to create test scenarios -- automated or otherwise -- against the problems we're guarding against. Recall that one of the problems I initially called out was that we get the "latest" order twice; thus if a new order was inserted into the database during the matching phase, it would be possible that two different orders were being evaluated simultaneously in different rules. This would be spectacularly difficult to test -- honestly, I can't think of how to induce the scenario consistently (at least, not without significantly modifying the Drools runtime and the production code.)
It's not unreasonable to ask a software engineer to consider race conditions, of course. If we had a method where we called "get latest" twice, it would be reasonable to expect an experience engineer to recognize that potentially the second call may return a different value than the first. A junior engineer may not notice this weakness, however, and may implement the sequential calls, even in a simple code context. (After all, you want to make sure your data isn't stale, right? What better than to get a fresh copy every time?) Personally, I wouldn't fault an entry-level engineer for overlooking this; with experience comes seniority -- but do you really want to restrict your rules to only being updated by senior engineers? And, given the inability to test for the problems that arise from not understanding these complexities -- do you really want to have to rely on equally senior but still very human code reviewers to notice, identify, and flag potential workflow issues?
That's the crux of the problem really. You're relying on two very particular and complex technologies -- Drools rule execution and database access -- both of which have their own distinct problems, but which together present an additional set of combined challenges that one can only readily identify and work with if an engineer understands both knowledge domains. And the worst part? You can't reliably produce automated tests for these issues, so new problems are likely to be identified only in production.
At the end of the day, my argument really boils down to some software engineering principles. KISS -- Keep It Simple, Stupid -- and the Single Responsibility Principle. The purpose of a database is to provide data access; the purpose of Drools or any rules system is to make decisions against inbound data; access your data first, and then you can make decisions at your leisure. And, honestly, most software engineering could benefit from KISS: not only does it make your life simpler today, but it reduces your maintenance overhead tomorrow. If your rules don't interact with your database, you don't need to worry about a well meaning intern showing up several years down the road and making an innocent change in response to a bug that causes millions of dollars' worth of consequences. (True story.)
The End
Honestly, my examples weren't even as complex as they could be. Consider, for example, what might happen if the model returned by our data access layer included a Hibernate lazily loaded collection. Such a collection can only be loaded if we have an active transaction (eg. we need to be inside the @Transactional
context); we're safe as long as the rules never interact with that collection ... but what if they potentially need to? Should we eagerly load the collection, just in case, and take the performance hit? Or do we need to somehow get a transaction to do the lazy-load only when we need it?
At this point I'm belaboring the point, and I hope that I've made it clear why you shouldn't access databases from Drools even with a hardened, production-ready data access layer.
Like part 1, this isn't actually the end of my extended rant against database access in Drools -- it's just the wrap-up of part 2 this monologue. Stay tuned for part 3!
Cover image attribution: cowins @ Pixabay
Top comments (0)