A-frame started life as a port of the re-frame event and effect handling machinery to the async domain.
It has evolved to become even more data-driven, and to give greater control over event processing. It is being used for varied back-end event-processing tasks, including a business game engine and vanilla API handling.
They tell you to keep your side-effecting code minimal and away from your pure code. A-frame helps you to do it. It's not as posh as a freer monad based effects thingy, but it's very data-driven, has an easy to understand model, and is easy to observe.
The term "simple" data is used below - it means data containing no opaque objects - i.e. no functions or other opaque types. Roughly anything that can easily be serialised and deserialised to/from EDN or JSON.
A-frame uses a similar event-processing model to re-frame - events are usually handled in a 3 stage process:
[gather coeffects] -> [handle event] -> [process effects] As in re-frame, this process is implemented with an interceptor chain. Unlike re-frame, the a-frame interceptor chain is:
- asynchronous - the
:enter,:leaveand:errorfns in any interceptor may return a promise of their result. - fully data-driven - the interceptor chains themselves are described by simple data. Since
events,coeffectsandeffectsare also simple data, the entire state of the interceptor chain at any point is fully serialisable.
A typical event-handler interceptor chain looks like this:
-> [enter: ] -> [enter: coeffect-a] -> [enter: coeffect-b] -> [enter: handle-event] --| <- [leave: handle-effects] <- [leave: ] <- [leave: ] <- [leave: ] <-| The final interceptor in the chain contains the event-handler. An event-handler is always a pure function with no side-effects. Prior interceptors contain coeffect or effect handlers, which may have side-effects and may return a promise of their result.
Events are usually simple maps describing something that happened. An event map must have an :a-frame/id key, which describes the type of the event and will be used to find a handler for processing the event.
Events may also be simple vectors of [<id> ...], as they generally are in re-frame. This form is less preferred because it makes literal paths referencing data in the events harder to read.
Event handler functions have a signature:
(fn [<coeffects> <event>])
Coeffects are a simple data map representing inputs gathered from the environment and required by the event-handler. A particular event handler has a chain of coeffect handlers, each of which identifies a particular coeffect handler by keyword.
Coeffect handler functions have a signature:
(fn ([<app> <coeffects>]) ([<app> <coeffects> <data>]))
The arity providing the <data> arg allows data gathered from previous coeffects and the event to be given to the coeffect handler.
Effects are a simple datastructure describing outputs from the event handler. Effects are either a map of {<effect-key> <effect-data>}, indicating concurrent processing of offects, or a vector of such maps - requiring sequential processing. The <effect-key> keywords are used to find a handler for a particular effect.
Effect handler functions have a signature:
(fn [<app> <effect-data>] )
This example defines a ::get-foo event, to service some imaginary GET /foo API, which uses a ::load-foo cofx to load the object and then returns the loaded object as an :api/response effect.
(require '[a-frame.core :as af]) (require '[a-frame.std-interceptors :as af.stdintc]) (require '[a-frame.multimethods :as mm]) (require '[malli.core :as m]) (af/reg-cofx ::load-foo (fn [;; app context {api-client :api :as app} ;; other coeffects _coeffects ;; resolved data arg {id :id url :url}] {:id (str url "/" id) :name "foo" :client api-client})) ;; since we can't resolve vars from keywords on cljs, and we don't want ;; any opaque objects in our simple data, we use a multimethod ;; to specify the schema validation in inject-validated-cofx (defmethod mm/validate ::foo [_ value] (m/validate [:map [:id :string] [:name :string]] value)) (af/reg-event-fx ::get-foo ;; inject the ::load-foo cofx with an arg resolved from ;; the event and other cofx, validate the value ;; conforms to schema ::foo [(af/inject-validated-cofx ::load-foo {:id #af/event-path ::foo-id :url #af/cofx-path [:config :api-url]} ::foo)] (fn [{foo ::load-foo :as coeffects} event] ;; uncomment this throw to see error reporting in action ;; (throw (ex-info "boo" {})) {:api/response {:foo foo}})) (def router (af/create-router ;; app context for opaque objects like network clients {:api ::api} ;; global interceptors are prepended to every event's ;; interceptor-chain {:a-frame.router/global-interceptors af.stdintc/minimal-global-interceptors})) (def r (af/dispatch-sync router ;; optional initial coeffects {:config {:api-url "http://foo.com/api"}} ;; the event {:a-frame/id ::get-foo ::foo-id "1000"})) ;; unpick deref'ing a promise only works on clj (-> @r :a-frame/effects :api/response) ;; => {:foo {:id "http://foo.com/api/1000", :name "foo" :client :user/api}}of interest is the interceptor history log, which details the full interceptor execution history:
(-> @r :a-frame.interceptor-chain/history) ;; => [[:a-frame.std-interceptors/unhandled-error-report :a-frame.interceptor-chain/enter :a-frame.interceptor-chain/noop :_ :a-frame.interceptor-chain/success] [{:a-frame.interceptor-chain/key :a-frame.cofx/inject-validated-cofx, :a-frame.cofx/id :user/load-foo, :a-frame.cofx/path :user/load-foo, :a-frame.cofx/schema :user/foo, :a-frame.cofx/arg {:id #a-frame.ctx/path [:a-frame/coeffects :a-frame.coeffect/event :user/foo-id], :url #a-frame.ctx/path [:a-frame/coeffects :config :api-url]}} :a-frame.interceptor-chain/enter :a-frame.interceptor-chain/execute {:id "1000", :url "http://foo.com/api"} :a-frame.interceptor-chain/success] [{:a-frame.interceptor-chain/key :a-frame.std-interceptors/fx-event-handler, :a-frame.std-interceptors/pure-handler-key :user/get-foo} :a-frame.interceptor-chain/enter :a-frame.interceptor-chain/execute :_ :a-frame.interceptor-chain/success] [{:a-frame.interceptor-chain/key :a-frame.std-interceptors/fx-event-handler, :a-frame.std-interceptors/pure-handler-key :user/get-foo} :a-frame.interceptor-chain/leave :a-frame.interceptor-chain/noop :_ :a-frame.interceptor-chain/success] [{:a-frame.interceptor-chain/key :a-frame.cofx/inject-validated-cofx, :a-frame.cofx/id :user/load-foo, :a-frame.cofx/path :user/load-foo, :a-frame.cofx/schema :user/foo, :a-frame.cofx/arg {:id #a-frame.ctx/path [:a-frame/coeffects :a-frame.coeffect/event :user/foo-id], :url #a-frame.ctx/path [:a-frame/coeffects :config :api-url]}} :a-frame.interceptor-chain/leave :a-frame.interceptor-chain/noop :_ :a-frame.interceptor-chain/success] [:a-frame.std-interceptors/unhandled-error-report :a-frame.interceptor-chain/leave :a-frame.interceptor-chain/noop :_ :a-frame.interceptor-chain/success]]each log entry has the form:
[<interceptor-spec> <interceptor-fn> <action> <data-arg> <outcome>]
so looking at the second entry, which refers to the ::load-foo cofx:
[{:a-frame.interceptor-chain/key :a-frame.cofx/inject-validated-cofx, :a-frame.cofx/id :user/load-foo, :a-frame.cofx/path :user/load-foo, :a-frame.cofx/schema :user/foo, :a-frame.cofx/arg {:id #a-frame.ctx/path [:a-frame/coeffects :a-frame.coeffect/event :user/foo-id], :url #a-frame.ctx/path [:a-frame/coeffects :config :api-url]}} :a-frame.interceptor-chain/enter :a-frame.interceptor-chain/execute {:id "1000", :url "http://foo.com/api"} :a-frame.interceptor-chain/success]both the specification of the cofx data arg :a-frame.cofx/arg and the resolved <data-arg> can be seen.
Whenever an error occurs during interceptor-chain processing the following things happen:
- the current operation is halted
- the causal exception is wrapped in an
ex-infowith a full description of the state of the interceptor-chain when the error happened, - the rest (if any) of the queue of interceptors is discarded
- the stack of entered interceptors is unwound, calling
errorinstead ofleave, until either the error is handled or there are no remaining interceptors on the stack. - if the error was handled, unwinding proceeds with
leave - if the error was not handled the descriptive
ex-infois thrown
Both the minimal global interceptors and the default global interceptors include the a-frame.std-interceptors/unhandled-error-report interceptor which logs a human-readable report on the error, and re-throws the informative ex-info.
The ex-info includes the full interceptor-context after the error finished processing, so the causal exception along with the :a-frame.interceptor-chain/history key can be inspected for clues as to what went wrong. The interceptor-context from just before the error occured is also included in the :a-frame.interceptor-chain/resume key - this is called the "resume-context".
It is possible to try re-executing the problematic operation either by supplying the ex-info or the resume-context to the resume fn:
(a-frame.interceptor-chain/resume <app-ctx> <a-frame-router> <a-frame-ex-info-or-resume-context>)
Since the resume-context is just simple data, the operation can be resumed in a different VM or even machine from that where the original failure happened - as long as any data references in the resume context are resolvable.
The :a-frame.fx/do-fx interceptor will handle all effects. It is prepended to every event's interceptor-chain by the default global interceptors a-frame.std-interceptors/default-global-interceptors.
If you don't want to handle effects immediately, perhaps because you want to write the effects to a Kafka topic or db table for later handling, then you can specify the minimal global interceptors a-frame.std-interceptors/minimal-global-interceptors which will do nothing with effects generated by the event handler.
If you want something in-between - maybe handling some effects immediately, and leaving some for later, then you could specify a custom interceptor.
Logging can be difficult with asynchronous operations - stack traces get erased and dymamic variables don't work reliably, so when multiple operations are proceeding concurrently it can be difficult to narrow a log stream to just the lines relating to a single logical operation.
A-frame provides a set-log-context interceptor, which adds a log context value into the interceptor chain. The logging macros in a-frame.log can then be used to log with context.
taoensso.timbre is currently used for logging, since it's the only common clojure/script logging library which supports logging with context.
You can call a-frame.log.timbre/configure-timbre to add an output-fn to timbre's println appender which will print the context value, leading to log entries like this one produced by the a-frame.std-interceptors/unhandled-error-report interceptor:
2023-05-23T11:12:53.852Z ERROR [a-frame.std-interceptors:168] [ForkJoinPool.commonPool-worker-15] [id:c35eb490-f95a-11ed-b7a0-ccb8a4033a35] - a-frame unhandled error: the context value - in this case [id:c35eb490-f95a-11ed-b7a0-ccb8a4033a35] - will be present on all log entries for the interceptor chain, no matter that individual interceptor functions are executed on different threads.
- Support for OpenTelemetry tracing and logging would make sense, maybe via clj-otel
- the
dispatch-syncfx can model tail-recursion, but adispatch-synccofx could be used to model regular recursion, returning a result to the coeffects