ribol

conditional restarts for clojure


Author: Chris Zheng  (z@caudate.me)
Library: v0.3.3
Date: 31 October 2013
Website: http://www.github.com/zcaudate/ribol
Generated By: MidjeDoc


1   Overview

  1.1   Installation

2   Unlucky Numbers

3   Conditional Restarts

  3.1   Raising issues
  3.2   Managing issues

4   API Reference

  4.1   raise
    4.1.1   hash-map
    4.1.2   keyword
    4.1.3   vector
    4.1.4   option/default
  4.2   manage/on
    4.2.1   checkers
    4.2.2   bindings
    4.2.3   catch and finally
  4.3   special forms
    4.3.1   continue
    4.3.2   fail
    4.3.3   choose
    4.3.4   default
    4.3.5   escalate
  4.4   hooks
    4.4.1   raise-on
    4.4.2   raise-on-all
    4.4.3   anticipate

5   Control Strategies

  5.1   Normal
    5.1.1   No Raise
    5.1.2   Issue Raised
  5.2   Catch
    5.2.1   First Level Catch
    5.2.2   Second Level Catch
  5.3   Continue
    5.3.1   First Level Continue
    5.3.2   Second Level Continue
  5.4   Choose
    5.4.1   Choose Lower-Level
    5.4.2   Choose Upper-Level
  5.5   Choose - More Strategies
    5.5.1   Overridding An Option
    5.5.2   Default Option
    5.5.3   Overriding Defaults
  5.6   Escalate
    5.6.1   Simple Escalation
    5.6.2   Escalation with Options
  5.7   Fail
  5.8   Default
    5.8.1   Escalation with Defaults
  5.9   Branch Using On

6   Implementation

  6.1   The Workplace
    6.1.1   The dumb throw
    6.1.2   The smart raise
    6.1.3   The proactive workplace
  6.2   The Issue Management Board
    6.2.1   Control Flow as Data
    6.2.2   Implementing Catch
    6.2.3   Implementing Choose
    6.2.4   Implementing the Rest

7   End Notes


ribol

conditional restarts for clojure


Author: Chris Zheng  (z@caudate.me)
Library: v0.3.3
Date: 31 October 2013
Website: http://www.github.com/zcaudate/ribol
Generated By: MidjeDoc


1    Overview

This is quite a comprehensive guide to ribol and how conditional restart libraries may be used. There are currently three other conditional restart libraries for clojure:

A simple use case looking at the advantage in using restarts over exceptions can be seen in Unlucky Numbers. There is also a Core library API with examples.

For those that wish to know more about conditional restarts, a comparison of different strategies that can be implemented is done in Control Strategies. While for those curious about how this jumping around has been achieved, look at Implementation.

1.1    Installation

Add to project.clj dependencies (use double quotes):

[im.chit/ribol '0.3.3']

All functionality is found contained in ribol.core

