Fix Machine.deleteRule isolation for shared wildcard sub-patterns (#255) - #256
Conversation
ec07b68 to
525f579
Compare
…s#255) Deleting one rule could silently break other rules that share byte-level trie transitions, which happens for identical or overlapping wildcard sub-patterns. The result was either a ghost (deleted rule keeps matching) or an orphan (surviving rule stops matching), and it was non-deterministic because it depended on HashSet iteration order. Root cause: for a wildcard clause the add path creates a separate ByteMatch (and NameState) per rule over shared byte transitions. deleteStep used findPattern, which returns a single HashSet-order-dependent NameState, and guarded teardown with doesNameStateContainPattern on that one NameState only. Fix (delete path only, no changes to addRule or matching): - ByteMachine.findAllPatterns returns every NameState a pattern leads to. - deleteStep removes the sub-rule from the NameState that actually holds the rule being deleted, and only tears down shared transitions once no NameState references the pattern (noNameStateContainsPattern). - The single-NameState case runs the original logic unchanged. Adds reproductions (including the three from aws#255) plus boundary tests for the already-safe cases. Full suite passes; no performance regression. Also adds a mixed-operator shape test (wildcard combined with anything-but, numeric, exists, prefix and exact) to guard the multi-operator trie shape.
525f579 to
9ac9426
Compare
|
Hi @sanchezdale Thanks for the excellent report and fix — the writeups in #255 and this PR are some of the clearest I've seen. Also appreciate the validation details. I verified this independently, the issue is real and the fix does what it claims to do. The mechanism analysis matches my own as well. A few items before merge: 1. Required licensing confirmation. The PR body is missing the confirmation line from our PR template. Could you reply here confirming:
2. Another gap I found while investigating. A sample test to confirm this if you are interested: @Test
public void anythingButWildcard_sharedClause_deleteIsolation() throws Exception {
String ruleJson = "{\"name\":[\"test\"],\"properties.foo\":[{\"anything-but\":{\"wildcard\":\"*bar*\"}}]}";
String event = "{\"name\":\"test\",\"properties\":{\"foo\":\"nomatchhere\"}}";
for (int i = 0; i < 200; i++) {
Machine machine = Machine.builder().build();
machine.addRule("rule1", ruleJson);
machine.addRule("rule2", ruleJson);
assertEquals(2, machine.rulesForJSONEvent(event).size());
machine.deleteRule("rule1", ruleJson);
List<String> after = machine.rulesForJSONEvent(event);
assertFalse("rule1 should be deleted", after.contains("rule1"));
assertTrue("rule2 should still match", after.contains("rule2"));
}
}The I'll open a follow-up issue and fix that separately — no action needed from you. 3. And another issue I found as I was reviewing this: In the old code, when 4. Nit. Can you confirm No. 1, and I will also add No. 4 on top. |
… switch Collapses the duplicated pattern-type switch so the two methods cannot drift when a new pattern type is added. No behavior change: findPattern returns the first (or only) NameState findAllPatterns yields, matching the previous semantics for every pattern type.
|
Thanks @fym-rgb!really appreciate the thorough review and verification. Good catch on anything-but: {wildcard: ...}, and thanks for taking that on. I’ve updated the PR description. Thanks again! |
|
No problem @sanchezdale. CI benchmarks are running now. Once they're clean I'll merge this, and I'll cut a tagged pre-release on GitHub you can build against right away, so you're not blocked on the adjacent gaps I'm fixing separately (they don't (or shouldn't) affect your patterns since plain wildcard is fully covered by this PR). A Maven Central release will follow once the remaining fixes land. |
fym-rgb
left a comment
There was a problem hiding this comment.
Approving — all pre-merge items are closed:
- Licensing: Apache 2.0 confirmation added to the PR description. Thanks!
- Correctness: independently verified — the 8 new tests fail on unmodified
main(both ghost and strand reproduce) and the full suite is 767/767 green on this branch. The single-NameState delete path is byte-identical to the previous logic, so non-shared patterns are untouched. - Performance: CI green on all 4 JDKs (8/11/17/21). I compared the CI benchmark output against the last
mainrun: no rule type regresses consistently across JDKs — deltas are within cross-runner noise, as expected since none of the changed methods are reachable fromrulesForJSONEvent. - Item 4 (nit): landed on the branch as 4f714af (
findPatternnow delegates tofindAllPatterns, single type switch). - Item 3 (multi-NameState candidate flow): accepted deliberately as reviewed above.
- Item 2 (anything-but wildcard gap): pre-existing, tracked separately; fix coming as a follow-up on top of this.
Merging with rebase to keep authorship of the fix and the nit distinct. A tagged pre-release you can build against follows right after; Maven Central once the remaining fixes land.
Thanks again for a model contribution.
Issue #, if available:
#255
Description of changes:
We found a bug where cloning a rule (same rule, but different name) and then deleting one of them would cause a couple of non-deterministic behaviors but only when one of the rules had a wildcard.
The two possibilities were:
We reported this in #255, and this PR is one option to fix it.
What we narrowed it down to:
When two rules share a wildcard clause (like
*bar*), Ruler shares the trie transitions between them, but each rule still gets its own match marker at the end of that shared path (its ownByteMatchtoNameState).The delete path didn't handle that:
ByteMachine.findPattern(...)only returned oneNameStatefor the pattern, and which one depended onHashSetiteration order (that's the non-determinism). Half the time delete grabbed the surviving rule's NameState, didn't find the rule we were deleting, and did nothing so a ghost.doesNameStateContainPattern) only checked that one NameState, never the sibling still using the shared path so an orphan.The fix (delete/teardown path only, no changes to
addRuleor matching):ByteMachine.findAllPatternswhich returns all NameStates a pattern leads to. Delete now removes the sub-rule from the NameState that actually holds the rule being deleted → kills the ghost.noNameStateContainsPattern) → kills the orphan.$or, etc.) runs the original logic unchanged (deleteStepForNameState), so nothing else moves.Testing:
mainand pass with the fix.prefix, and rules sharing nothing.Benchmark / Performance (for source code changes):
Perf note (grain of salt): I ran this on my laptop, not a clean/isolated CI box, so please treat these as directional rather than authoritative. This change only touches the add/delete path, the matching path (
rulesForJSONEvent) is untouched, so I wouldn't expect any real effect on match throughput.Ran on: Apple M4 Pro (8P + 4E), 48 GB RAM, macOS 26.4.1, OpenJDK 26.0.1.
StableBenchmarks, warmup=10 / measure=30,origin/mainvsfix/wildcard-delete-isolation. Metric is events/sec, higher is faster.