λ

Journal manual

Table of Contents

[in package JOURNAL with nicknames JRN]

λ

1 journal ASDF System Details

λ

2 Links

Here is the official repository and the HTML documentation for the latest version.

λ

3 Background

Logging, tracing, testing, and persistence are about what happened during code execution. Recording machine-readable logs and traces can be repurposed for white-box testing. More, when the code is rerun, selected frames may return their recorded values without executing the code, which could serve as a mock framework for writing tests. This ability to isolate external interactions and to reexecute traces is sufficient to reconstruct the state of a program, achieving simple persistence not unlike a journaling filesystem or Event Sourcing.

Journal is the library to log, trace, test and persist. It has a single macro at its heart: JOURNALED, which does pretty much what was described. It can be thought of as generating two events around its body: one that records the name and an argument list (as in a function call), and another that records the return values. In Lisp-like pseudocode:

(defmacro journaled (name args &body body)
  `(progn
     (record-event `(:in ,name :args ,args))
     (let ((,return-values (multiple-value-list (progn ,@body))))
       (record-event `(:out ,name :values ,return-values))
       (values-list ,return-values))))

The Journal library is this idea taken to its logical conclusion.

λ

4 Distinguishing features

As a logging facility
#68200.234: ("some-context")
#68200.234:   Informative log message
#68200.250: => NIL

See Logging for a complete example.

Compared to CL:TRACE
(FOO 2.1)
  (1+ 2.1)
  => 3.1
=E "SIMPLE-ERROR" "The assertion (INTEGERP 3.1) failed."

See Tracing for a complete example.

As a test framework
(define-file-bundle-test (test-user-registration :directory "registration")
  (let ((username (replayed ("ask-username")
                    (format t "Please type your username: ")
                    (read-line))))
    (add-user username)
    (assert (user-exists-p username))))

See Testing for a complete example.

As a solution for persistence
(defun my-resumable-autosaving-game-with-history ()
  (with-bundle (bundle)
    (play-guess-my-number)))

See Persistence for a complete example.

λ

5 Basics

The JOURNALED macro does both recording and replaying of events, possibly at the same time. Recording is easy: events generated by JOURNALED are simply written to a journal, which is a sequence of events much like a file. What events are generated is described in JOURNALED. Replay is much more involved, thus it gets its own section. The journals used for recording and replaying are specified by WITH-JOURNALING or by WITH-BUNDLE.

The Journals reference is presented later, but for most purposes creating them (e.g. with MAKE-IN-MEMORY-JOURNAL, MAKE-FILE-JOURNAL) and maybe querying their contents with LIST-EVENTS will suffice. Some common cases of journal creation are handled by the convenience function TO-JOURNAL.

Built on top of journals, Bundles juggle repeated replay-and-record cycles focussing on persistence.

λ

5.1 In-events

Upon entering a block, JOURNALED generates an IN-EVENT, which conceptually opens a new frame. These in-events are created from the NAME, VERSION and ARGS arguments of JOURNALED. For example,

(journaled (name :version version :args args) ...)

creates an event like this:

`(:in ,name :version ,version :args ,args)

where :VERSION and :ARGS may be omitted if they are NIL. Versions are used for Replay.

λ

5.2 Out-events

Upon leaving a block, JOURNALED generates and OUT-EVENT, closing the frame opened by the corresponding in-event. These out-events are property lists like this:

(:out foo :version 1 :values (42))

Their NAME and VERSION (FOO and 1 in the example) are the same as in the in-event: they are the corresponding arguments of JOURNALED. EXIT and OUTCOME are filled in differently depending on how the block finished its execution.

There is a further grouping of outcomes into expected and unexpected.

λ

5.3 Working with unreadable values

The events recorded often need to be readable. This is always required with FILE-JOURNALs, and often with Synchronization with in-memory journals. By choosing an appropriate identifier or string representation of the unreadable object to journal, this is not a problem in practice. JOURNALED provides the VALUES hook for this purpose.

With EXTERNAL-EVENTs, whose outcome is replayed (see Replaying the outcome), we also need to be able to reverse the transformation of VALUES, and this is what the REPLAY-VALUES argument of JOURNALED is for.

Let's see a complete example.

(defclass user ()
  ((id :initarg :id :reader user-id)))

(defmethod print-object ((user user) stream)
  (print-unreadable-object (user stream :type t)
    (format stream "~S" (slot-value user 'id))))

(defvar *users* (make-hash-table))

(defun find-user (id)
  (gethash id *users*))

(defun add-user (id)
  (setf (gethash id *users*) (make-instance 'user :id id)))

(defvar *user7* (add-user 7))

(defun get-message ()
  (replayed (listen :values (values-> #'user-id)
                    :replay-values (values<- #'find-user))
    (values *user7* "hello")))

(jtrace user-id find-user get-message)

(let ((bundle (make-file-bundle "/tmp/user-example/")))
  (format t "Recording")
  (with-bundle (bundle)
    (get-message))
  (format t "~%Replaying")
  (with-bundle (bundle)
    (get-message)))
.. Recording
.. (GET-MESSAGE)
..   (USER-ID #<USER 7>)
..   => 7
.. => #<USER 7>, "hello"
.. Replaying
.. (GET-MESSAGE)
..   (FIND-USER 7)
..   => #<USER 7>, T
.. => #<USER 7>, "hello"
==> #<USER 7>
=> "hello"

The point of this example that for to be able to journal the return values of GET-MESSAGE, the USER object must be transformed to something readable. On the Recording run, (VALUES-> #'USER-ID) replaces the user object with its id in the EVENT-OUTCOME recorded, but the original user object is returned.

When Replaying, the journaled OUT-EVENT is replayed (see Replaying the outcome):

(:OUT GET-MESSAGE :VERSION :INFINITY :VALUES (7 "hello"))

The user object is looked up according to :REPLAY-VALUES and is returned along with "hello".

λ

5.4 Utilities

λ

5.5 Pretty-printing

Instead of collecting events and then printing them, events can be pretty-printed to a stream as they generated. This is accomplished with Pretty-printing journals, discussed in detail later, in the following way:

(let ((journal (make-pprint-journal)))
  (with-journaling (:record journal)
    (journaled (foo) "Hello")))
..
.. (FOO)
.. => "Hello"

Note that Pretty-printing journals are not tied to WITH-JOURNALING and are most often used for Logging and Tracing.

λ

5.6 Error handling

λ

6 Logging

Imagine a utility library called glib.

Default to muffling
(defvar *glib-log* nil)
(defvar *patience* 1)

(defun sl33p (seconds)
  (logged (*glib-log*) "Sleeping for ~As." seconds)
  (sleep (* *patience* seconds)))

Glib follows the recommendation to have a special variable globally bound to NIL by default. The value of *GLIB-LOG* is the journal to which glib log messages will be routed. Since it's NIL, the log messages are muffled, and to record any log message, we need to change its value.

Routing logs to a journal

Let's send the logs to a PPRINT-JOURNAL:

(setq *glib-log* (make-pprint-journal
                  :log-decorator (make-log-decorator :time t)))
(sl33p 0.01)
..
.. 2020-08-31T12:45:23.827172+02:00: Sleeping for 0.01s.

That's a bit too wordy. For this tutorial, let's stick to less verbose output:

(setq *glib-log* (make-pprint-journal))
(sl33p 0.01)
..
.. Sleeping for 0.01s.
Capturing logs in with-journaling record

If we were recording a journal for replay and wanted to include glib logs in the journal, we would do something like this:

(with-journaling (:record t)
  (let ((*glib-log* :record))
    (sl33p 0.01)
    (journaled (non-glib-stuff :version 1)))
  (list-events))
=> ((:LEAF "Sleeping for 0.01s.")
    (:IN NON-GLIB-STUFF :VERSION 1)
    (:OUT NON-GLIB-STUFF :VERSION 1 :VALUES (NIL)))

We could even (SETQ *GLIB-LOG* :RECORD) to make it so that glib messages are included by default in the RECORD-JOURNAL. In this example, the special *GLIB-LOG* acts like a log category for all the log messages of the glib library (currently one).

Rerouting a category

Next, we route *GLIB-LOG* to wherever *APP-LOG* is pointing by binding *GLIB-LOG* to the symbol *APP-LOG*.

(defvar *app-log* nil)

(let ((*glib-log* '*app-log*)
      (*app-log* (make-pprint-journal :pretty nil)))
  (sl33p 0.01))
..
.. (:LEAF "Sleeping for 0.01s.")

Note how pretty-printing was turned off and we see the LEAF-EVENT generated by LOGGED in its raw plist form.

Conditional routing

Finally, to make routing decisions conditional we need to change SL33P:

(defvar *glib-log-level* 1)

(defun sl33p (seconds)
  (logged ((and (<= 2 *glib-log-level*) *glib-log*))
          "Sleeping for ~As." (* *patience* seconds))
  (sleep seconds))

;;; Check that it all works:
(let ((*glib-log-level* 1))
  (format t "~%With log-level ~A" *glib-log-level*)
  (sl33p 0.01)
  (setq *glib-log-level* 2)
  (format t "~%With log-level ~A" *glib-log-level*)
  (sl33p 0.01))
..
.. With log-level 1
.. With log-level 2
.. Sleeping for 0.01s.
Nested log contexts

LOGGED is for single messages. JOURNALED can provide nested context:

(defun callv (var value symbol &rest args)
  "Call SYMBOL-FUNCTION of SYMBOL with VAR dynamically bound to VALUE."
  (framed ("glib:callv" :log-record *glib-log*
                        :args `(,var ,value ,symbol ,@args))
    (progv (list var) (list value)
      (apply (symbol-function symbol) args))))

(callv '*print-base* 2 'print 10)
..
.. ("glib:callv" *PRINT-BASE* 2 PRINT 10)
.. 1010 
.. => 10
=> 10

(let ((*glib-log-level* 2))
  (callv '*patience* 7 'sl33p 0.01))
..
.. ("glib:callv" *PATIENCE* 7 SL33P 0.01)
..   Sleeping for 0.07s.
.. => NIL

λ

6.1 Customizing logs

Customizing the output format is possible if we don't necessarily expect to be able to read the logs back programmatically. There is an example in Tracing, which is built on Pretty-printing journals.

Here, we discuss how to make logs more informative.

λ

6.2 Log record

WITH-JOURNALING and WITH-BUNDLE control replaying and recording within their dynamic extent, which is rather a necessity because Replay needs to read the events in the same order as the JOURNALED blocks are being executed. However, LOG-EVENTs do not affect replay so we can allow more flexibility in routing them.

The LOG-RECORD argument of JOURNALED and LOGGED controls where LOG-EVENTs are written both within WITH-JOURNALING and without. The algorithm to determine the target journal is this:

  1. If LOG-RECORD is :RECORD, then (RECORD-JOURNAL) is returned.

  2. If LOG-RECORD is NIL, then it is returned.

  3. If LOG-RECORD is a JOURNAL, then it is returned.

  4. If LOG-RECORD is symbol (other than NIL), then the SYMBOL-VALUE of that symbol is assigned to LOG-RECORD and we go to step 1.

If the return value is NIL, then the event will not be written anywhere, else it is written to the journal returned.

This is reminiscent of SYNONYM-STREAMs, also in that it is possible end up in cycles in the resolution. For this reason, the algorithm stop with a JOURNAL-ERROR after 100 iterations.

Interactions

Events may be written to LOG-RECORD even without an enclosing WITH-JOURNALING, and it does not affect the JOURNAL-STATE. However, it is a JOURNAL-ERROR to write to a :COMPLETED journal (see JOURNAL-STATE).

When multiple threads log to the same journal it is guaranteed that individual events are written atomically, but frames from different threads do not necessarily nest. To keep the log informative, the name of thread may be added to the events as decoration.

Also see notes on thread Safety.

λ

6.3 Logging with leaf-events

λ

7 Tracing

JTRACE behaves similarly to CL:TRACE, but deals non-local exits gracefully.

Basic tracing
(defun foo (x)
  (sleep 0.12)
  (1+ x))

(defun bar (x)
  (foo (+ x 2))
  (error "xxx"))

(jtrace foo bar)

(ignore-errors (bar 1))
..
.. (BAR 1)
..   (FOO 3)
..   => 4
.. =E "SIMPLE-ERROR" "xxx"
Log-like output

It can also include the name of the originating thread and timestamps in the output:

(let ((*trace-thread* t)
      (*trace-time* t))
  (ignore-errors (bar 1)))
..
.. 2020-09-02T19:58:19.415204+02:00 worker: (BAR 1)
.. 2020-09-02T19:58:19.415547+02:00 worker:   (FOO 3)
.. 2020-09-02T19:58:19.535766+02:00 worker:   => 4
.. 2020-09-02T19:58:19.535908+02:00 worker: =E "SIMPLE-ERROR" "xxx"
Profiler-like output
(let ((*trace-real-time* t)
      (*trace-run-time* t))
  (ignore-errors (bar 1)))
..
.. #16735.736 !68.368: (BAR 1)
.. #16735.736 !68.369:   (FOO 3)
.. #16735.857 !68.369:   => 4
.. #16735.857 !68.369: =E "SIMPLE-ERROR" "xxx"
Customizing the content and the format

If these options are insufficient, the content and the format of the trace can be customized:

(let ((*trace-journal*
       (make-pprint-journal :pretty '*trace-pretty*
                     :prettifier (lambda (event depth stream)
                                   (format stream "~%Depth: ~A, event: ~S"
                                           depth event))
                     :stream (make-synonym-stream '*error-output*)
                     :log-decorator (lambda (event)
                                      (append event '(:custom 7))))))
  (ignore-errors (bar 1)))
..
.. Depth: 0, event: (:IN BAR :ARGS (1) :CUSTOM 7)
.. Depth: 1, event: (:IN FOO :ARGS (3) :CUSTOM 7)
.. Depth: 1, event: (:OUT FOO :VALUES (4) :CUSTOM 7)
.. Depth: 0, event: (:OUT BAR :ERROR ("SIMPLE-ERROR" "xxx") :CUSTOM 7)

In the above, *TRACE-JOURNAL* was bound locally to keep the example from wrecking the global default, but the same effect could be achieved by SETFing PPRINT-JOURNAL-PRETTIFIER, PPRINT-JOURNAL-STREAM and JOURNAL-LOG-DECORATOR.

λ

7.1 Slime integration

Slime by default binds C-c C-t to toggling CL:TRACE. To integrate JTRACE into Slime, add the following ELisp snippet to your Emacs initialization file or load src/journal.el:

(defun slime-toggle-jtrace-fdefinition (spec)
  "Toggle JTRACE."
  (interactive (list (slime-read-from-minibuffer
                      "j(un)trace: " (slime-symbol-at-point))))
  (message "%s" (slime-eval `(journal::swank-toggle-jtrace ,spec))))

(define-key slime-mode-map (kbd "C-j")
  'slime-toggle-jtrace-fdefinition)

(define-key slime-repl-mode-map (kbd "C-c C-j")
  'slime-toggle-jtrace-fdefinition)

Since JTRACE lacks some features of CL:TRACE, most notably that of tracing non-global functions, it is assigned a separate binding, C-c C-j.

λ

8 Replay

During replay, code is executed normally with special rules for blocks. There are two modes for dealing with blocks: replaying the code or replaying the outcome. When code is replayed, upon entering and leaving a block, the events generated are matched to events read from the journal being replayed. If the events don't match, a REPLAY-FAILURE is signaled which marks the record journal as having failed the replay. This is intended to make sure that the state of the program during the replay matches the state at the time of recording. In the other mode, when the outcome is replayed, a block may not be executed at all, but its recorded outcome is reproduced (e.g. the recorded return values are returned).

Replay can be only be initiated with WITH-JOURNALING (or its close kin WITH-BUNDLE). After the per-event processing described below, when WITH-JOURNALING finishes, it might signal REPLAY-INCOMPLETE if there are unprocessed non-log events left in the replay journal.

Replay is deemed successful or failed depending on whether all events are replayed from the replay journal without a REPLAY-FAILURE. A journal that records events from a successful replay can be used in place of the journal that was replayed, and so on. The logic of replacing journals with their successful replays is automated by Bundles. WITH-JOURNALING does not allow replay from journals that were failed replays themselves. The mechanism, in terms of which tracking success and failure of replays is implemented, revolves around JOURNAL-STATE and EVENT-VERSIONs, which we discuss next.

λ

8.1 Journaled for replay

The following arguments of JOURNALED control behaviour under replay.

λ

8.2 Bundles

Consider replaying the same code repeatedly, hoping to make progress in the processing. Maybe based on the availability of external input, the code may error out. After each run, one has to decide whether to keep the journal just recorded or stick with the replay journal. A typical solution to this would look like this:

(let ((record nil))
  (loop
    (setq record (make-in-memory-journal))
    (with-journaling (:record record :replay replay)
      ...)
    (when (and
           ;; RECORD is a valid replay of REPLAY ...
           (eq (journal-state record) :completed)
           ;; ... and is also significantly different from it ...
           (not (journal-diverged-p record)))
      ;; so use it for future replays.
      (setq replay record))))

This is pretty much what bundles automate. The above becomes:

(let ((bundle (make-in-memory-bundle)))
  (loop
    (with-bundle (bundle)
      ...)))

With FILE-JOURNALs, the motivating example above would be even more complicated, but FILE-BUNDLEs work the same way as IN-MEMORY-BUNDLEs.

λ

8.3 The replay strategy

The replay process for both In-events and Out-events starts by determining how the generated event (the new event from now on) shall be replayed. Roughly, the decision is based on the NAME and VERSION of the new event and the replay event (the next event to be read from the replay). There are four possible strategies:

The strategy is determined by the following algorithm, invoked whenever an event is generated by a journaled block:

  1. Log events are not matched to the replay. If the new event is a log event or a REPLAY-FAILURE has been signalled before (i.e. the record journal's JOURNAL-STATE is :MISMATCHED), then insert is returned.

  2. Else, log events to be read in the replay journal are skipped, and the next unread, non-log event is peeked at (without advancing the replay journal).

    • end of replay: If there are no replay events left, then:

      • If REPLAY-EOJ-ERROR-P is NIL in WITH-JOURNALING (the default), insert is returned.

      • If REPLAY-EOJ-ERROR-P is true, then END-OF-JOURNAL is signalled.

    • mismatched name: Else, if the next unread replay event's name is not EQUAL to the name of the new event, then:

    • matching name: Else, if the name of the next unread event in the replay journal is EQUAL to the name of new event, then it is chosen as the replay event.

      • If the replay event's version is higher than the new event's version, then REPLAY-VERSION-DOWNGRADE is signalled.

      • If the two versions are equal, then match is returned.

      • If the new event's version is higher, then upgrade is returned.

      Where :INFINITY is considered higher than any integer and equal to itself.

In summary:

 | new event | end-of-replay     | mismatched name   | matching name |
 |-----------+-------------------+-------------------+---------------|
 | Log       | insert            | insert            | insert        |
 | Versioned | insert/eoj-error  | insert/name-error | match-version |
 | External  | insert/eoj-error  | insert/name-error | match-version |

Version matching (match-version above) is based which event has a higher version:

 | replay event    | =     | new event |
 |-----------------+-------+-----------|
 | downgrade-error | match | upgrade   |

λ

8.4 Matching in-events

If the replay strategy is match, then, for in-events, the matching process continues like this:

λ

8.4.1 Replaying the outcome

So, if an in-event is triggered that matches the replay, EVENT-VERSION(0 1) is :INFINITY, then normal execution is altered in the following manner:

To be able to reproduce the outcome in the replay journal, some assistance may be required from REPLAY-VALUES and REPLAY-CONDITION:

λ

8.5 Matching out-events

If there were no Replay failures during the matching of the IN-EVENT, and the conditions for Replaying the outcome were not met, then the block is executed. When the outcome of the block is determined, an OUT-EVENT is triggered and is matched to the replay journal. The matching of out-events starts out as in The replay strategy with checks for EVENT-NAME and EVENT-VERSION.

If the replay strategy is insert or upgrade, then the out-event is written to RECORD-JOURNAL, consuming an event with a matching name from the REPLAY-JOURNAL in the latter case. If the strategy is match, then:

Note that The replay strategy for the in-event and the out-event of the same frame may differ if the corresponding out-event is not present in REPLAY-JOURNAL, which may be the case when the recording process failed hard without unwinding properly, or when an unexpected outcome triggered the transition to JOURNAL-STATE :LOGGING.

λ

8.6 Replay failures

λ

8.7 Upgrades and replay

The replay mechanism is built on the assumption that the tree of frames is the same when the code is replayed as it was when the replay journal was originally recorded. Thus, non-deterministic control flow poses a challenge, but non-determinism can be isolated with EXTERNAL-EVENTs. However, when the code changes, we might find the structure of frames in previous recordings hard to accommodate. In this case, we might decide to alter the structure, giving up some of the safety provided by the replay mechanism. There are various tools at our disposal to control this tradeoff between safety and flexibility:

With that, let's see how WITH-REPLAY-FILTER works.

λ

9 Testing

Having discussed the Replay mechanism, next are Testing and Persistence, which rely heavily on it. Suppose we want to unit test user registration. Unfortunately, the code communicates with a database service and also takes input from the user. A natural solution is to create mocks for these external systems to unshackle the test from the cumbersome database dependency and to allow it to run without user interaction.

We do this below by wrapping external interaction in JOURNALED with :VERSION :INFINITY (see Replaying the outcome).

(defparameter *db* (make-hash-table))

(defun set-key (key value)
  (replayed ("set-key" :args `(,key ,value))
    (format t "Updating db~%")
    (setf (gethash key *db*) value)
    nil))

(defun get-key (key)
  (replayed ("get-key" :args `(,key))
    (format t "Query db~%")
    (gethash key *db*)))

(defun ask-username ()
  (replayed ("ask-username")
    (format t "Please type your username: ")
    (read-line)))

(defun maybe-win-the-grand-prize ()
  (checked ("maybe-win-the-grand-prize")
    (when (= 1000000 (hash-table-count *db*))
      (format t "You are the lucky one!"))))

(defun register-user (username)
  (unless (get-key username)
    (set-key username `(:user-object :username ,username))
    (maybe-win-the-grand-prize)))

Now we write a test that records these interactions in a file when it's run for the first time.

(define-file-bundle-test (test-user-registration
                          :directory (asdf:system-relative-pathname
                                      :journal "test/registration/"))
  (let ((username (ask-username)))
    (register-user username)
    (assert (get-key username))
    (register-user username)
    (assert (get-key username))))

;; Original recording: everything is executed
JRN> (test-user-registration)
Please type your username: joe
Query db
Updating db
Query db
Query db
Query db
=> NIL

On reruns, none of the external stuff is executed. The return values of the external JOURNALED blocks are replayed from the journal:

;; Replay: all external interactions are mocked.
JRN> (test-user-registration)
=> NIL

Should the code change, we might want to upgrade carefully (see Upgrades and replay) or just rerecord from scratch:

JRN> (test-user-registration :rerecord t)
Please type your username: joe
Query db
Updating db
Query db
Query db
Query db
=> NIL

Thus satisfied that our test runs, we can commit the journal file in the bundle into version control. Its contents are:


(:IN "ask-username" :VERSION :INFINITY)
(:OUT "ask-username" :VERSION :INFINITY :VALUES ("joe" NIL))
(:IN "get-key" :VERSION :INFINITY :ARGS ("joe"))
(:OUT "get-key" :VERSION :INFINITY :VALUES (NIL NIL))
(:IN "set-key" :VERSION :INFINITY :ARGS ("joe" (:USER-OBJECT :USERNAME "joe")))
(:OUT "set-key" :VERSION :INFINITY :VALUES (NIL))
(:IN "maybe-win-the-grand-prize" :VERSION 1)
(:OUT "maybe-win-the-grand-prize" :VERSION 1 :VALUES (NIL))
(:IN "get-key" :VERSION :INFINITY :ARGS ("joe"))
(:OUT "get-key" :VERSION :INFINITY :VALUES ((:USER-OBJECT :USERNAME "joe") T))
(:IN "get-key" :VERSION :INFINITY :ARGS ("joe"))
(:OUT "get-key" :VERSION :INFINITY :VALUES ((:USER-OBJECT :USERNAME "joe") T))
(:IN "get-key" :VERSION :INFINITY :ARGS ("joe"))
(:OUT "get-key" :VERSION :INFINITY :VALUES ((:USER-OBJECT :USERNAME "joe") T))

Note that when this journal is replayed, new VERSIONED-EVENTs are required to match the replay. So after the original recording, we can check by eyeballing that the record represents a correct execution. Then on subsequent replays, even though MAYBE-WIN-THE-GRAND-PRIZE sits behind REGISTER-USER and is hard to test with ASSERTs, the replay mechanism verifies that it is called only for new users.

This record-and-replay style of testing is not the only possibility: direct inspection of a journal with the low-level events api (see Events reference) can facilitate checking non-local invariants.

λ

10 Persistence

λ

10.1 Persistence tutorial

Let's write a simple game.

(defun play-guess-my-number ()
  (let ((my-number (replayed (think-of-a-number)
                     (random 10))))
    (format t "~%I thought of a number.~%")
    (loop for i upfrom 0 do
      (write-line "Guess my number:")
      (let ((guess (replayed (read-guess)
                     (values (parse-integer (read-line))))))
        (format t "You guessed ~D.~%" guess)
        (when (= guess my-number)
          (checked (game-won :args `(,(1+ i))))
          (format t "You guessed it in ~D tries!" (1+ i))
          (return))))))

(defparameter *the-evergreen-game* (make-in-memory-bundle))
Original recording

Unfortunately, the implementation is lacking in the input validation department. In the transcript below, PARSE-INTEGER fails with junk in string when the user enters not a number:

CL-USER> (handler-case
             (with-bundle (*the-evergreen-game*)
               (play-guess-my-number))
           (error (e)
             (format t "Oops. ~A~%" e)))
I thought of a number.
Guess my number:
7 ; real user input
You guessed 7.
Guess my number:
not a number ; real user input
Oops. junk in string "not a number"
Replay and extension

Instead of fixing this bug, we just restart the game from the beginning, Replaying the outcome of external interactions marked with REPLAYED:

CL-USER> (with-bundle (*the-evergreen-game*)
           (play-guess-my-number))
I thought of a number.
Guess my number:
You guessed 7.
Guess my number: ; New recording starts here
5 ; real user input
You guessed 5.
Guess my number:
4 ; real user input
You guessed 4.
Guess my number:
2 ; real user input
You guessed 2.
You guessed it in 4 tries!
It's evergreen

We can now replay this game many times without any user interaction:

CL-USER> (with-bundle (*the-evergreen-game*)
           (play-guess-my-number))
I thought of a number.
Guess my number:
You guessed 7.
Guess my number:
You guessed 5.
Guess my number:
You guessed 4.
Guess my number:
You guessed 2.
You guessed it in 4 tries!
The generated events

This simple mechanism allows us to isolate external interactions and write tests in record-and-replay style based on the events produced:

CL-USER> (list-events *the-evergreen-game*)
((:IN THINK-OF-A-NUMBER :VERSION :INFINITY)
 (:OUT THINK-OF-A-NUMBER :VERSION :INFINITY :VALUES (2))
 (:IN READ-GUESS :VERSION :INFINITY)
 (:OUT READ-GUESS :VERSION :INFINITY :VALUES (7))
 (:IN READ-GUESS :VERSION :INFINITY :ARGS NIL)
 (:OUT READ-GUESS :VERSION :INFINITY :VALUES (5))
 (:IN READ-GUESS :VERSION :INFINITY :ARGS NIL)
 (:OUT READ-GUESS :VERSION :INFINITY :VALUES (4))
 (:IN READ-GUESS :VERSION :INFINITY :ARGS NIL)
 (:OUT READ-GUESS :VERSION :INFINITY :VALUES (2))
 (:IN GAME-WON :VERSION 1 :ARGS (4))
 (:OUT GAME-WON :VERSION 1 :VALUES (NIL)))

In fact, being able to replay this game at all already checks it through the GAME-WON event that the number of tries calculation is correct.

In addition, thus being able to reconstruct the internal state of the program gives us persistence by replay. If instead of a IN-MEMORY-BUNDLE, we used a FILE-BUNDLE, the game would have been saved on disk without having to write any code for saving and loading the game state.

Discussion

Persistence by replay, also known as Event Sourcing, is appropriate when the external interactions are well-defined and stable. Storing events shines in comparison to persisting state when the control flow is too complicated to be interrupted and resumed easily. Resuming execution in deeply nested function calls is fraught with such peril that it is often easier to flatten the program into a state machine, which is as pleasant as manually managing continuations.

In contrast, the Journal library does not favour certain styles of control flow, and only requires that non-determinism is packaged up in REPLAYED, which allows it to reconstruct the state of the program from the recorded events at any point during its execution and resume from there.

λ

10.2 Synchronization to storage

In the following, we explore how journals can serve as a persistence mechanism and the guarantees they offer. The high-level summary is that journals with SYNC can serve as a durable and consistent storage medium. The other two ACID properties, atomicity and isolation, do not apply because Journal is single-client and does not need transactions.

λ

10.2.1 Synchronization strategies

When a journal or bundle is created (see MAKE-IN-MEMORY-JOURNAL, MAKE-FILE-JOURNAL, MAKE-IN-MEMORY-BUNDLE, MAKE-FILE-BUNDLE), the SYNC option determines when - as a RECORD-JOURNAL - the recorded events and JOURNAL-STATE changes are persisted durably. For FILE-JOURNALs, persisting means calling something like fsync(), while for IN-MEMORY-JOURNALs, a user defined function is called to persist the data.

λ

10.2.2 Synchronization with in-memory journals

Unlike FILE-JOURNALs, IN-MEMORY-JOURNALs do not have any built-in persistent storage backing them, but with SYNC-FN, persistence can be tacked on. If non-NIL, SYNC-FN must be a function of a single argument, an IN-MEMORY-JOURNAL. SYNC-FN is called according to Synchronization strategies, and upon normal return the journal must be stored durably.

The following example saves the entire journal history when a new data event is recorded. Note how SYNC-TO-DB is careful to overwrite *DB* only if it is called with a journal that has not failed the replay (as in Replay failures) and is sufficiently different from the replay journal as determined by JOURNAL-DIVERGENT-P.

(defparameter *db* ())

(defun sync-to-db (journal)
  (when (and (member (journal-state journal)
                     '(:recording :logging :completed))
             (journal-divergent-p journal))
    (setq *db* (journal-events journal))
    (format t "Saved ~S~%New events from position ~S~%" *db*
            (journal-previous-sync-position journal))))

(defun make-db-backed-record-journal ()
  (make-in-memory-journal :sync-fn 'sync-to-db))

(defun make-db-backed-replay-journal ()
  (make-in-memory-journal :events *db*))

(with-journaling (:record (make-db-backed-record-journal)
                  :replay (make-db-backed-replay-journal))
  (replayed (a)
    2)
  (ignore-errors
    (replayed (b)
      (error "Whoops"))))
.. Saved #((:IN A :VERSION :INFINITY)
..         (:OUT A :VERSION :INFINITY :VALUES (2)))
.. New events from position 0
.. Saved #((:IN A :VERSION :INFINITY)
..         (:OUT A :VERSION :INFINITY :VALUES (2))
..         (:IN B :VERSION :INFINITY)
..         (:OUT B :ERROR ("SIMPLE-ERROR" "Whoops")))
.. New events from position 2
..

In a real application, external events often involve unreliable or high-latency communication. In the above example, block B signals an error, say, to simulate some kind of network condition. Now a new journal for replay is created and initialized with the saved events and the whole process is restarted.

(defun run-with-db ()
  (with-journaling (:record (make-db-backed-record-journal)
                    :replay (make-db-backed-replay-journal))
    (replayed (a)
      (format t "A~%")
      2)
    (replayed (b)
      (format t "B~%")
      3)))

(run-with-db)
.. B
.. Saved #((:IN A :VERSION :INFINITY)
..         (:OUT A :VERSION :INFINITY :VALUES (2))
..         (:IN B :VERSION :INFINITY)
..         (:OUT B :VERSION :INFINITY :VALUES (3)))
.. New events from position 0
..
=> 3

Note that on the rerun, block A is not executed because external events are replayed simply by reproducing their outcome, in this case returning 2. See Replaying the outcome. Block B, on the other hand, was rerun because it had an unexpected outcome the first time around. This time it ran without error, a data event was triggered and SYNC-FN invoked.

If we were to invoke the now completed RUN-WITH-DB again, it would simply return 3 without ever invoking SYNC-FN:

(run-with-db)
=> 3

With JOURNAL-REPLAY-MISMATCH, SYNC-FN can be optimized to to reuse the sequence of events in the replay journal up until the point of divergence.

λ

10.2.3 Synchronization with file journals

For FILE-JOURNALs, SYNC determines when the events written to the RECORD-JOURNAL and its JOURNAL-STATE will be persisted durably in the file. Syncing to the file involves two calls to fsync(), and is not cheap.

Syncing events to files is implemented as follows.

Note that this implementation assumes that after writing the start transaction marker a crash cannot leave any kind of garbage bytes around: it must leave zeros. This is not true for all filesytems. For example, ext3/ext4 with data=writeback can leave garbage around.

λ

11 Safety

Thread safety

Changes to journals come in two varieties: adding an event and changing the JOURNAL-STATE. Both are performed by JOURNALED only unless the low-level streamlet interface is used (see Streamlets reference). Using JOURNALED wrapped in a WITH-JOURNALING, WITH-BUNDLE, or Log record without WITH-JOURNALING is thread-safe.

Process safety

Currently, there is no protection against multiple OS processes writing the same FILE-JOURNAL or FILE-BUNDLE.

Signal safety

Journal is designed to be async-unwind safe, but not reentrant. Interrupts are disabled only for the most critical cleanup forms. If a thread is killed without unwinding, that constitutes aborted execution, so guarantees about Synchronization to storage apply, but JOURNAL objects written by the thread are not safe to access, and the Lisp should probably be restarted.

λ

12 Events reference

Events are normally triggered upon entering and leaving the dynamic extent of a JOURNALED block (see In-events and Out-events) and also by LOGGED. Apart from being part of the low-level substrate of the Journal library, working with events directly is sometimes useful when writing tests that inspect recorded events. Otherwise, skip this entire section.

All EVENTs have EVENT-NAME and EVENT-VERSION(0 1), which feature prominently in The replay strategy. After the @JOURNAL-TUTORIAL, the following example is a reminder of how events look in the simplest case.

(with-journaling (:record t)
  (journaled (foo :version 1 :args '(1 2))
    (+ 1 2))
  (logged () "Oops")
  (list-events))
=> ((:IN FOO :VERSION 1 :ARGS (1 2))
    (:OUT FOO :VERSION 1 :VALUES (3))
    (:LEAF "Oops"))

So a JOURNALED block generates an IN-EVENT and an OUT-EVENT, which are simple property lists. The following reference lists these properties, there semantics and the functions to read them.

λ

12.1 Event versions

λ

12.2 In-events

λ

12.3 Out-events

λ

12.4 Leaf-events

λ

13 Journals reference

In Basics, we covered the bare minimum needed to work with journals. Here we go into the details.

λ

13.1 Comparing journals

After replay finished (i.e. WITH-JOURNALING completed), we can ask the question whether there were any changes produced. This is answered in the strictest sense by IDENTICAL-JOURNALS-P, and somewhat more functionally by EQUIVALENT-JOURNALS-P.

Also see JOURNAL-DIVERGENT-P.

The rest of section is about concrete subclasses of JOURNAL.

λ

13.2 In-memory journals

λ

13.3 File journals

λ

13.4 Pretty-printing journals

λ

14 Bundles reference

In Bundles, we covered the repeated replay problem that WITH-BUNDLE automates. Here we provide a reference for the bundle classes.

λ

14.1 In-memory bundles

λ

14.2 File bundles

λ

15 Streamlets reference

This section is relevant mostly for implementing new kinds of JOURNALs in addition to FILE-JOURNALs and IN-MEMORY-JOURNALs. In normal operation, STREAMLETs are not worked with directly.

λ

15.1 Opening and closing

λ

15.2 Reading from streamlets

λ

15.3 Writing to streamlets

λ

16 Glossary