(use 'ribol.core)

2    Unlucky Numbers

In this demonstration, we look at how code bloat problems using throw/try/catch could be reduced using raise/manage/on. Two functions are defined:

(defn check-unlucky [n]
  (if (#{4 13 14 24 666} n)
    (throw (RuntimeException. "Unlucky Number"))
    n))

(defn int-to-str [n]
  (do (Thread/sleep 10)  ;; Work out something
      (str (check-unlucky n))))
(int-to-str 1) => "1"

(int-to-str 666) => (throws RuntimeException "Unlucky Number")

We can then use int-to-str to run across multiple numbers:

(mapv int-to-str (range 4))
=> ["0" "1" "2" "3"]

Exceptions mess up the middle

Except when we try to use it in with a sequence containing an unlucky number

(mapv int-to-str (range 20))

=> (throws RuntimeException "Unlucky Number")

We can try and recover using try/catch

(try
  (mapv int-to-str (range 20))
  (catch RuntimeException e
    "Unlucky number in the sequence"))

=> "Unlucky number in the sequence"

But we can never get the previous sequence back again because we have blown the stack. The only way to 'fix' this problem is to change int-to-str so that it catches the exception:

(defn int-to-str-fix [n]
  (try
    (if (check-unlucky n)    ;;
      (do (Thread/sleep 10)  ;; Work out something
          (str n)))
    (catch RuntimeException e
      "-")))

(mapv int-to-str-fix (range 20))
=> ["0" "1" "2" "3" "-" "5" "6" "7" "8" "9" "10"
    "11" "12" "-" "-" "15" "16" "17" "18" "19"]

This is seriously unattractive code. We have doubled our line-count to int-to-str without adding too much functionality.

For real world scenarios like batch processing a bunch of files, there are more ways that the program can go wrong. The middle code becomes messy very quickly.

Raising issues, not throwing exceptions

This problem actually has a very elegant solution if we use ribol. Instead of throwing an exception, we can raise an issue in check-unlucky:

(defn check-unlucky [n]
  (if (#{4 13 14 24 666} n)
    (raise [:unlucky-number {:value n}])
    n))

int-to-str does not have to change

(defn int-to-str [n]
  (do (Thread/sleep 10)  ;; Work out something
      (str (check-unlucky n))))

We still get the same functionality:

(int-to-str 1) => "1"

(mapv int-to-str (range 4))
=> ["0" "1" "2" "3"]

Handling raised issues

What happens when we use this with unlucky numbers? Its almost the same... except that instead of raising a RuntimeException, we get a clojure.lang.ExceptionInfo object:

(mapv int-to-str (range 20))
=> (throws clojure.lang.ExceptionInfo)

We can still use try/catch to recover from the error

(try
  (mapv int-to-str (range 20))
  (catch clojure.lang.ExceptionInfo e
    "Unlucky number in the sequence"))
=> "Unlucky number in the sequence"

We set up the ribol handlers by replacing try with manage and catch with on. This gives the exact same result as before.

(manage
  (mapv int-to-str (range 20))
  (on :unlucky-number []
    "Unlucky number in the sequence"))
=> "Unlucky number in the sequence"

A sleight of code

However, the whole point of this example is that we wish to keep the previous results without ever changing int-to-str. We will do this with continue:

(manage
  (mapv int-to-str (range 20))
  (on :unlucky-number []
      (continue "-")))
=> ["0" "1" "2" "3" "-" "5" "6" "7" "8" "9" "10"
    "11" "12" "-" "-" "15" "16" "17" "18" "19"]

What just happened?

continue is a special form that allows higher level functions to jump back into the place where the exception was called. So once the manage block was notified of the issue raised in check-unlucky, it did not blow away the stack but jumped back to the back at which the issue was raised and continued on with - instead. In this way, the exception handling code instead of being written in int-to-str, can now be written at the level that it is required.

Unlucky for whom?

The chinese don't like the number 4 and any number with the number 4, but they don't mind 13 and 666. We can write use the on handler to define cases to process:

(defn ints-to-strs-chinese [arr]
  (manage
   (mapv int-to-str arr)
   (on {:unlucky-number true
        :value #(or (= % 666)
                    (= % 13))}
       [value]
       (continue value))

   (on :unlucky-number []
       (continue "-"))))

(ints-to-strs-chinese [11 12 13 14])
=> ["11" "12" "13" "-"]

(ints-to-strs-chinese [1 2 666])
=> ["1" "2" "666"]

The christians don't mind the numbers with 4, don't like 13 and really don't like 666. In this example, it can be seen that if 666 is seen, it will jump out and return straight away, but will continue on processing with other numbers.

(defn ints-to-strs-christian [arr]
  (manage
   (mapv int-to-str arr)
   (on [:unlucky-number] [value]
       (condp = value
         13 (continue "-")
         666 "ARRRGHHH!"
         (continue value)))))

(ints-to-strs-christian [11 12 13 14])
=> ["11" "12" "-" "14"]

(ints-to-strs-christian [1 2 666])
=> "ARRRGHHH!"

It can be seen from this example that the int-to-str function can be reused without any changes. this would be extremely difficult to do with just try/catch.

For the still sceptical, I'm proposing a challenge: to reimplement ints-to-strs-christian without changing unlucky-numbers or int-to-str using only try and catch. I am scared just thinking about it...

3    Conditional Restarts

ribol provides a conditional restart system. It can be thought of as an issue resolution system or try++/catch++. We use the term issues to differentiate them from exceptions. The difference is purely semantic: issues are managed whilst exceptions are caught. They all refer to abnormal program flow.

Restart systems are somewhat analogous to a management structure. A low level function will do work until it encounter an abnormal situation. An issue is raised up the chain to higher-level functions. These can manage the situation depending upon the type of issue raised.

In the author's experience, there are two forms of exceptions that a programmer will encounter:

  1. Programming Mistakes - These are a result of logic and reasoning errors, should not occur in normal operation and should be eliminated as soon as possible. The best strategy for dealing with this is to write unit tests and have functions fail early with a clear message to the programmer what the error is.
    • Null pointers
    • Wrong inputs to functions
  2. Exceptions due to Circumstances - These are circumstancial and should be considered part of the normal operation of the program.
    • A database connection going down
    • A file not found
    • User input not valid

The common method of try and catch is not really needed when dealing with the Type 1 exceptions and a little too weak when dealing with those of Type 2.

The net effect of using only the try/catch paradigm in application code is that in order to mitigate these Type 2 exceptions, there requires a lot of defensive programming. This turns the middle level of the application into spagetti code with program control flow (try/catch) mixed in with program logic.

Conditional restarts provide a way for the top-level application to more cleanly deal with Type 2 exceptions.

3.1    Raising issues

ribol provide richer semantics for resolution of Type 2 exceptions. Instead of throw, a new form raise is introduced (e.3.1).

e.3.1  -  raise syntax

(raise {:input-not-string true :input-data 3}     ;; issue payload
       (option :use-na [] "NA")                   ;; option 1
       (option :use-custom [n] n)                 ;; option 2
       (default :use-custom "nil"))               ;; default choice

raise differs to throw in a few ways:

3.2    Managing issues

Instead of the try/catch combination, manage/on is used (e.3.2).

e.3.2  -  manage/on syntax

(manage (complex-operation)
        (on :node-working [node-name]
            (choose :wait-for-node))
        (on :node-failed [node-name]
            (choose :reinit-node))
        (on :database-down []
            (choose :use-database backup-database))
        (on :core-failed []
            (terminate-everything)))

Issues are managed through on handlers within a manage block. If any issue is raised with the manage block, it is passed to each handler. There are six ways that a handler can deal with a raised issue:

Using these six different different issue resolution directives, the programmer has the richness of language to craft complex process control flow strategies without mixing logic handling code in the middle tier. Restarts can also create new ways of thinking about the problem beyond the standard throw/catch mechanism and offer more elegant ways to build programs and workflows.

4    API Reference

4.1    raise

The keyword raise is used to raise an 'issue'. At the simplest, when there is no manage blocks, raise just throws a clojure.lang.ExceptionInfo object

[[{:title "raise is of type clojure.lang.ExceptionInfo" :tag "raise-type"}]]
(raise {:error true})
=> (throws clojure.lang.ExceptionInfo)

The payload of the issue can be extracted using ex-data

(try
  (raise {:error true})
  (catch clojure.lang.ExceptionInfo e
    (ex-data e)))
=> {:error true}

The payload can be expressed as a hash-map, a keyword or a vector. We define the raises-issue macro to help explore this a little further:

(defmacro raises-issue [payload]
  `(throws (fn [e#] 
             ((just ~payload) (ex-data e#)))))

Please note that the raises-issue macro is only working with midje. In order to work outside of midje, we need to define the payload macro:

(defmacro payload [& body]
    `(try ~@body
          (throw (Throwable.))
          (catch clojure.lang.ExceptionInfo e#
            (ex-data e#))
          (catch Throwable t#
            (throw (Exception. "No Issue raised")))))

Its can be used to detect what type of issue has been raised:

(payload (raise :error))
=> {:error true}

4.1.1    hash-map

Because the issue can be expressed as a hash-map, it is more general than using a class to represent exceptions.

(raise {:error true :data "data"})
=> (raises-issue {:error true :data "data"})

4.1.2    keyword

When a keyword is used, it is shorthand for a map with having the specified keyword with value true.

(raise :error)
=> (raises-issue {:error true})

4.1.3    vector

Vectors can contain only keywords or both maps and keywords. They are there mainly for syntacic sugar

(raise [:lvl-1 :lvl-2 :lvl-3])
=> (raises-issue {:lvl-1 true :lvl-2 true :lvl-3 true})


(raise [:lvl-1 {:lvl-2 true :data "data"}])
 => (raises-issue {:lvl-1 true :lvl-2 true :data "data"})

4.1.4    option/default

Strategies for an unmanaged issue can be specified within the raise form:

e.4.1  -  default :use-nil

(raise :error
       (option :use-nil [] nil)
       (option :use-custom [n] n)
       (default :use-nil))

=> nil

e.4.2  -  default :use-custom

(raise :error
       (option :use-nil [] nil)
       (option :use-custom [n] n)
       (default :use-custom 10))

=> 10

e.4.3  -  no default

(raise :error
       (option :use-nil [] nil)
       (option :use-custom [n] n))

=> (raises-issue {:error true})

4.2    manage/on

Raised issues can be resolved through use of manage blocks set up. The blocks set up execution scope, providing handlers and options to redirect program flow. A manage block looks like this:

(manage

 ... code that may raise issue ...

 (on <chk> <bindings>
     ... handler body ...)

 (option <label> <bindings>
     ... option body ...)

 (finally                   ;; only one
     ... finally body ...))

We define half-int and its usage:

(defn half-int [n]
  (if (= 0 (mod n 2))
    (quot n 2)
    (raise [:odd-number {:value n}])))
(half-int 2)
=> 1

(half-int 3)
=> (raises-issue {:odd-number true :value 3})

4.2.1    checkers

Within the manage form, issue handlers are specified with on. The form requires a check, which if returns true will be

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number []
     "odd-number-exception"))
=> "odd-number-exception"

The checker can be a map with the value

(manage
 (mapv half-int [1 2 3 4])
 (on {:odd-number true} []
     "odd-number-exception"))
=> "odd-number-exception"

Or it can be a map with a checking function:

(manage
 (mapv half-int [1 2 3 4])
 (on {:odd-number true?} []
     "odd-number-exception"))
=> "odd-number-exception"

A set will check if any elements are true

(manage
 (mapv half-int [1 2 3 4])
 (on #{:odd-number} []
     "odd-number-exception"))
=> "odd-number-exception"

A vector will check if all elements are true

(manage
 (mapv half-int [1 2 3 4])
 (on [:odd-number] []
     "odd-number-exception"))
=> "odd-number-exception"

An underscore will match anything

(manage
 (mapv half-int [1 2 3 4])
 (on _ []
     "odd-number-exception"))
=> "odd-number-exception"

on-any can also be used instead of on _

(manage
 (mapv half-int [1 2 3 4])
 (on-any []
     "odd-number-exception"))
=> "odd-number-exception"

4.2.2    bindings

Bindings within the on handler allow values in the issue payload to be accessed:

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number e
     (str "odd-number: " (:odd-number e) ", value: " (:value e))))
