-
Notifications
You must be signed in to change notification settings - Fork 115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Issue 275: Alternative approach using a listener #447
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
(ns clara.tools.loop-detector | ||
(:require [clara.rules.listener :as l] | ||
[clara.rules.engine :as eng])) | ||
|
||
(deftype LoopDetectorListener [cycles-count max-cycles fn-on-limit] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It relates to my comment @ https://github.com/cerner/clara-rules/pull/447/files#diff-a498c1d1444fc426bcaddeaa009e8dbfR26 It is clear it is tedious to setup a listener that implements all the functions that could be needed by any listener at a given Clara version. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is certainly more regular when it comes to cljs-land and web events - but I think it has good precedent in general. It's typically shortened to just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to be more explicit it could also be said that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should it just be called There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 I like "CyclicalRuleListener", will change. |
||
l/ITransientEventListener | ||
(left-activate! [listener node tokens] | ||
listener) | ||
(left-retract! [listener node tokens] | ||
listener) | ||
(right-activate! [listener node elements] | ||
listener) | ||
(right-retract! [listener node elements] | ||
listener) | ||
(insert-facts! [listener node token facts] | ||
listener) | ||
(alpha-activate! [listener node facts] | ||
listener) | ||
(insert-facts-logical! [listener node token facts] | ||
listener) | ||
(retract-facts! [listener node token facts] | ||
listener) | ||
(alpha-retract! [listener node facts] | ||
listener) | ||
(retract-facts-logical! [listener node token facts] | ||
listener) | ||
(add-accum-reduced! [listener node join-bindings result fact-bindings] | ||
listener) | ||
(remove-accum-reduced! [listener node join-bindings fact-bindings] | ||
listener) | ||
(add-activations! [listener node activations] | ||
listener) | ||
(remove-activations! [listener node activations] | ||
listener) | ||
(fire-activation! [listener activation resulting-operations] | ||
listener) | ||
(fire-rules! [listener node] | ||
listener) | ||
(activation-group-transition! [listener original-group new-group] | ||
(when (>= @cycles-count max-cycles) | ||
(fn-on-limit)) | ||
(swap! cycles-count inc)) | ||
(to-persistent! [listener] | ||
(LoopDetectorListener. (atom 0) max-cycles fn-on-limit)) | ||
|
||
l/IPersistentEventListener | ||
(to-transient [listener] | ||
listener)) | ||
|
||
(defn with-loop-detection [session fn-on-limit max-cycles] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do the arguments come in a different order for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, I have changed this in #458 |
||
(eng/with-listener session (LoopDetectorListener. (atom 0) max-cycles fn-on-limit))) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -203,3 +203,19 @@ | |
e# \newline | ||
"Non matches found: " \newline | ||
res#))))))) | ||
|
||
#?(:clj | ||
(defn ex-data-maps | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "in order" is a bit vague. I'd immediately ask "what order?" and "ascending or descending?"
I also tried to make it more cljs friendly since there isn't a reason this has to stay stuck in clj-only for long. It wouldn't require much change to handle both. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've used a slightly modified version of this docstring in #458 Note that I think this can return empty ex-data maps; I have changed the docstring you proposed above accordingly. |
||
"Given a Throwable, return in order the ExceptionInfo data maps for all items in the | ||
chain that implement IExceptionInfo and have nonempty data maps." | ||
[t] | ||
(let [throwables ((fn append-self | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we just make it a let-binding instead of a recursive anonymous inline fn? It's just making the impl look harder to follow. (let [append-self (fn append-self [prior t1]] ...)]
...) Or use #?(:clj
(defn ex-data-maps
"Given a Throwable, return in order the ExceptionInfo data maps for all items in the
chain that implement IExceptionInfo and have nonempty data maps."
[t]
(letfn [(append-self [prior t1]
(if t1
(append-self (conj prior t1) (.getCause ^Throwable t1))
prior))])
(->> t
(append-self [])
(into [] (comp (map ex-data)))))) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, changed in #458 |
||
[prior t1] | ||
(if t1 | ||
(append-self (conj prior t1) (.getCause ^Throwable t1)) | ||
prior)) | ||
[] | ||
t)] | ||
(into [] | ||
(comp (map ex-data)) | ||
throwables)))) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
(ns clara.test-infinite-loops | ||
(:require [clojure.test :refer :all] | ||
[clara.rules :refer :all] | ||
[clara.rules.testfacts :refer [->Cold ->Hot ->First ->Second]] | ||
[clara.tools.testing-utils :refer [def-rules-test | ||
ex-data-maps | ||
side-effect-holder-fixture | ||
side-effect-holder | ||
assert-ex-data]] | ||
[clara.tools.tracing :as tr] | ||
[clara.rules.accumulators :as acc] | ||
[clara.tools.loop-detector :as ld]) | ||
(:import [clara.rules.testfacts Cold Hot First Second] | ||
[clara.tools.tracing | ||
PersistentTracingListener])) | ||
|
||
(use-fixtures :each side-effect-holder-fixture) | ||
|
||
(defn throw-fn | ||
[] | ||
(throw (ex-info "Infinite loop suspected" {:clara-rules/infinite-loop-suspected true}))) | ||
|
||
(def-rules-test test-truth-maintenance-loop | ||
|
||
;; Test of an infinite loop to an endless cycle caused by truth maintenance, | ||
;; that is an insertion that causes its support to be retracted. | ||
|
||
{:rules [hot-rule [[[:not [Hot]]] | ||
(insert! (->Cold nil))] | ||
|
||
cold-rule [[[Cold]] | ||
(insert! (->Hot nil))]] | ||
|
||
:sessions [empty-session [hot-rule cold-rule] {}]} | ||
|
||
(let [watched-session (ld/with-loop-detection empty-session throw-fn 3000)] | ||
|
||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} (fire-rules watched-session)))) | ||
|
||
(def-rules-test test-truth-maintenance-loop-with-salience | ||
|
||
;; Test of an infinite loop to an endless cycle caused by truth maintenance, | ||
;; that is an insertion that causes its support to be retracted, when the | ||
;; two rules that form the loop have salience and are thus parts of different | ||
;; activation groups. | ||
|
||
|
||
{:rules [hot-rule [[[:not [Hot]]] | ||
(insert! (->Cold nil)) | ||
{:salience 1}] | ||
|
||
cold-rule [[[Cold]] | ||
(insert! (->Hot nil)) | ||
{:salience 2}]] | ||
|
||
:sessions [empty-session [hot-rule cold-rule] {} | ||
empty-session-negated-salience [hot-rule cold-rule] {:activation-group-fn (comp - :salience :props)}]} | ||
|
||
(doseq [session (map #(ld/with-loop-detection % throw-fn 3000) | ||
[empty-session empty-session-negated-salience])] | ||
;; Validate that the results are the same in either rule ordering. | ||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} (fire-rules session)))) | ||
|
||
|
||
(def-rules-test test-recursive-insertion | ||
|
||
;; Test of an infinite loop due to runaway insertions without retractions. | ||
|
||
{:rules [cold-rule [[[Cold]] | ||
(insert! (->Cold "ORD"))]] | ||
|
||
:sessions [empty-session [cold-rule] {}]} | ||
|
||
(let [watched-session (ld/with-loop-detection empty-session throw-fn 10)] | ||
|
||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} | ||
(-> watched-session | ||
(insert (->Cold "ARN")) | ||
;; Use a small value here to ensure that we don't run out of memory before throwing. | ||
;; There is unfortunately a tradeoff where making the default number of cycles allowed | ||
;; high enough for some use cases will allow others to create OutOfMemory errors in cases | ||
;; like these but we can at least throw when there is enough memory available to hold the facts | ||
;; created in the loop. | ||
(fire-rules {:max-cycles 10}))))) | ||
|
||
(def-rules-test test-recursive-insertion-loop-no-salience | ||
|
||
{:rules [first-rule [[[First]] | ||
(insert! (->Second))] | ||
|
||
second-rule [[[Second]] | ||
(insert! (->First))]] | ||
|
||
:sessions [empty-session [first-rule second-rule] {}]} | ||
|
||
(let [watched-session (ld/with-loop-detection empty-session throw-fn 10)] | ||
|
||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} | ||
(-> watched-session | ||
(insert (->First)) | ||
(fire-rules {:max-cycles 10}))))) | ||
|
||
(def-rules-test test-recursive-insertion-loop-with-salience | ||
|
||
{:rules [first-rule [[[First]] | ||
(insert! (->Second)) | ||
{:salience 1}] | ||
|
||
second-rule [[[Second]] | ||
(insert! (->First)) | ||
{:salience 2}]] | ||
|
||
:sessions [empty-session [first-rule second-rule] {} | ||
empty-session-negated-salience [first-rule second-rule] {:activation-group-fn (comp - :salience :props)}]} | ||
|
||
(doseq [session | ||
(map #(ld/with-loop-detection % throw-fn 10) | ||
[empty-session empty-session-negated-salience])] | ||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} | ||
(-> session | ||
(insert (->First)) | ||
(fire-rules {:max-cycles 10}))))) | ||
|
||
;; FIXME: Fix this before merging; it is a genuine issue to address not a test problem. | ||
;; (def-rules-test test-tracing-infinite-loop | ||
|
||
;; {:rules [cold-rule [[[Cold]] | ||
;; (insert! (->Cold "ORD"))]] | ||
|
||
;; :sessions [empty-session [cold-rule] {}]} | ||
|
||
;; (let [watched-session (ld/with-loop-detection empty-session throw-fn 10)] | ||
|
||
;; (try | ||
;; (do (-> watched-session | ||
;; tr/with-tracing | ||
;; (insert (->Cold "ARN")) | ||
;; ;; Use a small value here to ensure that we don't run out of memory before throwing. | ||
;; ;; There is unfortunately a tradeoff where making the default number of cycles allowed | ||
;; ;; high enough for some use cases will allow others to create OutOfMemory errors in cases | ||
;; ;; like these but we can at least throw when there is enough memory available to hold the facts | ||
;; ;; created in the loop. | ||
;; (fire-rules {:max-cycles 10})) | ||
;; (is false "The infinite loop did not throw an exception.")) | ||
;; (catch Exception e | ||
;; (let [data-maps (ex-data-maps e) | ||
;; loop-data (filter :clara-rules/infinite-loop-suspected data-maps)] | ||
;; (is (= (count loop-data) | ||
;; 1) | ||
;; "There should only be one exception in the chain from infinite rules loops.") | ||
;; (is (= (-> loop-data first :listeners count) 1) | ||
;; "There should only be one listener.") | ||
;; (is (-> loop-data first :listeners ^PersistentTracingListener first .-trace not-empty) | ||
;; "There should be tracing data available when a traced session throws an exception on an infinite loop.")))))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is still an issue with the listener approach? If we want that, I think that's a broader issue than just specifically having a trace when this max cycle logic throws. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If there is an infinite loop I think it is useful to able to use a trace to show which rules are looping. My approach in #458 allows the trace to be added. |
||
|
||
(def-rules-test test-max-cycles-respected | ||
|
||
;; As the name suggests, this is a test to validate that setting the max-cycles to different | ||
;; values actually works. The others tests are focused on validating that infinite loops | ||
;; throw exceptions, and the max-cycles values there are simply set to restrict resource usage, | ||
;; whether CPU or memory, by the test. Here, on the other hand, we construct a rule where we control | ||
;; how many rule cycles will be executed, and then validate that an exception is thrown when that number is | ||
;; greater than the max-cycles and is not when it isn't. For good measure, we validate that the non-exception-throwing | ||
;; case has queryable output to make sure that the logic was actually run, not ommitted because of a typo or similar. | ||
|
||
{:rules [recursive-rule-with-end [[[First]] | ||
(when (< @side-effect-holder 20) | ||
(do | ||
(insert-unconditional! (->First)) | ||
(swap! side-effect-holder inc)))]] | ||
|
||
:queries [first-query [[] [[?f <- First]]]] | ||
|
||
:sessions [empty-session [recursive-rule-with-end first-query] {}]} | ||
|
||
(reset! side-effect-holder 0) | ||
|
||
(assert-ex-data {:clara-rules/infinite-loop-suspected true} | ||
(-> (ld/with-loop-detection empty-session throw-fn 10) | ||
(insert (->First)) | ||
(fire-rules {:max-cycles 10}))) | ||
|
||
(reset! side-effect-holder 0) | ||
|
||
(is (= (count (-> (ld/with-loop-detection empty-session throw-fn 30) | ||
(insert (->First)) | ||
(fire-rules {:max-cycles 30}) | ||
(query first-query))) | ||
21))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any new functions added here are actually breaking changes to user-provided listeners since they will not be implementing these new functions and they will be called. That'll likely result on the JVM as a AbstractMethodError. This isn't an issue to be solved in this particular work. I'm just raising the general concern since it is applicable.
Aside: I have always been bothered with how this is what happens in Clojure when you add protocol functions. Even if you have Object type defaults for all functions, they'll be ignored and not inherited by individual protocol implementors on things like records.
It could be argued that you aren't supposed to define huge protocols in clj and that's where we are going wrong. However, even if this was split across N many protocols, we still have hardcoded assumptions in the engine that we can call every function them on the underlying implementations provided. However, more granularity could let implementors impl a smaller protocol and all the rest would be a default with an default eg. Object impl, so that sort of still works out.
Eg. we could have multiple protocols like:
I think an option that seems promising to alleviate this in the future is to wrap calls to user-provided listeners with a listener that Clara controls. This listener can catch the errors for unimplemented functions by the underlying listener. Constantly handling exceptions would not be ideal from a performance perspective, so this failure-case would likely need to be cached and remembered for subsequent calls. We could warn upon session creation - by checking all functions immediately - to assist in people upgrading. Or we could warn on first time we encounter failed call, however, that is less deterministic.
I'd have to double check, but we may be using the
DelegatingListener
already over user-supplied listeners, so it may already be in the position to do this.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with the premise here, we've had breaking changes to listeners in the past and while not a big deal it still impacts consumers.
It might be worth it to log an issue to look into ways that we can change listeners in the future to be less impactful. For now i think i am ok with adding a new method to this protocol, and announcing it as a breaking change, like we did in the past.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've logged #457 to look into a better approach to this with a thought on how to do it. For now I think we can do as Ethan suggested above.