Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4e1c59
update node logic
sovelten May 29, 2025
cb15457
add generated llm claude-4-sonnet tests
joaopluigi Jun 12, 2025
1346cbe
review generated llm claude-4-sonnet tests
joaopluigi Jun 26, 2025
d8180dc
add generated llm tests on api-test namespace
joaopluigi Jul 3, 2025
83fc8dc
rm comment code
joaopluigi Jul 3, 2025
6b091bb
fix lint
joaopluigi Jul 3, 2025
84a1c01
add first version of try-env macro
joaopluigi Jul 31, 2025
1a557d9
wip: completable future antics
aredington Aug 21, 2025
fc1c5a9
passings tests with cf based function composition
aredington Aug 21, 2025
0fedad2
Append to tail not head
aredington Aug 21, 2025
8400792
Get rid of type shenanigans, just add catching and indirection
aredington Sep 4, 2025
98d9d99
write test for with-error-handler, try to work on try-macro (wip)
sovelten Sep 11, 2025
620dae0
fix test
sovelten Sep 11, 2025
b29857c
initial implementation of with-try macro
joaopluigi Sep 18, 2025
3e37a8a
add working version of with-try macro with some initial tests
joaopluigi Oct 13, 2025
e770e0d
Quote prevents aliases from resolving
aredington Oct 16, 2025
0d75eb3
lint fix
aredington Oct 16, 2025
b5bd14d
clean up code and make tests use engine-test-suite
joaopluigi Oct 16, 2025
140e42d
fix lint
joaopluigi Oct 16, 2025
30d8064
Update nodely to 2.0.3
sovelten Oct 30, 2025
661ad89
Update CHANGELOG.md
sovelten Oct 30, 2025
61c616e
Make impl details private, add docstring to `with-try`
aredington Oct 30, 2025
7dfedb4
Document `with-try` in Readme
aredington Oct 30, 2025
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions project.clj
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
(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"}

: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"]
Expand Down
1 change: 1 addition & 0 deletions src/nodely/api/v0.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions src/nodely/data.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/nodely/engine/applicative.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))))
37 changes: 37 additions & 0 deletions test/nodely/api_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
59 changes: 59 additions & 0 deletions test/nodely/data_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))))))))
Loading