=> "odd-number: true, value: 1"

Bindings can be a vector

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number [odd-number value]
     (str "odd-number: " odd-number ", value: " value)))
=> "odd-number: true, value: 1"

Bindings can also be a hashmap

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number {odd? :odd-number v :value}
     (str "odd-number: " odd? ", value: " v)))
=> "odd-number: true, value: 1"

4.2.3    catch and finally

The special forms catch and finally are also supported in the manage blocks for exception handling just as they are in try blocks.

(manage
 (throw (Exception. "Hello"))
 (catch Exception e
   "odd-number-exception")
 (finally
   (println "Hello")))
=> "odd-number-exception" ;; Also prints "Hello"

They can be mixed and matched with on forms

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number []
     "odd-number-exception")
 (finally
   (println "Hello")))
=> "odd-number-exception" ;; Also prints "Hello"

4.3    special forms

There are five special forms that can be used within the on handler:

4.3.1    continue

The continue special form is used to continue the operation from the point that the issue was raised (e.4.4). It must be pointed out that this is impossible to do using the try/catch paradigm because the all the information from the stack will be lost.

The on handler can take keys of the payload of the raised issue as parameters. In e.4.5, a vector containing strings of the odd numbers are formed. Whereas in e.4.6, the on handler puts in fractions instead.

