Activity API
Last updated 03 November 2017 trailblazer-activity v0.2An activity is a collection of connected tasks with one start event and one (or many) end events.
Overview
Since TRB 2.1, we use BPMN lingo and concepts for describing workflows and processes.
An activity is a workflow that contains one or several tasks. It is the main concept to organize control flow in Trailblazer.
The following diagram illustrates an exemplary workflow where a user writes and publishes a blog post.
After writing and spell-checking, the author has the chance to publish the post or, in case of typos, go back, correct, and go through the same flow, again. Note that there’s only a handful of defined transistions, or connections. An author, for example, is not allowed to jump from “correct” into “publish” without going through the check.
The activity
gem allows you to define this activity and takes care of implementing the control flow, running the activity and making sure no invalid paths are taken.
Your job is solely to implement the tasks and deciders put into this activity - Trailblazer makes sure it is executed it in the right order, and so on.
To eventually run this activity, three things have to be done.
- The activity needs be defined. Easiest is to use the Activity.from_hash builder.
- It’s the programmer’s job (that’s you!) to implement the actual tasks (the “boxes”). Use tasks for that.
- After defining and implementing, you can run the activity with any data by
call
ing it.
Operation vs. Activity
An Activity
allows to define and maintain a graph, that at runtime will be used as a “circuit”. Or, in other words, it defines the boxes, circles, arrows and signals between them, and makes sure when running the activity, the circuit with your rules will be executed.
Please note that an Operation
simply provides a neat DSL for creating an Activity
with a railway-oriented wiring (left and right track). An operation always maintains an activity internally.
class Create < Trailblazer::Operation
step :exists?, pass_fast: true
step :policy
step :validate
fail :log_err
step :persist
fail :log_db_err
step :notify
end
Check the operation above. The DSL to create the activity with its graph is very different to Activity
, but the outcome is a simple activity instance.
When call
ing an operation, several transformations on the arguments are applied, and those are passed to the Activity#call
invocation. After the activity finished, its output is transformed into a Result
object.
Activity
To understand how an activity works and what it performs in your application logic, it’s easiest to see how activities are defined, and used.
Activity: From_Hash
Instead of using an operation, you can manually define activities by using the Activity.from_hash
builder.
activity = Activity.from_hash do |start, _end|
{
start => { Trailblazer::Activity::Right => Blog::Write },
Blog::Write => { Trailblazer::Activity::Right => Blog::SpellCheck },
Blog::SpellCheck => { Trailblazer::Activity::Right => Blog::Publish,
Trailblazer::Activity::Left => Blog::Correct },
Blog::Correct => { Trailblazer::Activity::Right => Blog::SpellCheck },
Blog::Publish => { Trailblazer::Activity::Right => _end }
}
end
The block yields a generic start and end event instance. You then connect every task in that hash (hash keys) to another task or event via the emitted signal.
Activity: Call
To run the activity, you want to call
it.
my_options = {}
last_signal, options, flow_options, _ = activity.( nil, my_options, {} )
- The
start
event iscall
ed and per default returns the generic signalTrailblazer::Circuit::Right
. - This emitted (or returned) signal is connected to the next task
Blog::Write
, which is nowcall
ed. -
Blog::Write
emits anotherRight
signal that leads toBlog::SpellCheck
beingcall
ed. -
Blog::SpellCheck
defines two outgoing signals and hence can decide what next task to call by emitting eitherRight
if the spell check was ok, orLeft
if the post contains typos. - …and so on.
Visualizing an activity as a graph makes it very straight-forward to understanding the mechanics of the flow.
Note how signals translate to edges (or connections) in the graph, and tasks become vertices (or nodes).
The return values are the last_signal
, which is usually the end event (they return themselves as a signal), the last options
that usually contains all kinds of data from running the activity, and additional args.
Activity: From_Wirings
TODO: currently, this is not relevant for normal use cases.
Signal
Signals are objects emitted or returned by tasks and activities. Every signal returned by a task needs to be wired to a follow-up task or event in the circuit. Otherwise, you will see a IllegalOutputSignalError
from the circuit at run-time.
Please note that a signal can be any object, it doesn’t necessarily have to be Circuit::Right
or Circuit::Left
. These are simple generic library signals, but you can use strings, your own classes or whatever else makes sense for you.
The decoupling of return values (signals) and the actual wiring in the activity is by design and allows to reconnect tasks and their outputs without having to change the implementation.
Task
Every “box” in a circuit is called task in Trailblazer. This is adopted from the BPMN standard. A task can be any object with a call
method: a lambda, a callable object, an operation, an activity, etc. As long as it follows the task interface, anything can be plugged into an activity’s circuit.
Task Interface
The task interface is the low-level interface for tasks in activities. It is identical to call
in the Activity interface.
task = lambda do | signal, options, flow_options, *args |
puts "Hey, I was called!"
options["model"] = Song.new
[ Trailblazer::Circuit::Right, options, flow_options, *args ]
end
While signal
as the emitted signal from the previous task is usually to be ignored, options
represents the incoming run-time data, flow_options
is a library-level data structure, and an arbitrary number of additional incoming arguments need to be accepted and returned.
It’s up to the task whether to write to options
, create a new object, etc.
The returned signal (e.g. Right
) is crucial as it is used to determine the next task after this one.
All returned data is directly passed as input arguments to the next task or event.
Always remember that the task interface is the pure, low-level form for tasks. It allows to access and return any data that is available and relevant for running activities.
The step interface is a higher level interface for “tasks” that is introduced by trailblazer-operation
. It is more convenient to use for developers but gives you a limited number of run-time arguments, only.
Tasks can also be any callable object, for example a class with a call
class method.
class MyTask
def self.call( signal, options, flow_options, *args )
puts "Hey, I was called!"
options["model"] = Song.new
[ Trailblazer::Circuit::Right, options, flow_options, *args ]
end
end
Activity Interface
The Activity interface allows you to use any kind of object as an activity, as long as it follows this interface. This is especially helpful when composing complex workflows where activities call activities, etc. as it doesn’t limit you to operations, only.
You need to expose two public methods, only.
Activity#call
Activity#outputs
Activity Interface: Call
The call
method runs the instance’s circuit with a provided set of arguments.
results = activity.call( last_signal, options, flow_options, *args )
The inbound arguments are
-
last_signal
The signal emitted from the previous activity/task. Usually, this is ignored, but it allows you to start the activity from some other point, depending on thatlast_signal
. Sometimes, that signal is also called direction in the code base. -
options
is runtime data from the caller. Depending on your mutation strategy, this should be treated as immutable. -
flow_options
contains additional framework data for flow control, the task wraps, tracing, etc. Leave this alone unless you know what you’re doing. -
*args
The activity interface requires any additional numbers of arguments to be accepted (and returned!).
The returned objects from the call
are almost identical to the incoming.
results #=>
[ last_signal, options, flow_options, *args ]
Here, last_signal
is your last signal emitted, and options
are all old options plus whatever your activity added. All additional arguments must be returned in the same order.
The signature of an activity (call
arguments and returned objects) is also known as Task interface.
Activity Interface: Outputs
An activity also has to expose the outputs
method that defines its end events with semantic data.
activity.outputs #=>
{
<Event::End::Success xxx> => {
role: :success
},
<Event::End::Failure xxx> => {
role: :failure
},
<Event::End::Failure 0x1> => {
role: :unauthorized
},
}
Any last_signal
returned from call
must be a key in the outputs
hash. The value hash must contain the key :role
that specifies a semantical purpose what this end event represents.
Currently, only :success
and :failure
are canonically understood, but with the emerge of the activity
gem, we expect more standardized ends to come.
The :role
key makes sure that nested activities’ ends can automatically be connected in the composing, outer activity.
Subprocess
A major concept of both BPMN and Trailblazer is to be able to compose activities with activities. What is a function or a method in programming is a subprocess in BPMN: a nested activity.
call omits start event
bla
circuit.([options, flows], **circuit_args)
signal, [], __ignored_circuit_args = task.([options, flows],**circuit_args)