Plan: Rules Engine Cache Race Fix
On this page
Context
The rules engine maintains an in-memory cache of compiled JDM rule sets (Arc<RwLock<HashMap<String, Arc<ZenDecision>>>>).
All four mutation handlers (create, update, delete, import) call reload_all() after their DB write, which reads ALL rule sets from the database, compiles ALL of them, then atomically replaces the entire HashMap.
Under concurrent mutations (e.g., parallel integration tests), one handler’s reload_all() can read stale DB state and overwrite another handler’s freshly-inserted cache entry — causing transient 404s on evaluate.
This was documented in the Roadmap under Known Issues & Tech Debt.
Root Cause
Two concurrent creates (A and B) with reload_all():
-
A writes to DB, A calls
reload_all()— reads DB (sees A only so far), starts building cache -
B writes to DB, B calls
reload_all()— reads DB (sees A+B), starts building cache -
A’s
reload_all()finishes, replaces entire cache with {A} — B is lost -
B’s
reload_all()finishes, replaces cache with \{A, B} — temporarily correct -
But the event subscriber then fires, calling
reload_all()again — potential further overwrites
The window is small but reproducible under parallel integration tests (~1 in 20 runs).
Fix: Incremental Cache Mutations
Replace reload_all() in API mutation handlers with targeted single-entry cache operations.
Additionally, skip self-originated cache invalidation events to prevent the subscriber’s reload_all() from overwriting freshly-inserted cache entries on the same instance.
Keep reload_all() only for:
-
Initial startup (
RulesEngine::new()) -
Cross-instance cache invalidation subscriber (
main.rs) — events from other instances only
Files Modified
| File | Change |
|---|---|
|
Add |
|
Change |
|
Update all 4 mutation handlers to use incremental cache mutations |
|
Subscriber skips self-originated events via |
Handler Changes Summary
create_rule_set
Save the compiled decision from validation, then insert_decision(name, decision) after DB insert.
update_rule_set
Fetch old record before update to capture old name.
After DB update: remove_decision(old_name), then insert_decision(new_name, decision).
If content wasn’t updated, compile from the returned record’s stored content.
Why This Works
With incremental mutations, two concurrent creates (A and B):
-
A writes to DB, A calls
insert_decision("A", decisionA)— cache: {A} -
B writes to DB, B calls
insert_decision("B", decisionB)— cache: \{A, B} -
Both succeed — the write lock is held only for the HashMap insert (microseconds)
The subscriber skips self-originated events (matched by instance_id), so the local cache is never overwritten by a stale reload_all().
For other instances, reload_all() still fires as a reconciliation step — since all DB writes have committed by the time the event propagates through RabbitMQ, the full reload sees the correct state.
Verification
-
cargo fmt --check --all— no formatting issues -
cargo clippy --workspace --locked— no warnings -
cargo test --workspace --lib— unit tests pass -
cargo xtask dev reload— rebuild with new code -
cargo test --workspace— all integration tests pass (including previously-flakyevaluate_multiple_times_same_set) -
Run integration tests 3x to confirm stability
-
cargo xtask e2e— E2E tests pass