e.4.4  -  continue using nan

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number []
     (continue :nan)))

=> [:nan 1 :nan 2]

e.4.5  -  continue using str

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number [value]
     (continue (str value))))

=> ["1" 1 "3" 2]

e.4.6  -  continue using fractions

(manage
 (mapv half-int [1 2 3 4])
 (on :odd-number [value]
     (continue (/ value 2))))

=> [1/2 1 3/2 2]

4.3.2    fail

The fail special form will forcibly cause an exception to be thrown. It is used when there is no need to advise managers of situation. More data can be added to the failure (e.).

e.4.7  -  failure

(manage
  (mapv half-int [1 2 3 4])
  (on :odd-number []
    (fail [:unhandled :error])))

=> (raises-issue {:value 1 :odd-number true :unhandled true :error true})

4.3.3    choose

The choose special form is used to jump to a option. A new function half-int-b (e.) is defined giving options to jump to within the raise form.

(defn half-int-b [n]
    (if (= 0 (mod n 2))
      (quot n 2)
      (raise [:odd-number {:value n}]
             (option :use-nil [] nil)
             (option :use-nan [] :nan)
             (option :use-custom [n] n))))

Its usage can be seen in e.4.8 where different paths can be chosen depending upon :value. An option can also be specified in the manage block (e.4.9). Options can also be overridden when specified in higher manage blocks (e.4.10).

e.4.8  -  choosing different paths based on value

(manage
 (mapv half-int-b [1 2 3 4])
 (on {:value 1} []
     (choose :use-nil))
 (on {:value 3} [value]
     (choose :use-custom (/ value 2))))

=> [nil 1 3/2 2]

e.4.9  -  choosing option within manage form

(manage
 (mapv half-int-b [1 2 3 4])
 (on :odd-number []
     (choose :use-empty))
 (option :use-empty [] []))

=> []

e.4.10  -  overwriting :use-nil within manage form

(manage
 (mapv half-int-b [1 2 3 4])
 (on :odd-number []
     (choose :use-nil))
 (option :use-nil [] nil))

=> nil

4.3.4    default

The default special short-circuits the raise process and skips managers further up to use an issue's default option. A function is defined and is usage is shown how the default form behaves.

(defn half-int-c [n]
  (if (= 0 (mod n 2))
    (quot n 2)
    (raise [:odd-number {:value n}]
           (option :use-nil [] nil)
           (option :use-custom [n] n)
           (default :use-custom :odd))))

(manage
 (mapv half-int-c [1 2 3 4])
 (on :odd-number [value] (default)))
=> [:odd 1 :odd 2]

