Skip to content
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

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/main/clojure/clara/rules/engine.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -1739,7 +1739,9 @@
;; group before continuing.
(do
(flush-updates *current-session*)
(recur (mem/next-activation-group transient-memory) next-group))
(let [upcoming-group (mem/next-activation-group transient-memory)]
(l/activation-group-transition! listener next-group upcoming-group)
(recur upcoming-group next-group)))

(do

Expand Down Expand Up @@ -1808,7 +1810,7 @@
[]
(l/get-children p-listener)))
(catch #?(:clj Exception :cljs :default)
listener-exception
listener-exception
listener-exception))}
e)))))

Expand All @@ -1823,7 +1825,9 @@
;; updates and recur with a potential new activation group
;; since a flushed item may have triggered one.
(when (flush-updates *current-session*)
(recur (mem/next-activation-group transient-memory) next-group))))))
(let [upcoming-group (mem/next-activation-group transient-memory)]
(l/activation-group-transition! listener next-group upcoming-group)
(recur upcoming-group next-group)))))))

(deftype LocalSession [rulebase memory transport listener get-alphas-fn pending-operations]
ISession
Expand Down
7 changes: 7 additions & 0 deletions src/main/clojure/clara/rules/listener.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
(remove-activations! [listener node activations])
(fire-activation! [listener activation resulting-operations])
(fire-rules! [listener node])
(activation-group-transition! [listener original-group new-group])
Copy link
Collaborator

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:

IAlphaEventListener
IAccumEventListener
IActivationEventListener
IFireEventListener
<... etc ...>

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.

Copy link
Contributor

@EthanEChristian EthanEChristian Mar 15, 2020

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.

This isn't an issue to be solved in this particular work.

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.

Copy link
Collaborator Author

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.

(to-persistent! [listener]))

;; A listener that does nothing.
Expand Down Expand Up @@ -60,6 +61,8 @@
listener)
(fire-rules! [listener node]
listener)
(activation-group-transition! [listener original-group new-group]
listener)
(to-persistent! [listener]
listener)

Expand Down Expand Up @@ -136,6 +139,10 @@
(doseq [child children]
(fire-rules! child node)))

(activation-group-transition! [listener original-group new-group]
(doseq [child children]
(activation-group-transition! child original-group new-group)))

(to-persistent! [listener]
(delegating-listener (map to-persistent! children))))

Expand Down
2 changes: 2 additions & 0 deletions src/main/clojure/clara/tools/internal/inspect.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
listener)
(remove-activations! [listener node activations]
listener)
(activation-group-transition! [listener previous-group new-group]
listener)
(fire-rules! [listener node]
listener))

Expand Down
51 changes: 51 additions & 0 deletions src/main/clojure/clara/tools/loop_detector.cljc
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]
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fn-on-limit is named backwards to what to I expect - ie. on-limit-fn

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 on-limit, but it isn't bad to say "fn" to make it clear what it is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be more explicit it could also be said that on-limit-exceeded-fn would be more clear.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it just be called MaxCycleListener to be more explicit?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxCycleListener seems a bit vague from a new user perspective, CyclicalRuleListener perhaps...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do the arguments come in a different order for the fn-on-limit & max-cycles here than what they come in for the actual record constructor?
I'd prefer consistency. It seems more (subjectively) natural to have the fn arg come last in both cases.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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)))
16 changes: 16 additions & 0 deletions src/main/clojure/clara/tools/testing_utils.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,19 @@
e# \newline
"Non matches found: " \newline
res#)))))))

#?(:clj
(defn ex-data-maps
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?"

Given a throwable/exception/error `t`, return all non-empty `ex-data` maps from stack trace cause chain in the order they occur traversing the chain from this `t` on up the rest of the call stack.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 letfn for it to avoid repeating the name if that was the issue:

#?(: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))))))

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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))))
3 changes: 3 additions & 0 deletions src/main/clojure/clara/tools/tracing.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@
(fire-rules! [listener node]
(append-trace listener {:type :fire-rules :node-id (:id node)}))

(activation-group-transition! [listener previous-group new-group]
(append-trace listener {:type :activation-group-transition :new-group new-group :previous-group previous-group}))

(to-persistent! [listener]
(PersistentTracingListener. @trace)))

Expand Down
189 changes: 189 additions & 0 deletions src/test/clojure/clara/test_infinite_loops.clj
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."))))))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still an issue with the listener approach?
I'm not sure there necessarily should/can be a trace available when arbitrary exceptions are thrown during rule evaluation.

If we want that, I think that's a broader issue than just specifically having a trace when this max cycle logic throws.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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)))
7 changes: 4 additions & 3 deletions src/test/common/clara/tools/test_tracing.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@

;; Ensure expected events occur in order.
(is (= [:add-facts :alpha-activate :right-activate :left-activate
:add-activations :fire-activation :add-facts-logical]
:add-activations :fire-activation :add-facts-logical :activation-group-transition]
(map :type (t/get-trace session))))))

(def-rules-test test-insert-and-retract-trace
Expand All @@ -151,8 +151,9 @@
session-trace (t/get-trace session)]

;; Ensure expected events occur in order.
(is (= [:add-facts :alpha-activate :right-activate :left-activate :add-activations :fire-activation :add-facts-logical
:retract-facts :alpha-retract :right-retract :left-retract :remove-activations :retract-facts-logical]
(is (= [:add-facts :alpha-activate :right-activate :left-activate :add-activations :fire-activation
:add-facts-logical :activation-group-transition :retract-facts :alpha-retract :right-retract
:left-retract :remove-activations :retract-facts-logical]
(map :type session-trace)))

;; Ensure only the expected fact was indicated as retracted.
Expand Down