diff --git a/CHANGELOG.md b/CHANGELOG.md index 381cd83..9a307e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2.0.3 / 2024-11-14 +- Add with-try macro to nodely.api.v0, enabling adding an exception handler globally to an environment. + ## 2.0.2 / 2024-11-14 - Remove the default engine behavior in internal applicative eval functions diff --git a/README.md b/README.md index 2001d3d..5be1c22 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,30 @@ Let's say that `x` and `y` are actually expensive http requests. What we want is This will evaluate the node by realizing all required dependencies, without executing the dependencies that are not needed. For instance, if x is even, y is not evaluated at all. +#### Environment Defined Exception Handling + +Nodely offers the `with-try` macro to provide an environment-level +policy for exception handling. This tool is essentially coarse grained +and will apply an exception handler to every leaf, branch, and +sequence node in the environment provided to it. The syntax mirrors +that of Clojure's `(try ... (catch ...))` special forms, e.g. + +```clojure + (with-try {:a (>leaf (/ 5 ?b)) + :b (>value 0)} + (catch ArithmeticException _ Double/NaN) + (catch NullPointerException _ 0)) +``` + +Multiple catch clauses may be specified to perform type based +dispatching of which expression an exceptional case should +trigger. + +This is offered as a tool for setting exceptional policy independently +of specifying environments; Nodely clients are advised to prefer +handling exceptional cases explicitly in the implementations of leaf, +branch and sequence functions when possible. + #### Testing Using `eval-node-with-values` is the most straightforward way to evaluate a node by supplying actual values for the data dependencies. If we want to test `branch-node` without actually making the expensive calls, it is as simple as that: diff --git a/project.clj b/project.clj index 984ca87..c6a5df6 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject dev.nu/nodely "2.0.2" +(defproject dev.nu/nodely "2.0.3" :description "Decoupling data fetching from data dependency declaration" :url "https://github.com/nubank/nodely" :license {:name "MIT"} @@ -6,7 +6,7 @@ :plugins [[s3-wagon-private "1.3.4" :exclusions [com.fasterxml.jackson.core/jackson-core]] [com.fasterxml.jackson.core/jackson-core "2.12.4"]] - :dependencies [[org.clojure/clojure "1.10.3"] + :dependencies [[org.clojure/clojure "1.12.0"] [aysylu/loom "1.0.2"] [org.clojure/core.async "1.5.648" :scope "provided"] [funcool/promesa "10.0.594" :scope "provided"] diff --git a/src/nodely/api/v0.clj b/src/nodely/api/v0.clj index 90c6c05..58b92dd 100644 --- a/src/nodely/api/v0.clj +++ b/src/nodely/api/v0.clj @@ -20,6 +20,7 @@ nodely.data/leaf nodely.data/sequence nodely.data/branch + nodely.data/with-try engine-core/checked-env) (import-fn nodely.engine.lazy/eval-node-with-values eval-node-with-values) diff --git a/src/nodely/data.clj b/src/nodely/data.clj index f92f8ef..b96d338 100644 --- a/src/nodely/data.clj +++ b/src/nodely/data.clj @@ -116,10 +116,116 @@ :sequence (recur (::process-node node) (conj inputs (::input node)))))) +(defn catching + "Like `clojure.core/comp`, except only affects throw completions of + the wrapped `g`. `f` must be a function of a single Exception, and + will be passed the exception thrown from `g` if `g` completes + exceptionally." + [f g] + (fn [& args] + (try (apply g args) + (catch Throwable t (f t))))) + +(defn update-leaf + [leaf f compositor] + ;; f new function, arg is old function + (update leaf :nodely.data/fn #(compositor f %))) + +(declare env-update-helper) + +(defn update-branch + [{::keys [condition truthy falsey]} + f + {:keys [apply-to-condition?] + :or {apply-to-condition? false} :as opts} + compositor] + #::{:type :branch + :condition (if apply-to-condition? + (env-update-helper condition f opts compositor) + condition) + :falsey (env-update-helper falsey f opts compositor) + :truthy (env-update-helper truthy f opts compositor)}) + +(defn update-sequence + [sequence f compositor] + (update sequence ::process-node env-update-helper f {} compositor)) + +(defn env-update-helper + [node f opts compositor] + (case (::type node) + :value (update node ::value f) + :leaf (update-leaf node f compositor) + :branch (update-branch node f opts compositor) + :sequence (update-sequence node f compositor))) + +(defn update-node + ([node f opts] + (env-update-helper node f opts comp)) + ([node f] + (update-node node f {}))) + +(defn catch-node + ([node f opts] + (env-update-helper node f opts catching)) + ([node f] + (catch-node node f {}))) + ;; ;; Env Utils ;; +(defn with-error-handler + [env handler] + (update-vals env #(catch-node % handler {:apply-to-condition? true}))) + +(defn- tuple-to-handler + [m] + (fn [error] + (if-let [f (some (fn [[ex-class handler]] (when (instance? ex-class error) handler)) m)] + (f error) + (throw error)))) + +(defn- with-try-expr + [clauses] + (let [clauses (into [] (for [[c t s expr] clauses] + (do (assert (= c 'catch)) + (if-let [t (resolve t)] + [t (eval `(fn [~s] ~expr))] + (throw (ex-info (str "Could not resolve exception class: " t) {:type t}))))))] + clauses)) + +(defmacro with-try + "Macro + + `with-try` will apply an error handling semantic to every leaf, + branch, and sequence node in a provided environment. If any such + nodes throw an exception, the catch expressions described in the + body of `with-try` will be evaluated. The resulting value of the + matching catch clause will become the value of the node which threw + the exception. + + `with-try` has syntax equivalent to Clojure's `try` special form for + `catch` clauses but does not currently support use of a `finally` + clause. + + `with-try` creates a policy across an entire environment + indiscriminately, when it is possible, clients are advised to catch + exceptional cases in the implementations of leaf/branch/sequence + nodes explicitly. + + example: + + (with-try {:a (>leaf (/ 5 ?b)) + :b (>value 0)} + (catch ArithmeticException _ Double/NaN) + (catch NullPointerException _ 0)) + + will result in `:a` evaluating to 0" + [env & body] + `(with-error-handler + ~env + (tuple-to-handler ~(with-try-expr body)))) + (s/defn get-value :- s/Any [env :- Env k :- s/Keyword] diff --git a/src/nodely/engine/applicative.clj b/src/nodely/engine/applicative.clj index dbf6726..0c18820 100644 --- a/src/nodely/engine/applicative.clj +++ b/src/nodely/engine/applicative.clj @@ -97,4 +97,4 @@ (merge env (reduce (fn [acc [k v]] (assoc acc k (protocols/-extract v))) {} - (lazy-env/scheduled-nodes lazy-env))))) + (lazy-env/scheduled-nodes lazy-env))))) \ No newline at end of file diff --git a/test/nodely/api_test.clj b/test/nodely/api_test.clj index ccc50de..7cccda0 100644 --- a/test/nodely/api_test.clj +++ b/test/nodely/api_test.clj @@ -246,3 +246,40 @@ :supported-engine-names set?} (try (api/eval-node-channel env (>leaf (inc ?z)) {::api/engine :core.async.doesnt-exist}) (catch clojure.lang.ExceptionInfo e (ex-data e))))))) + +(defn with-try-engine-test-suite + [engine-key] + (t/testing (name engine-key) + (t/testing "Exception types must be resolvable at with-try expansion time" + (t/matching "Could not resolve exception class: NonSenseException" + (try + (eval '(nodely.api.v0/with-try exceptions-all-the-way-down + (catch NonSenseException _ 0))) + (catch clojure.lang.Compiler$CompilerException e + (ex-message (.getCause e)))))) + + (t/testing "with-try catches exceptions informed by class inheritance and catch clause ordering" + (t/matching 0 + (api/eval-key + (api/with-try exceptions-all-the-way-down + (catch Throwable _ -3) + (catch clojure.lang.ExceptionInfo _ 0)) + :d {::api/engine engine-key}))) + + (t/testing "with-try order of clauses can preempt inheritance" + (t/matching 0 + (api/eval-key + (api/with-try exceptions-all-the-way-down + (catch clojure.lang.ExceptionInfo _ -3) + (catch Throwable _ 0)) + :d {::api/engine engine-key}))))) + +(t/deftest with-try + (let [remove-keys (conj #{:core-async.iterative-scheduling + :async.virtual-futures} + (when (try (import java.util.concurrent.ThreadPerTaskExecutor) + (catch Throwable t t)) + :applicative.virtual-future))] + (for [engine (set/difference (set (keys api/engine-data)) + remove-keys)] + (with-try-engine-test-suite engine)))) diff --git a/test/nodely/data_test.clj b/test/nodely/data_test.clj index d631769..742dc74 100644 --- a/test/nodely/data_test.clj +++ b/test/nodely/data_test.clj @@ -26,3 +26,62 @@ (data/leaf [:a] identity)) (data/leaf [:b] identity) (data/leaf [:c] identity))))))) + +(deftest update-node-test + (testing "update-node with value node" + (let [value-node (data/value 42) + updated-node (data/update-node value-node (partial * 2))] + (is (= (::data/type updated-node) :value)) + (is (= 84 (::data/value updated-node))))) ; (* 2 42) = 84 + + (testing "update-node with leaf node" + (let [leaf-node (data/leaf [:x] (comp inc :x)) + updated-node (data/update-node leaf-node (partial * 2))] + (is (= (::data/type updated-node) :leaf)) + (is (= (::data/inputs updated-node) #{:x})) + (is (= 12 ((::data/fn updated-node) {:x 5}))))) ; (* 2 (inc 5)) = 12 + + (testing "update-node with branch node" + (let [condition (data/leaf [:x] (comp even? :x)) + truthy (data/value 10) + falsey (data/value 20) + branch-node (data/branch condition truthy falsey) + updated-node (data/update-node branch-node (partial * 2))] + (is (= (::data/type updated-node) :branch)) + ;; Condition should not be updated by default + (is (= (::data/condition updated-node) condition)) + ;; Truthy and falsey should be updated + (is (= 20 (::data/value (::data/truthy updated-node)))) ; (* 2 10) = 20 + (is (= 40 (::data/value (::data/falsey updated-node)))))) ; (* 2 20) = 40 + + (testing "update-node with branch node and apply-to-condition option" + (let [condition (data/leaf [:x] (comp inc :x)) + truthy (data/value 10) + falsey (data/value 20) + branch-node (data/branch condition truthy falsey) + updated-node (data/update-node branch-node (partial * 2) {:apply-to-condition? true})] + (is (= (::data/type updated-node) :branch)) + ;; All parts should be updated + (is (= 12 ((::data/fn (::data/condition updated-node)) {:x 5}))) ; (* 2 (inc 5)) = 12 + (is (= 20 (::data/value (::data/truthy updated-node)))) ; (* 2 10) = 20 + (is (= 40 (::data/value (::data/falsey updated-node)))))) ; (* 2 20) = 40 + + (testing "update-node with sequence node" + (let [sequence-node (data/sequence :items 5) + updated-node (data/update-node sequence-node (partial * 2))] + (is (= (::data/type updated-node) :sequence)) + (is (= (::data/input updated-node) :items)) + (is (= 10 (::data/value (::data/process-node updated-node)))))) ; (* 2 5) = 10 + + (testing "update-node with 2-arity (no options)" + (let [leaf-node (data/leaf [:x] (comp inc :x)) + updated-node (data/update-node leaf-node (partial * 2))] + (is (= (::data/type updated-node) :leaf)) + (is (= (::data/inputs updated-node) #{:x})) + (is (= 12 ((::data/fn updated-node) {:x 5})))))) + +(deftest with-error-handler-test + (testing "with-error-handler wraps leaf functions to handle thrown exceptions" + (let [throw-env {:a (data/leaf #{} (fn [] (throw (ex-info "OOps" {}))))} + handled-env (data/with-error-handler throw-env (fn [^Throwable _] :handled))] + (is (= :handled ((::data/fn (get handled-env :a))))))))