The default form can even refer to an option that has to be implemented higher up in scope. An additional function is defined:

(defn half-int-d [n]
  (if (= 0 (mod n 2))
    (quot n 2)
    (raise [:odd-number {:value n}]
           (default :use-empty))))

The usage for half-int-d can be seen in (e.4.11 and e.4.12) to show these particular cases.

e.4.11  -  half-int-d alone

(half-int-d 3)

=> (throws java.lang.Exception "RAISE_CHOOSE: the label :use-empty has not been implemented")

e.4.12  -  half-int-d inside manage block

(manage
 (mapv half-int-d [1 2 3 4])
 (option :use-empty [] [])
 (on :odd-number []
     (default)))

=> []

4.3.5    escalate

The escalate special form is used to add additional information to the issue and raised to higher managers. In the following example, if a 3 or a 5 is seen, then the flag :three-or-five is added to the issue and the :odd-number flag is set false.

(defn half-array-e [arr]
  (manage
    (mapv half-int-d arr)
    (on {:value (fn [v] (#{3 5} v))} [value]
        (escalate [:three-or-five {:odd-number false}]))))

(manage
  (half-array-e [1 2 3 4 5])
  (on :odd-number [value]
      (continue (* value 10)))
  (on :three-or-five [value]
      (continue (* value 100))))
  => [10 1 300 2 500]

Program decision points can be changed by higher level managers through escalate

(defn half-int-f [n]
 (manage
   (if (= 0 (mod n 2))
     (quot n 2)
     (raise [:odd-number {:value n}]
       (option :use-nil [] nil)
       (option :use-custom [n] n)
       (default :use-nil)))

    (on :odd-number []
      (escalate :odd-number
        (option :use-zero [] 0)
        (default :use-custom :nan)))))

(half-int-f 3) => :nan  ;; (instead of nil)
(mapv half-int-f [1 2 3 4])

=> [:nan 1 :nan 2] ;; notice that the default is overridden

(manage
  (mapv half-int-f [1 2 3 4])
  (on :odd-number []
    (choose :use-zero)))

=> [0 1 0 2]   ;; using an escalated option

Options specified higher up are favored:

(manage
  (mapv half-int-f [1 2 3 4])
  (on :odd-number []
    (choose :use-nil)))

  => [nil 1 nil 2]

  (manage
   (mapv half-int-f [1 2 3 4])
   (on :odd-number []
     (choose :use-nil))
   (option :use-nil [] nil))

  => nil  ;; notice that the :use-nil is overridden

4.4    hooks

When working with jvm libraries, there will always be exceptions. ribol provides macros to hook into thrown exceptions. Because all of the three methods are macros involving try, they all support the finally clause.

4.4.1    raise-on

raise-on hooks ribol into the java exception system. Once an exception is thrown, it can be turned into an issue:

(raise-on [ArithmeticException :divide-by-zero]
          (/ 4 2))
=> 2

(raise-on [ArithmeticException :divide-by-zero]
          (/ 1 0))
=> (raises-issue {:divide-by-zero true 
                  :origin #(instance? ArithmeticException %)})

(manage
 (raise-on [ArithmeticException :divide-by-zero]
           (/ 1 0))
 (on :divide-by-zero []
     (continue :infinity)))
=> :infinity

Any thrown clojure.lang.ExceptionInfo objects will be raised as issues.

(raise-on []
          (throw (ex-info "" {:a 1 :b 2})))
=> (raises-issue {:a 1 :b 2
                  :origin #(= (ex-data %) {:a 1 :b 2})})

Multiple exceptions are supported, as well as the finally clause.

(raise-on [[NumberFormatException ArithmeticException] :divide-by-zero
           Throwable :throwing]
          (throw (Throwable. "oeuoeu"))
          (finally (println 1)))
=> (raises-issue {:throwing true
                  :origin #(instance? Throwable %)}) ;; prints 1

4.4.2    raise-on-all

raise-on-all will raise an issue on any Throwable

(manage
 (raise-on-all :error (/ 4 2))
 (on :error []
     (continue :none)))
=> 2

(manage
 (raise-on-all :error (/ nil nil))
 (on :error []
     (continue :none)))
=> :none

4.4.3    anticipate

anticipate is another way to perform try and catch. Instead catching exceptions at the bottom of the block, it is possible to anticipate what exceptions will occur and deal with them directly. Anticipate also supports the finally clause

(anticipate [ArithmeticException :infinity]
            (/ 1 0))
=> :infinity

(anticipate [ArithmeticException :infinity
             NullPointerException :null]
            (/ nil nil))
=> :null

5    Control Strategies

This is a comprehensive (though non-exhaustive) list of program control strategies that can be used with ribol. It can be noted that the try/catch paradigm can implement sections 5.1 and 5.2. Other clojure restart libraries such as errorkit, swell and conditions additionally implement sections 5.3, 5.4 and 5.5.

ribol supports novel (and more natural) program control mechanics through the escalate (5.6), fail (5.7) and default (5.8) special forms as well as branching support in the on special form (5.9).

5.1    Normal

5.1.1    No Raise

The most straightforward code is one where no issues raised:

e.5.1  -  No Issues

(manage                          ;; L2
  [1 2 (manage 3)])              ;; L1 and L0

=> [1 2 3]

fig.1  -  No Issues Flow

5.1.2    Issue Raised

If there is an issue raised with no handler, it will throw an exception.

e.5.2  -  Unmanaged Issue

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise {:A true}))])      ;; L0

=> (raises-issue {:A true})

fig.2  -  Unmanaged Issue Flow

5.2    Catch

Once an issue has been raised, it can be handled within a managed scope through the use of 'on'. 'manage/on' is the equivalent to 'try/catch' in the following two cases:

5.2.1    First Level Catch

e.5.3  -  Catch on :A

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :A)                ;; L0
       (on :A [] :A))]           ;; H1A
 (on :B [] :B))                  ;; H2B

=> [1 2 :A]

fig.3  -  Catch on :A Flow

5.2.2    Second Level Catch

e.5.4  -  Catch on :B

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :B)                ;; L0
       (on :A [] :A))]           ;; H1A
 (on :B [] :B))                  ;; H2B

