-
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
feat: throw condition exception on TestNode exception #471
feat: throw condition exception on TestNode exception #471
Conversation
I need to add some test cases, but working on this feature so that we get more detailed exceptions (at a minimum knowing the rule where the exception is thrown) |
Would it be possible to log an issue for this feature, and provide a sample of the current exception and an ideal message? edit: mainly for tracking purposes |
@EthanEChristian I created issue #472 with some details, also I will be adding unit tests for clarity. |
e7bdd1d
to
4c1fca8
Compare
New stacktrace printout:
|
|
||
IConditionNode | ||
(get-condition-description [this] | ||
(into [:test] (:constraints (meta test))))) |
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.
Might be interesting to have a test that confirms that this metadata is maintained across a serialization/deserialization.
I feel fairly confident that the metadata would survive as the entire form should be captured, but incase something changes in the future, a test to catch it would be good.
The test would likely need to be added somewhere in the durability test:
https://github.com/cerner/clara-rules/blob/main/src/test/clojure/clara/test_durability.clj
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.
Sure, I will add that in as we also rely serialization heavily; I'm testing it live today on our backend.
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.
@EthanEChristian I've added a test to ensure that the metadata is maintained across a serialization/deserialization, it worked as expected. I also tested this on a live system and worked as expected.
Overall, i think this makes sense and I am glad you were able to thread it into the pattern that we have for other node types. |
If this looks good to merge can you approve? Ideally I'd like to get this in and cut a release. |
`(let [handler# (fn ~fn-name [~'?__token__] | ||
(let [~@assignments] | ||
(and ~@constraints)))] | ||
(with-meta handler# |
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.
There may be a reason to be concerned here about the overhead of using with-meta
to wrap the fn.
with-meta
wraps the fn in a RestFn here https://github.com/clojure/clojure/blob/clojure-1.10.3/src/jvm/clojure/lang/AFunction.java#L29
which has the unfortunate consequence of making all calls to the wrapped fn go through variadic arg runtime unrolling via apply
https://github.com/clojure/clojure/blob/clojure-1.10.3/src/jvm/clojure/lang/AFunction.java#L31
The constraint fn's themselves may be called in hot spots so this can be concerning.
Microbench mark example (ran it a bunch of times to stabilize the numbers):
(let [f (fn [x] (inc x))
mf (with-meta f {:my :meta})]
(time (dotimes [_ 1e5]
(f 2)))
(time (dotimes [_ 1e5]
(mf 2))))
;;= "Elapsed time: 3.851125 msecs"
;;= "Elapsed time: 22.38039 msecs"
;;= nil
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.
True, there’s a bit of an overhead, the alternative approach I was considering was creating a protocol for the test expression handler instead of using a Fn directly so that it could carry the additional metadata needed for introspection at runtime, or just simply capturing that data when compile-test is called. It will require refactoring more tests but I can make an alternative PR with that approach.
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.
One possible way out of this sort of mess would be if this fn could just return this map instead and then
https://github.com/cerner/clara-rules/blob/014ed43ed2960d64db065a1fd108dce336aac960/src/main/clojure/clara/rules/compiler.clj#L1443
would use the :handler
value directly.
However, I think then there may be an issue with the durability layer persisting this meta information via serde...
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.
@mrrodriguez in my experience it gets persisted just fine because the durability layer persists the productions but removes the compiled portion, so it would recompile them whenever the session was reassembled.
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 have some comments and concerns.
Also, I'm wondering if the PR title is correct. My understanding of this issue is that there is a desire to add better error reporting for :test
nodes specifically correct?
@@ -494,7 +494,9 @@ | |||
(str "with bindings\n " bindings)) | |||
message-header (string/join ["Condition exception raised.\n" | |||
"when processing fact\n" | |||
(str " " (pr-str fact) "\n") | |||
(str " " (->> (flatten [fact]) |
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.
As a general principle I think it almost always is a red flag to need to use flatten
. What is arbitrary and out of control about the structure here that makes this necessary?
If the problem is just that sometimes fact
is not a fact, but rather a collection of facts, I think that should be handled explicitly to only unroll that top level.
Better yet, I think the name should just be changed to facts
and all callers should pass a collection. If they only have 1 they can put that in a coll too.
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.
Also is there any chance that this list of facts could be very large and cause a flood of output, eg. from accumulated coll values?
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.
@mrrodriguez I updated the PR title and description, I ended up rewriting the initial PR and targeting just the TestNode which is where there was missing exception handling logic; now, regarding the list of facts, this is the same possibility of flood of output with all the other Condition Exceptions where bindings are listed, because it is possible for the bindings used in a TestNode to be a list of accumulated facts all the same. I am wondering if I should change it so that the condition exception raised by the TestNode does not actually list facts because it does not really bind to any fact but it is purely dependent on the bindings and that is all that should be needed along with the condition/query encountering the exception.
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 that flatten shouldn't be used here. It's a blunt tool that makes it more difficult to understand the data structures being used and could hide logic errors, which is problematic with the complex data structures Clara passes around internally. :)
The need for this is coming from the :facts line in this code right though?
(throw-condition-exception {:cause e
:node node
:env env
:fact (keys (into {} (:matches token)))
:bindings (:bindings token)})
I don't think we need the :fact argument here though at all for TestNode as @k13gomez was considering above. Perhaps information on all previous facts in the rule network prior to reaching the error-throwing node, regardless of whether they were bound to variables, could be a useful addition in some use cases, but I don't think that's what we're currently doing for join nodes for example: https://github.com/cerner/clara-rules/blob/8107a5ab7fdb475e323c0bcb39084a83454deb1c/src/main/clojure/clara/rules/engine.cljc#L669
If we did want that I'd suggest representing that as something other than :fact but IMO is out of scope here.
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 think it makes sense to remove the fact
altogether for the :test
node so I will be doing that.
@@ -937,15 +950,19 @@ | |||
memory | |||
listener | |||
children | |||
(filter test tokens))) | |||
(filter (partial test-node-matches node test {}) tokens))) |
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.
FYI: partial
degrades performance possibly significantly beyond it's 4 arity case. So this is pushing it right up against the limit.
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.
Good point, I'm going to remove the env
argument since it doesn't really get used, I was just sticking to the existing pattern. Another possibility is to write this as an eager-for comprehension, so I'm going to give that a try.
(is (= {:?w 10, :?t nil} | ||
(-> e (ex-data) :bindings)))))) | ||
|
||
(deftest test-local-scope-visible-in-test-filter |
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'm confused by this test case's name since it seems to be checking that the :test
node is throwing an exception due to the ?t
value being nil right?
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'll give it a more descriptive name and actually add additional assertions since there's a couple more things I want to check on this exception.
(fire-rules) | ||
(query check-exception)) | ||
(is false "Scope binding test is expected to throw an exception.") | ||
(catch Exception e e))] |
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.
Shouldn't there be some validation that the exception has the useful information that was added in this PR?
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.
Yes, I'll add additional assertions to validate that.
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.
Here are some similar tests in case you haven't seen them: https://github.com/cerner/clara-rules/blob/8107a5ab7fdb475e323c0bcb39084a83454deb1c/src/test/clojure/clara/test_rules.clj#L2020
`(let [handler# (fn ~fn-name [~'?__token__] | ||
(let [~@assignments] | ||
(and ~@constraints)))] | ||
(with-meta handler# |
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.
One possible way out of this sort of mess would be if this fn could just return this map instead and then
https://github.com/cerner/clara-rules/blob/014ed43ed2960d64db065a1fd108dce336aac960/src/main/clojure/clara/rules/compiler.clj#L1443
would use the :handler
value directly.
However, I think then there may be an issue with the durability layer persisting this meta information via serde...
Thanks for all the feedback, I’ll work on this over the next few weeks, I think some of the questions and concerns should also lead to some new test cases. |
@@ -494,7 +494,9 @@ | |||
(str "with bindings\n " bindings)) | |||
message-header (string/join ["Condition exception raised.\n" | |||
"when processing fact\n" | |||
(str " " (pr-str fact) "\n") | |||
(str " " (->> (flatten [fact]) |
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 that flatten shouldn't be used here. It's a blunt tool that makes it more difficult to understand the data structures being used and could hide logic errors, which is problematic with the complex data structures Clara passes around internally. :)
The need for this is coming from the :facts line in this code right though?
(throw-condition-exception {:cause e
:node node
:env env
:fact (keys (into {} (:matches token)))
:bindings (:bindings token)})
I don't think we need the :fact argument here though at all for TestNode as @k13gomez was considering above. Perhaps information on all previous facts in the rule network prior to reaching the error-throwing node, regardless of whether they were bound to variables, could be a useful addition in some use cases, but I don't think that's what we're currently doing for join nodes for example: https://github.com/cerner/clara-rules/blob/8107a5ab7fdb475e323c0bcb39084a83454deb1c/src/main/clojure/clara/rules/engine.cljc#L669
If we did want that I'd suggest representing that as something other than :fact but IMO is out of scope here.
(is (= [[:test '(not-empty ?ts)]] | ||
(map eng/get-condition-description (get-test-nodes s)) | ||
(map eng/get-condition-description (get-test-nodes restored1)) | ||
(map eng/get-condition-description (get-test-nodes restored2))))))) |
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 suggest directly validating that the restored TestNode still works. Something like the following perhaps:
- Call fire-rules on the deserialized session and validate that there is no TemperatureHistory.
- Insert a Temperature into the deserialized session, call fire-rules, and validate that there is a TemperatureHistory fact.
I see that there is https://github.com/cerner/clara-rules/blob/fb171cbe36ce766ebd20d625e87ee1dab9899161/src/test/clojure/clara/test_durability.clj#L236 but it looks like that is targeted at testing the memory SerDe rather than the handler.
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 improved this test it now fires and then re-fires the serialized session as described above.
(fire-rules) | ||
(query check-exception)) | ||
(is false "Scope binding test is expected to throw an exception.") | ||
(catch Exception e e))] |
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.
Here are some similar tests in case you haven't seen them: https://github.com/cerner/clara-rules/blob/8107a5ab7fdb475e323c0bcb39084a83454deb1c/src/test/clojure/clara/test_rules.clj#L2020
I've made several changes to address the PR feedback, please take a second look at the current state of this change |
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 had a couple minor comments/thoughts but basically this looks good and I think it could be merged as is.
(str "with bindings\n " bindings)) | ||
facts-description (if-not (contains? args :fact) |
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.
Minor: IMO this would perhaps be a bit easier to read as
(if (contains? args :fact)
(str "when processing fact\n " (pr-str fact)
"with no fact"))
in that avoiding the negation is one less step to follow. Not a big deal though and perhaps just a matter of personal taste.
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.
good suggestion, makes sense, I think i will do that.
:fact nil | ||
:env {} | ||
:conditions-and-rules {[:test '(> ?t ?w)] #{[:query "my-test-query"]}}} | ||
(-> e (ex-data)))) |
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.
Generally we've used assert-ex-data in cases like this, the difference in behavior being that it just requires that some exception in the cause chain match the assertion, rather than the first one. This can make tests less brittle to failure when an exception is wrapped later for unrelated reasons. We don't have that functionality for the message though at this time, and I see value in testing the exception message since there is nontrivial logic there now. Also exceptions in e.g. the compiler would be more likely to be wrapped later, making such handling more important for tests aimed there. I'm OK with either keeping this as is or changing it along those lines; adding new test helpers here seems like scope creep.
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.
It's not too difficult to make assert-ex-data also be able to assert the message, as an additional optional parameter, I'm going to make that change and submit it, it will make it easier to test both message and ex-data of nested exceptions in the future.
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.
@WilliamParker i've made a small change to assert-ex-data
to allow checking the message along with the data as well, and updated the existing test to use the new (assert-ex-data "message..." {...ex-data...} form)
.
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.
+1 to those changes.
8ea7c0a
to
f59c847
Compare
ensure we throw a more detailed condition exception when an exception is encountered during the evaluation of a TestNode expression
Closes #472