Observability and Serialization¶
Once a pipeline or workflow has run, cl-dataflow gives you three
complementary ways to look back at it: render its structure as a
diagram, read its recorded trace as text or roll-up counts, and
serialize its context to a plain plist for storage, comparison, or
transmission. All of it is built on one shared introspection protocol —
flow-name, flow-metadata, and flow-kind — so the same handful of
functions work uniformly across nodes, edges, graphs, contexts, events,
effects, transitions, state machines, and pipelines.
This page covers the pipeline- and context-level observability layer
(src/observability.lisp, src/introspection.lisp), the cross-type
protocol (src/protocols.lisp), context/event/effect serialization
(src/context-serialization.lisp), and the structural equality predicates
(src/equality-predicates.lisp). The lower-level graph renderers
(graph->dot/graph->mermaid) and graph serialization
(graph-to-plist/plist-to-graph) are documented on Graphs;
pipeline (pipeline-to-plist/plist-to-pipeline) and state-machine
(state-machine-to-plist/plist-to-state-machine) plist round trips are
documented on Pipelines and Workflows and
State Machine Analysis respectively — this page
completes that story for contexts, events, and effects.
Rendering a pipeline¶
pipeline->dot and pipeline->mermaid render a pipeline's underlying graph
for visualization. They are thin wrappers over graph->dot/graph->mermaid
(see Graphs for the deterministic
sort order and rendering details) applied to pipeline-graph:
(cl-dataflow:pipeline->dot *pipeline* :name "ingest-pipeline")
;; => "digraph ingest-pipeline {
;; \"finish\";
;; \"start\";
;; \"start\" -> \"finish\" [label=\"value -> value\"];
;; }
;; "
(cl-dataflow:pipeline->mermaid *pipeline* :direction "LR")
;; => "flowchart LR
;; n0[\"finish\"]
;; n1[\"start\"]
;; n1 -->|value -> value| n0
;; "
Note that nodes are emitted in name order (finish before start), not
execution order, and every edge carries a from-port -> to-port label — the
default value ports here — so parallel edges across different ports stay
distinguishable.
:name and :direction default to "pipeline" and "TD", matching the
graph-level defaults.
Structural role enumeration¶
Four functions answer "what is in this pipeline, and what role does each node play" without walking the graph by hand:
| Function | Returns |
|---|---|
pipeline-node-names |
Every node name in the graph, lexicographically ordered (delegates to graph-node-names). |
pipeline-stage-names |
Stage names in execution order (the order run-pipeline runs them in). |
pipeline-source-names |
Names of source nodes — indegree 0 — name-ordered. |
pipeline-sink-names |
Names of sink nodes — no successors — name-ordered. |
(cl-dataflow:pipeline-node-names *pipeline*)
;; => ("finish" "start")
(cl-dataflow:pipeline-stage-names *pipeline*)
;; => ("start" "finish")
(cl-dataflow:pipeline-source-names *pipeline*)
;; => ("start")
(cl-dataflow:pipeline-sink-names *pipeline*)
;; => ("finish")
Note the difference between pipeline-node-names (alphabetical, useful for
set-like comparisons) and pipeline-stage-names (execution order, useful for
understanding data flow) — they can diverge whenever a node's name doesn't
happen to sort the same way it runs.
Trace formatting and summarizing¶
Every pipeline run and state-machine step appends to context-trace: node
runs, emitted events, performed effects, and state transitions all land in
one unified, chronologically ordered log. format-trace, trace-summary,
and context-summary turn that raw plist trace into something you can read
or report on.
format-trace renders the whole trace as numbered, human-readable lines:
(princ (cl-dataflow:format-trace *context*))
;; 0. event order-created
;; 1. transition idle --order-created--> order-created
;; 2. node start
;; 3. node finish
A node's trace record is appended after its handler returns, so anything the
handler emits along the way — events, effects, state transitions — lands
before its own node entry. Above, start's handler emitted the event and
stepped the state machine, so both precede node start.
trace-summary counts trace entries by kind:
context-summary gives a broader roll-up of the context itself — event,
effect, and stored-value counts, the trace length, and the current state —
rather than just the trace:
(cl-dataflow:context-summary *context*)
;; => (:events 1 :effects 0 :values 2 :trace 4 :state "order-created")
Here is a runnable example, adapted from the workflow in Getting Started, that drives a small workflow and then inspects it with both functions:
(defparameter *machine*
(cl-dataflow:make-state-machine
:state "idle"
:transitions
(list (cl-dataflow:make-transition "idle" "order-created" "order-created"))))
(defparameter *workflow*
(cl-dataflow:make-pipeline
:stages
(list (cl-dataflow:make-node
"create-order"
:handler (lambda (input context)
(cl-dataflow:emit-event context "order-created" :payload input)
(cl-dataflow:step-state-machine
*machine* "order-created" :context context)
input)))))
(defparameter *context*
(cl-dataflow:run-pipeline-with-test-context
*workflow* :input "A-100" :state "idle"))
(princ (cl-dataflow:format-trace *context*))
;; 0. event order-created
;; 1. transition idle --order-created--> order-created
;; 2. node create-order
(cl-dataflow:trace-summary *context*)
;; => (:total 3 :nodes 1 :events 1 :effects 0 :transitions 1)
(cl-dataflow:context-summary *context*)
;; => (:events 1 :effects 0 :values 1 :trace 3 :state "order-created")
Introspection: merging, filtering, and describing¶
Beyond formatting a single context's trace, src/introspection.lisp
provides three cross-cutting tools for working with contexts and flow
objects generically.
Combining two contexts¶
context-merge returns a new context that combines two runs — useful
when a workflow forks into parallel branches and you need to reassemble a
single observable record:
The merge rules:
- Stored node values:
*context-b*'s values overlay*context-a*'s (*context-b*wins on key collisions). - Events, effects, and trace: concatenated,
*context-a*'s entries first. - Effect handlers: merged into one table, with
*context-b*'s handlers overlaying*context-a*'s on key collisions. - Metadata: the two plists are concatenated with
*context-a*'s first. Sincegetfreturns the first match,*context-a*wins on duplicate keys — the opposite of the effect-handler rule, and the opposite direction from stored node values. Merging(:m 1)with(:m 2 :n 3)yields(:m 1 :m 2 :n 3). - Current state and result: taken from
*context-a*(the base).
Neither input context is modified — every context reader hands back a copy, so the merge builds its tables without touching either input.
Filtering a trace by kind¶
context-trace-of-kind narrows a context's trace to one entry kind —
:node, :event, :effect, or :transition — in chronological order,
which is handy when format-trace's combined view is too broad:
(cl-dataflow:context-trace-of-kind *context* :transition)
;; => ((:from "idle" :event-type "order-created" :to "order-created"
;; :state-before "idle" :guard-passed t :action-result nil))
A uniform structural view: flow-describe / flow-children¶
flow-children returns the immediate sub-components of a flow object:
- a graph's nodes, name-ordered;
- a pipeline's stages, in execution order;
- a state machine's transitions.
Leaf objects — nodes, edges, events, effects, transitions, and contexts —
have no children and return '().
flow-describe combines flow-kind, flow-name, flow-metadata, and a
child count (not the children themselves) into one structural plist,
giving generic tooling a single call that works the same way regardless of
what kind of object it's handed:
(cl-dataflow:flow-describe *pipeline*)
;; => (:kind :pipeline :name :pipeline :metadata () :children 2)
(cl-dataflow:flow-describe (cl-dataflow:find-node (cl-dataflow:pipeline-graph *pipeline*) "start"))
;; => (:kind :node :name "start" :metadata () :children 0)
The flow-name / flow-metadata / flow-kind protocol¶
src/protocols.lisp defines one introspection protocol implemented across
every public flow object — node, edge, graph, context, event,
effect, state-transition, state-machine, and pipeline — via a shared
define-flow-dispatch macro:
| Function | Purpose |
|---|---|
flow-name |
The object's identifying name or key: a node's name, an edge's (from to) pair, an event's type, a transition's event type, a state machine's current state, or a fixed keyword (:graph, :context, :pipeline) for objects without an intrinsic name. |
flow-metadata |
The object's metadata plist (empty list if none was set). |
flow-kind |
A keyword tag identifying the object's type: :node, :edge, :graph, :context, :event, :effect, :state-transition, :state-machine, or :pipeline. |
The protocol exists so generic tooling can treat every flow object the
same way without a large typecase at every call site — flow-describe
and flow-children above are exactly that: they are built entirely on top
of flow-name/flow-metadata/flow-kind plus a per-type children rule,
rather than re-deriving type dispatch themselves. Any object outside that
closed set signals a type-error naming the expected types, so a caller
passing something unsupported gets a clear failure rather than silent
nil.
(cl-dataflow:flow-kind *pipeline*)
;; => :pipeline
(cl-dataflow:flow-name (cl-dataflow:make-edge "start" "finish"))
;; => ("start" "finish")
Serialization: contexts, events, and effects¶
src/context-serialization.lisp completes the plist round-trip story that
graphs (graph-to-plist/plist-to-graph, see Graphs),
pipelines, and state machines (see Pipelines and Workflows
and State Machine Analysis) already have.
event-to-plist/plist-to-event and effect-to-plist/plist-to-effect
round-trip a single event or effect:
(cl-dataflow:event-to-plist (cl-dataflow:make-event "order-created" :payload '(:order-id "A-100")))
;; => (:type "order-created" :payload (:order-id "A-100") :metadata nil :trace-index nil)
(cl-dataflow:effect-to-plist (cl-dataflow:make-effect "charge-card" :payload 4200 :result :ok))
;; => (:type "charge-card" :payload 4200 :metadata nil :trace-index nil :result :ok)
context-to-plist serializes a context's entire observable state —
stored node values, events, effects, trace, metadata, state, and result —
with events/effects/trace normalized into chronological order.
plist-to-context rebuilds a context from that plist. Here is a full round
trip over the *context* from the workflow example above, showing what
survives:
(defparameter *plist* (cl-dataflow:context-to-plist *context*))
(getf *plist* :state)
;; => "order-created"
(getf *plist* :events)
;; => ((:type "order-created" :payload "A-100" :metadata nil :trace-index 0))
(defparameter *restored* (cl-dataflow:plist-to-context *plist*))
(cl-dataflow:context-state *restored*)
;; => "order-created"
(mapcar #'cl-dataflow:event-type (cl-dataflow:context-events-in-order *restored*))
;; => ("order-created")
(cl-dataflow:context-equal-p *context* *restored*)
;; => T
Effect handlers are runtime closures, not data, so context-to-plist
deliberately excludes context-effect-handlers — a context rebuilt by
plist-to-context has an empty handler table, just as plist-to-graph
rebuilds nodes with the default identity handler rather than preserving
the original closures:
(cl-dataflow:context-effect-handlers *restored*)
;; => #<HASH-TABLE :TEST EQUAL :COUNT 0> (empty — handlers are not serialized)
Register handlers again on the restored context (register-effect-handler)
before running it further.
Structural equality and reachability¶
src/equality-predicates.lisp generates four structural-equality predicates
from one define-plist-equal-p macro, all built on the same idea: two values
are structurally equal when their deterministic plist serializations are
equal, so runtime closures (node handlers, effect handlers, state-machine
guards and actions) never affect the comparison.
| Predicate | Compares |
|---|---|
graph-equal-p |
Nodes (names, ports, metadata) and edges (endpoints, ports, metadata), independent of insertion order (via graph-to-plist; see Graphs). Node handlers are ignored. |
pipeline-equal-p |
Graphs, stage order, and metadata (via pipeline-to-plist). Node handlers are ignored. |
state-machine-equal-p |
Current state, initial state, metadata, and transitions by from/event/to/metadata (via state-machine-to-plist). Guards and actions are ignored. |
context-equal-p |
Stored values, events, effects, trace, metadata, state, and result (via context-to-plist). Effect handlers are ignored. |
(cl-dataflow:pipeline-equal-p *pipeline* (cl-dataflow:copy-pipeline *pipeline*))
;; => T
(cl-dataflow:context-equal-p *context* *restored*)
;; => T
state-machine-reachable-p is a reachability predicate rather than an
equality one: it asks whether state to can be reached from state from
by following zero or more transitions (so from = to is trivially
reachable), comparing state names case-insensitively:
Where to go next¶
- Graphs covers
graph->dot/graph->mermaidandgraph-to-plist/plist-to-graph, the lower-level renderers and serializer this page's pipeline functions build on. - Pipelines and Workflows covers
pipeline-to-plist/plist-to-pipelineand the rest of the pipeline construction API. - State Machine Analysis covers
state-machine-to-plist/plist-to-state-machineand the analysis functions (state-machine-reachable-states,state-machine-unreachable-states, ...) thatstate-machine-reachable-pbuilds on. - Public API Reference lists every function on this page in one place, across its Observability, Context, Protocols, and serialization/equality groups.