=> :B

fig.4  -  Catch on :B Flow

5.3    Continue

The 'continue' form signals that the program should resume at the point that the issue was raised.

5.3.1    First Level Continue

In the first case, this gives the same result as try/catch.

e.5.5  -  Continue on :A

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :A)                ;; L0
       (on :A []                 ;; H1A
           (continue :3A)))]
 (on :B []                       ;; H2B
     (continue :3B)))

=> [1 2 :3A]

fig.5  -  Continue on :A Flow

5.3.2    Second Level Continue

However, it can be seen that when 'continue' is used on the outer manage blocks, it provides the 'manage/on' a way for top tier forms to affect the bottom tier forms without manipulating logic in the middle tier

e.5.6  -  Continue on :B

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :B)                ;; L0
       (on :A []                 ;; H1A
           (continue :3A)))]
 (on :B []                       ;; H2B
     (continue :3B)))

=> [1 2 :3B]

fig.6  -  Continue on :B Flow

5.4    Choose

choose and option work together within manage scopes. A raised issue can have options attached to it, just a worker might give their manager certain options to choose from when an unexpected issue arises. Options can be chosen that lie anywhere within the manage blocks.

5.4.1    Choose Lower-Level

e.5.7  -  Choose :X

(manage                           ;; L2
 [1 2 (manage                     ;; L1
        (raise :A                  ;; L0
               (option :X [] :3X)) ;; X
        (on :A []                  ;; H1A
            (choose :X))
        (option :Y [] :3Y))]       ;; Y
   (option :Z [] :3Z))              ;; Z

=> [1 2 :3X]

fig.7  -  Choose :X Flow

However in some cases, upper level options can be accessed as in this case. This can be used to set global strategies to deal with very issues that have serious consequences if it was to go ahead.

An example maybe a mine worker who finds a gas-leak. Because of previously conveyed instructions, he doesn't need to inform his manager and shuts down the plant immediately.

5.4.2    Choose Upper-Level

e.5.8  -  Choose :Z

(manage                           ;; L2
 [1 2 (manage                     ;; L1
       (raise :A                  ;; L0
              (option :X [] :3X)) ;; X
       (on :A []                  ;; H1A
           (choose :Z))
       (option :Y [] :3Y))]       ;; Y
 (option :Z [] :3Z))              ;; Z

=> :3Z

fig.8  -  Choose :Z Flow

5.5    Choose - More Strategies

5.5.1    Overridding An Option

If there are two options with the same label, choose will take the option specified at the highest management level. This means that managers at higher levels can over-ride lower level strategies.

e.5.9  -  Choose :X1

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A                   ;; L0
              (option :X [] :3X0)) ;; X0 - This is ignored
       (on :A []                   ;; H1A
         (choose :X))
       (option :X [] :3X1))]       ;; X1 - This is chosen
 (option :Z [] :3Z))               ;; Z

=> [1 2 :3X1]

fig.9  -  Choose :X1 Flow

5.5.2    Default Option

Specifying a 'default' option allows the raiser to have autonomous control of the situation if the issue remains unhandled.

e.5.10  -  Choose Default

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A                   ;; L0
              (default :X)         ;; D
              (option :X [] :3X))  ;; X
       (option :Y [] :3Y))]        ;; Y
 (option :Z [] :3Z))               ;; Z

=> [1 2 :3X]

fig.10  -  Choose Default Flow

5.5.3    Overriding Defaults

This is an example of higher-tier managers overriding options

