Zeta Workflow 07: Execution Sniffer Plugin
Having now written the narrative documentation, it looks to me like it would be useful to have a plugin which captures the execution state after each node executes. I’m writing this walk through as I design and test this plugin.
Remember: I’m making this up as I go!
Features
I have these preliminary goals:
- Understand the execution state of the current (most recently running) execution
- Provide access to the current execution’s variables
- Provide a list of all executions currently active. This goal is optional, being more an understanding/debugging aid than a production need
I picture the sniffer as being the passive go-between connecting the current Zeta Workflow thread to the Laravel wrapper (the Zeta Components Interface). The wrapper, in theory, only has access to the main thread. We could have any number of subflows, parallel executions, and so on. The wrapper has no way to sort this out short of diving in through the Zeta Workflow internals.
Take, for example, the case where a node decides to switch to another workflow. There is no such mechanism within Zeta Workflow. However, the node could return false, which suspends the workflow, and set an execution variable for the Sniffer to examine.
The Sniffer, in turn, could do something dramatic such as throw an exception. But since the workflow is already suspended, I think the passive approach is sufficient.
In this case the Laravel code which is running the workflow can query the Sniffer, with the Sniffer responding with a message to the effect of “Switch to workflow X.”
I also picture the Sniffer as being hidden entirely within the wrapper. The wrapper (or the Laravel Service Provider) instantiates the Sniffer, attaches it to the workflow execution, and handles any queries of the Sniffer.
We’ll need various tests ensuring that this mechanism actually works, for the main workflow, sub workflows, and parallel executions.
Tracing
As an added bonus, this might be a good mechanism for implementing a workflow event trace. However, an execution visualizer plugin already exists, documented at http://ezcomponents.org/docs/api/latest/Workflow/ezcWorkflowExecutionVisualizerPlugin.html.
That being the case, I’ll set aside the tracing idea for now. If I were to create a tracing mechanism, it looks like creating a separate plugin for that purpose is the way to go. That keeps the plugin in line with the Single Responsiblity Principle, and we only need to attach it to a workflow when tracing is needed.
Single Responsibility
The workflow can run a list of any number of plugins. Each plugin can attach itself to any number of events, or focus on a single specific event. For example, a plugin can be activated when the workflow suspends and when the workflow ends, but ignore all other events.
That being the case, it makes sense, when implementing plugins, to make a number of small plugins with a single clear responsibility or purpose, rather than a larger plugin with multiple responsibilities.
If we come up with a bunch of plugins, we might even want to create a plugin that attaches the plugins!
Sniffer Components
As a first cut, we can separate the Sniffer into the following components. Each component has a single well defined responsibility.
- The plugin itself extends ezcWorkflowExecutionPlugin. The plugin communicates with the execution.
- We maintain execution state with an Execution State object. Given the Zeta Workflow execution, the Execution State object captures and retains that execution’s current execution state. Our Sniffer will have multiple Execution State objects.
- The Execution State Interpreter is closely tied to whatever message a node meant to send. For example, the Execution State Interpreter might pass back the information that we are now to switch to workflow X. This formalizes how we already announce the next slide to display. We check the execution variable ‘slide’.
- The Execution Sniffer communicates with the wrapper. The Execution Sniffer is itself a composition bringing together the above components.
Since we have several components, we’ll create a new Sniffer directory and a corresponding tests/sniffer directory. We’ll define respective file namespaces accordingly.
Feature Details
Let’s lay out what each component should do. We’ll quickly discover that we’ll need another component which keeps track of the various Execution State objects. Call it the Execution State Registry.
The Plugin
The available methods are documented here: http://ezcomponents.org/docs/api/latest/Workflow/ezcWorkflowExecutionPlugin.html. For now let’s keep this simple and only implement afterNodeExecuted().
When called, we want to do the following.
- Notify the Execution State Registry that this execution is, as of this moment, the current execution.
- Obtain the relevant Execution State object from the Execution State Registry.
- Have the relevant Execution State object capture and retain this execution’s current execution state.
Our unit test, therefore, can be frightfully simple. We can pass in mocks of the execution and node, and condition mocks of the Execution State Registry and Execution State object to ensure they are called as intended.
To be sure, we may have some frightfully complex scenarios to explore, such as synchronized parallel executions with paused sub workflows. But we’ll get to those later!
The Execution State Object
The execution state object retains the execution state as defined by isCancelled(), hasEnded(), and isSuspended(); and the execution variables as obtained via getVariables().
The functionality is:
- Capture execution state, given the execution as passed to the plugin.
- Disgorge the last-captured execution state. Mirror the execution methods isCancelled(), hasEnded(), isSuspended, getVariables(), getVariable(), hasVariable(), getId().
Our unit tests will explore the API of each method, followed by verifying the functionality in each case.
The Execution State Interpreter
We’ll keep the interpreter simple. The interpreter returns a text string. Here is the logic:
- If the ‘cancel’ variable exists, return “cancel”.
- If the ‘swap’ variable exists, it names the new workflow. Return “swap “ followed by the new workflow name. See the below discussion.
- If the ‘slide’ variable exists, return “slide “ followed by the slide variable’s value.
- Otherwise, return an empty string.
We could do a series of workflow swaps, for example, if our first workflow determines the customer’s demographics; we swap to a second workflow which determines the appropriate offer group; that offer group is a workflow which presents a specific offer.
Where this gets tricky is with sub workflows and parallel executions. So far as I know, if we are switching to a new workflow, the only thing we can do is cancel the current workflow, and proceed with the new workflow. The cancel() destroys all vestiges of the current workflow, including sub workflows and parallel threads.
Another option might be to leave the current workflow suspended, run the new workflow, and then resume the suspended workflow.
Another option might be that the workflow ends itself, presenting the new workflow name as its final result. We then proceed with the new workflow.
The Execution Sniffer
The Execution Sniffer communicates with the Zeta Components Interface (Wrapper). Either the Wrapper, or the Sniffer, is responsible for ensuring the plugin is attached to all workflows.
Since the Wrapper is responsible for instantiating workflow executions, I’m inclined to make the Wrapper responsible for attaching the plugin when it does so. But, as I think about that, I think we’ll make the Wrapper responsible for telling the Sniffer to attach its plugin.
We need to do a bit of homework. Once a plugin is attached to the main workflow, does it get attached to sub workflows and parallel threads?
Let’s check the sub workflow first. See zetacomponents/workflow/src/nodes/sub_workflow.php.
public function execute( ezcWorkflowExecution $execution ) { if ( $execution->definitionStorage === null ) { throw new ezcWorkflowExecutionException( 'No ezcWorkflowDefinitionStorage implementation available.' ); } $workflow = $execution->definitionStorage->loadByName( $this->configuration['workflow'] ); // Sub Workflow is not interactive. if ( !$workflow->isInteractive() && !$workflow->hasSubWorkflows() ) { $subExecution = $execution->getSubExecution( null, false ); $subExecution->workflow = $workflow; $this->passVariables( $execution, $subExecution, $this->configuration['variables']['in'] ); $subExecution->start(); }
That’s enough code to tell us what we need to know. See line 15. We need to check $execution->getSubExecution( null, false ). On a side note, it looks like we can only use sub workflows if we are using the tied database. definitionStorage is the tied database component, and line three makes it mandatory for the Sub Workflow node.
GetSubExecution() is in zetacomponents/workflow/src/interfaces/execution.php, the same file containing start() and resume() we studied earlier.
/** * Returns a new execution object for a sub workflow. * * If this method is used to resume a subworkflow you must provide * the execution id through $id. * * If $interactive is false an ezcWorkflowExecutionNonInteractive * will be returned. * * This method can be used by nodes implementing sub-workflows * to get a new execution environment for the subworkflow. * * @param int $id * @param bool $interactive * @return ezcWorkflowExecution * @ignore */ public function getSubExecution( $id = null, $interactive = true ) { if ( $interactive ) { $execution = $this->doGetSubExecution( $id ); } else { $execution = new ezcWorkflowExecutionNonInteractive; } foreach ( $this->plugins as $plugin ) { $execution->addPlugin( $plugin ); } return $execution; }
Line 31 is our answer. For each plugin in the parent execution (line 29), we add that plugin to the new sub execution (line 31).
What about parallel executions? I took a moment to chase that down as best I could from the online documentation, and it looks like we activate nodes in the current execution but do NOT create a new execution. So, it appears we have our plugin in place. If we ever do parallel threads, we’ll want to test and validate our assumption.
Homework complete, let’s lay out the Execution Sniffer features.
- When requested by the wrapper, and given the execution, attach our plugin to the execution. attachPlugin()
- Return the current Execution State Interpreter result. currentInterpretation()
- Return execution state of the current Execution State object. currentIsCancelled(), currentHasEnded(), currentIsSuspended(), currentGetVariables(), currentGetVariable(), currentHasVariable(), currentGetId().
We’ll have the Laravel Service Provider wire everything together.
Execution State Registry
Since we potentially have multiple executions, we need a registry to manage the multiple Execution State objects. The registry also maintains the notion of which is the “current” Execution State object. The features are:
- Accept notification that “this” is the current execution.
- For a given execution, return the relevant Execution State object. We either fetch it from our cache, or construct it on the spot. I am assuming that, by this point, each execution has an id. If not, initially, throw an exception. Later, we should probably ignore executions with no id, assuming that later in the workflow execution it WILL have an id, and we can snag it at that point.
- Return the “current” Execution State object.
Development Plan
Our general approach is to begin with the component with the least dependencies. We begin by writing tests exploring its interface (constructor and callable methods). We then drill down, fleshing out and verifying its functionality.
Having fully developed one component, we can accurately mock it while developing later components. Having fully unit tested it, we can be confident that mocking is sufficient.
We’ll develop our components in the following order, based on each component’s dependencies:
- The Execution State Object. Its only dependency is the execution, which we’ll mock.
- The Execution State Registry. Its only dependencies are Execution State Objects.
- The Execution State Interpreter. It acts on an Execution State Object.
- The Plugin. It works with the Execution State Registry and Execution State Objects.
- The Execution Sniffer. It is composed of each of the above objects.
Up next: The Execution State Object.