e.5.11  -  Choose Default :X2

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A                   ;; L0
              (default :X)         ;; D
              (option :X [] :3X0)) ;; X0
       (option :X [] :3X1))]       ;; X1
  (option :X [] :3X2))             ;; X2

=> :3X2

fig.11  -  Choose Default :X2 Flow

5.6    Escalate

5.6.1    Simple Escalation

When issues are escalated, more information can be added and this then is passed on to higher-tier managers

e.5.12  -  Escalate :B

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A)                  ;; L0
       (on :A []                   ;; H1A
          (escalate :B)))]
 (on :B []                         ;; H2B
     (continue :3B)))

=> [1 2 :3B]

fig.12  -  Escalate :B Flow

5.6.2    Escalation with Options

More options can be added to escalate. When these options are chosen, it will continue at the point in which the issue was raised.

e.5.13  -  Escalate :B, Choose :X

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A)                  ;; L0
       (on :A []                   ;; H1A
           (escalate
            :B
            (option :X [] :3X))))] ;; X
  (on :B []                        ;; H2B
      (choose :X)))

=> [1 2 :3X]

fig.13  -  Escalate :B, Choose :X Flow

5.7    Fail

Fail forces a failure. It is used where there is already a default option and the manager really needs it to fail.

e.5.14  -  Force Failure

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :A                 ;; L0
              (option :X [] :X)
              (default :X))
       (on :A []                 ;; H1A
           (fail :B)))])

=> (raises-issue {:A true :B true})

fig.14  -  Force Fail Flow

5.8    Default

Default short-circuits higher managers so that the issue is resolved internally.

e.5.15  -  Force Default

(manage                          ;; L2
 [1 2 (manage                    ;; L1
       (raise :A                 ;; L0
              (option :X [] :X)
              (default :X))
       (on :A []                  ;; H1A
           (default)))]
 (on :A [] (continue 3)))

=> [1 2 :X]

5.8.1    Escalation with Defaults

This is default in combination with escalate to do some very complex jumping around.

e.5.16  -  Escalate :B, Choose Default

(manage                            ;; L2
 [1 2 (manage                      ;; L1
       (raise :A                   ;; L0
         (option :X [] :X))        ;; X
       (on :A []                   ;; H1A
           (escalate
            :B
            (default :X))))]       ;; D1
  (on :B []                        ;; H2B
      (default)))

=> [1 2 :X]

fig.15  -  Escalate :B, Choose Default Flow

5.9    Branch Using On

Ribol strategies can also be combined within the on handler. In the following example, it can be seen that the on :error handler supports both escalate and continue strategies.

e.5.17  -  Input Dependent Branching

(manage (manage
         (mapv (fn [n]
                 (raise [:error {:data n}]))
               [1 2 3 4 5 6 7 8])
         (on :error [data]
             (if (> data 5)
               (escalate :too-big)
               (continue data))))
        (on :too-big [data]
            (continue (- data))))

=> [1 2 3 4 5 -6 -7 -8]

Using branching strategies with on much more complex interactions can be constructed beyond the scope of this document.

6    Implementation

6.1    The Workplace

Two macros - raise and manage work together in creating the illusion of allowing code to seemingly jump around between higher and lower level functions. This in reality is not the case at all. We revisit the analogy of the worker who comes across something in their everyday routine that they cannot process.

6.1.1    The dumb throw

When (throw .....) is invoked, the worker basically says: 'Dude, I quit! You deal with it' and lets higher up managers deal with the consequences.

fig.16  -  Lazy Worker

6.1.2    The smart raise

When (raise ....) is used, the worker will attempt to notify their manager and ask for instructions. Only when there are no instructions forthcoming will they quit:

e.6.1  -  Continue Example

(defn dislike-odd [n]
  (if (odd? n) (raise :error) n))

(manage
 (mapv dislike-odd (range 10))
 (on :error [] (continue :odd)))
=> [0 :odd 2 :odd 4 :odd 6 :odd 8 :odd]

Purely from looking at the example code, it reads:

fig.17  -  Smart Worker

Anytime there is an odd input to dislike-odd, the code seemingly jumps out to the context of the manager and having handled the issue, the code then seemingly jumps back into the function again.

Note the words seemingly. This is how we as programmers should be thinking about the problem as we write code to handle these type of issues. We are tricked for our own good because it makes us able to better reason about our programs without having to deal with the implementation details.

However, we are naturally suspicious of this from a performance point of view. If we don't know the mechanism, we ask ourselves... won`t all this jumping around make our code slower?

6.1.3    The proactive workplace

In reality, the program never left the place where raise was called. There was no saving of the stack or anything fancy. The raise/continue combination was an illusion of a jump. There was no jump. Calling raise/continue is most likely computationally cheaper than try/catch.

Going back to the workplace analogy, another way to manage exceptional circumstances is to have a prearranged noticeboard of what to do when things go wrong. Managers can write/override different ways to handle an issue on this board proactively. The worker, when encountering an issue, would go look at the board first to decide upon the best course of action to take. Only when there are no matching solutions to the issue will they solve it themselves or give up and quit. In this way, managers will not have to be called everytime something came up. This is the same mechanism of control that ribol uses.

fig.18  -  Proactive Management

6.2    The Issue Management Board

We look at what happens when there is such an Issue Management Board put in place. raise is called. The worker will look at the board, starting with lowest level manager and proceeding up the management chain to see what strategies has to be been put into place. In the case of e.6.1, there would have been a handler already registered on the board to deal with the :error. The worker will pass any arguments of the issue to the handler function and then return with the result.

fig.19

The management does not even need to know that an exception has occured because they have been proactive.

6.2.1    Control Flow as Data

Whilst the raise/continue mechanism was decribed in brief, a bit more explanation is required to understand how different forms of jumps occur. Ribol implements the 5 special forms as data-structures:

  (defmacro continue [& body]
    `{::type :continue ::value (do ~@body)})

  (defmacro default [& args]
    `{::type :default ::args (list ~@args)})

  (defmacro choose [label & args]
    `{::type :choose ::label ~label ::args (list ~@args)})

  (defmacro fail
    ([] {::type :fail})
    ([contents]
       `{::type :fail ::contents ~contents}))

escalate is not shown because it a bit more complex as options and defaults can. Essentially, it is still just a data structure.

The raise macro calls raise-loop which looks at the ::type signature of the result returned by the on handler.

  (defn raise-loop [issue managers optmap]
    (... code ...
         (condp = (::type res)
           :continue (::value res)
           :choose (raise-choose issue (::label res) (::args res) optmap)
           :default (raise-unhandled issue optmap)
           :fail (raise-fail issue (::contents res))
           :escalate (raise-escalate issue res managers optmap)
           (raise-catch mgr res)))

    ... code ...)

In the case of :continue, it can be seen that the function just returns (::value res). The function in which raise was called proceeds without ever jumping anywhere.

In the case of other forms, there are different handlers to handle each case. If the on handler returns a non-special form value, it will call raise-catch. So it is possible to mess with the internals of ribol by creating a datastructure of the same format of the special forms. However, please do it for shits and giggles only as I'm not sure what it would do to your program.

6.2.2    Implementing Catch

To understand how catch is implemented, we have to look at the manage macro:

(defmacro manage
  ... code ...

  `(binding [*managers* (cons ~manager *managers*)
             *optmap* (merge ~optmap *optmap*)]
     (try
       ~@body-forms
       (catch clojure.lang.ExceptionInfo ~'ex
         (manage-signal ~manager ~'ex))
       ~@finally-forms)))

So essentially, it is a try/catch block wrapped in an binding form.

When raise-loop gets a non-special form value back from a function handler in the manager it will call raise-catch, which will create a catch signal and actually throw it. The signal is just a clojure.lang.ExceptionInfo. The signal has a ::target, which is the :id of the manager. It also has ::value, which is the original result from on.

  (defn- raise-catch [manager value]
    (throw (create-catch-signal (:id manager) value)))

  (defn- create-catch-signal
    [target value]
    (ex-info "catch" {::signal :catch ::target target ::value value}))

Going back to the manage block, it can be seen that manage will catch any clojure.lang.ExceptionInfo objects thrown. When a signal is thrown from lower functions, it will be caught and manage-signal is then called. If the target does not match the :id, then the exception is rethrown. If the exception has ::signal of :catch then the manager will return (::value data).

(defn manage-signal [manager ex]
    (let [data (ex-data ex)]
      (cond (not= (:id manager) (::target data))
            (throw ex)

            ... choose code ....

            (= :catch (::signal data))
            (::value data)

            :else (throw ex))))

6.2.3    Implementing Choose

Choose works with options. It can be seen that apart from the *managers* structure, there is also an *optmap*. The optmap holds as the key/value pairs the labels of what options are registered and the id of the manager that provided the option.

Choose also requires that a signal be sent, but the target will now be a lookup on the optmap given an option label. The signal is very similar to the catch signal.

(defn- create-choose-signal
    [target label args]
     (ex-info "choose" {::signal :choose ::target target ::label label ::args args}))

The part that processes :choose is shown in manage-signal:

 (defn manage-signal
  ...
       (= :choose (::signal data))
       (let [label (::label data)
             f (get (:options manager) label)
             args (::args data)]
         (manage-apply f args label))
  ...)

6.2.4    Implementing the Rest

fail, default, escalate all use similar data as control-flow mechanisms to allow control to be directed to the correct part of the program. It is through this mechanism that branching in the on handlers can be achieved.

7    End Notes

For any feedback, requests and comments, please feel free to lodge an issue on github or contact me directly.

Chris.