Zeta Workflow 03: Failure Handling Walkthrough
Here we look at the nuances of start() and resume(). We walk through several components along the way.
Let’s take my usual test-first approach. Our aim is to characterize and verify Zeta Workflow’s behavior in failure scenarios.
Failure vs. Exception
Draw a careful distinction between “failure” and “exception”:
- A “failure” is when the node (or its service object) returns false indicating the workflow should NOT proceed.
- An “exception” is when the node (or its service object) throws an Exception.
As I developed the package, my primary use case has been “At this point, tell Laravel to present slide X.” We implement this by using the SlideProvider callback you have already seen. We’ll continue this approach by using the same type of scenario, but condition SlideProvider to return false, telling Zeta Workflow something failed.
The Unit Test
Our unit test tests/rollback/slideFailTest.php begins:
namespace StarTribune\Workflow\Tests\Workflow; require_once __DIR__.'/../DbTestCase.php'; use StarTribune\Workflow\Wrapper\ZetaComponentsWrapper; use StarTribune\Workflow\Wrapper\ZetaComponentsInterface; class slideFailTest extends \StarTribune\Workflow\Tests\DbTestCase {
We already have lots to talk about!
Laravel vs. PHPDocumentor Expectations
The namespace declaration (line 1) is for phpdoc. The file, in full, begins:
<?php /** * Verify workflow behavior upon node failure * @package StarTribune/Workflow * * @author ewb (3/23/2014) */ namespace StarTribune\Workflow\Tests\Workflow;
There is a problem with the above. The Laravel package coding standard expects the namespace declaration to be on the same line as that initial <?php. However, phpdocumentor 2 expects a “file docblock” and a “class docblock.” The comment above is the file docblock. If I move it after the namespace declaration, phpdocumentor fails to recognize it as the file docblock.
To get a clean phpdoc run, therefore, I moved ALL namespace declarations to be after the file docblock. The clean phpdoc run, obviously, is at the cost of (so far as I know) violating the Laravel package coding standard.
The unit tests are divided into folders. Each folder gets its own namespace, so that the phpdoc documentation is also structured the same way. Without that, all the unit tests are jumbled into a single list.
Our unit test extends DbTestCase (line 6 of the first snippet). We therefore require_once the DbTestCase file (line 3 of the first snippet). DbTestCase creates and tears down our database tables for each unit test. That means the tests run slowly, but we don’t really have much choice since it’s important we exercise the real tie-in code.
Instantiating the Interface
See line 5 of the first snippet. ZetaComponentsInterface is our wrapper API. All users of the package should code to the Interface not the Wrapper. The API file itself is the definitive documentation of what the wrapper does and how to use it. That documentation is in the package phpdoc folder.
The Laravel Service Provider connects ZetaComponentsWrapper to ZetaComponentsInterface as follows:
public function bindZetaComponentsInterface() { $this->app->bind('StarTribune\Workflow\Wrapper\ZetaComponentsInterface', function($app){ $slide = $this->app->make('StarTribune\Workflow\Wrapper\SlideHelper'); $parms = array('slide' => $slide); $node = new Wrapper\NodeHelper($parms); $parms['node'] = $node; $implementation = new Wrapper\ZetaComponentsWrapper($parms); return $implementation; }); }
The above looks weird in that I am using a combination of “new” and “$this->app->make().” I could not get the Service Provider to work if I used App::make() throughout. Perhaps someone with more Laravel experience can improve that code. (This is my first time using Laravel.)
Test Setup
As you will see next, we need only instantiate the ZetaComponentsInterface, and the ServiceProvider will take care of the wrapper and its helpers.
Here is the unit test setup:
public $wrapper; public $slides; public $main = 'main'; public function setUp() { parent::setUp(); if(preg_match('/memory/', $this->dbenvironment)) { $this->markTestSkipped("Not propagating migration into tiein connection"); } $this->wrapper = \App::make('StarTribune\Workflow\Wrapper\ZetaComponentsInterface'); $this->registerSlides(); $this->buildWorkflow(); }
It’s important we do parent::setUp() (line 5) before our own setup. The parent setup creates the database tables we will use.
Next we check which database engine we are using. I have the tests set up to run with MySQL, sqlite, and sqlite :memory:. The tests simply don’t work whenever we are using both the Laravel connection and the Zeta Components tied database connection. Each connection creates its own in-memory database. So, where we need both to be hitting the same database, we just skip all tests with the memory flavor.
We’ll revisit the “which database engine” issue when we examine the Makefile later in this series of posts.
From here on out we use the Wrapper. If anything isn’t clear, check the ZetaComponentsInterface documentation. Line 9 instantiates the Zeta Components Interface, which as we know from the Service Provider code, is actually instantiating the Zeta Components Wrapper.
The last two setUp() items (lines 10 and 11) are registerSlides() and buildWorkflow(). We look at these next.
Register Slides
One of the Wrapper’s main purposes is to coordinate “show this slide at this point” with the workflow Action Node. The wrapper makes the text string “slide1″, for example, synonymous with the Action Node which invokes the Service Object which sets the execution’s “slide” variable to the value for “slide1”.
We start this process by telling the Wrapper what slides we’ll be using.
public function registerSlides() { $this->slides = array( 'slide1' => 'Slide_1', 'slide2' => 'Slide_2', 'fail1' => 'Fail_1', 'exception1' => 'Exception_1', ); $this->wrapper->createSlides($this->slides); }
The key, e.g., ‘slide1’ on line 3, is how we’ll refer to the slide throughout the workflow. The value (e.g., ’Slide_1’ on line 3) is intended to name the relevant Laravel view. Pass the list of slides to createSlides(), and we’re done.
Next we build the workflow.
public function buildWorkflow() { $this->wrapper->createWorkflow($this->main); $this->wrapper->addToStart('slide1'); $this->wrapper->addSlideOutNode('slide1', 'fail1'); $this->wrapper->addSlideOutNode('fail1', 'slide2'); $this->wrapper->endHere('slide2'); $this->wrapper->saveWorkflow(); }
We are creating a workflow named ‘main’ (line 2). From the start node we proceed to slide1 (line 3), then fail1 (line 4), then slide2 (line 5), then the end node (line 6). The workflow MUST be saved to the tied database. That’s handled by saveWorkflow() on line 7.
The Test Double
This test is all about error handling. Therefore I added a bit of code to the SlideProvider which simulates failures. You can see the original SlideProvider at Zeta Workflow 01: Workflow Communication. Here is the modified version:
class SlideProviderException extends \Exception { } class SlideProvider implements \ezcWorkflowServiceObject { private $_slide; public function __construct($slide) { $this->_slide = $slide; } public function execute(\ezcWorkflowExecution $execution) { $execution->setVariable('slide', $this->_slide); $response = $this->determineResponse($execution); return $response; } /** * Testing hook to allow for alternate behavior. We set the "continue" variable * as a way of tracking the number of calls here. Note that this variable is * effectively global, not specific to this slide instance. The first time * through, "continue" gets set to 0 AFTER testing for its existence, and * incremented by 1 thereafter. This allows us to get through a multi-resume * scenario. */ public function determineResponse(\ezcWorkflowExecution $execution) { $response = true; if(preg_match('/^fail/i', $this->_slide)) { $continue = 0; if($execution->hasVariable('continue')) { $continue = $execution->getVariable('continue'); $execution->setVariable('continue', $continue+1); } else { $execution->setVariable('continue', 0); } $response = $continue ? true : false; } elseif(preg_match('/^exception/i', $this->_slide)) { $continue = 0; if($execution->hasVariable('continue')) { $continue = $execution->getVariable('continue'); $execution->setVariable('continue', $continue+1); } else { $execution->setVariable('continue', 0); } if(!$continue) { throw new SlideProviderException("Slide '{$this->_slide}' triggers exception. continue: ". $execution->getVariable('continue')); } } return $response; } }
What this means is that when we reach the slide ‘fail1’, execute() will return false, and the workflow will suspend. Line 27 sees that the slide name begins with the word fail. Line 28 sets $continue to 0. Line 29 checks to see if the execution has a variable named continue. It does not, because we did not yet set it. We take the else path, line 32, and set the execution variable continue to zero. Since our local variable $continue remains zero, line 35 sets $response to false.
We fall through to line 50, which returns $response false. This pops us back to execute() at line 12, and execute() returns false on line 13. When a Service Object returns false, it is indicating a failure. The execution suspends at the Action Node which called this Service Object.
When we resume, it will again return false and the workflow will suspend. We take the “fail” path at line 27. Line 29 determines that the execution now has the variable “continue”. We set our internal $continue to its value, which is zero, line 30. We increment the execution variable to one, line 31. Line 35 sets $response to false, because our internal $continue variable is zero. Execute() returns false at line 13, which again indicates Fail.
The next time we resume, we return true and continue the workflow. We again key off the slide name fail1 at line 27. We set our local variable $continue to 1 from the persisted execution variable at line 30, and increment the execution variable to 2 at line 31. At line 35 the $response becomes true because $continue evaluates true (in PHP, a 1 casts to boolean true). Execute() returns true at line 13, telling the workflow that the Service Object completed normally and that the workflow may continue.
Unit Tests to Verify Behavior
As we walk through the tests verifying this behavior, remember that the workflow is start -> slide1 -> fail1 -> slide2 -> end.
Verify Slide
Here is how we verify that the correct slide is produced:
public function verifySlide($slideName, $msg = '') { $actual = $this->wrapper->getVariable('slide'); $expected = $this->slides[$slideName]; $this->assertEquals($expected, $actual, $msg); }
We simply compare the workflow variable to the expected slide name.
Start Workflow
To start the workflow:
public function startWorkflow() { $this->wrapper->loadByName($this->main); return $this->wrapper->start(); }
Load the workflow by name, start() it, and return the execution id.
The Unit Tests
First, verify that the workflow indeed starts and suspends:
public function testStart() { $startid = $this->startWorkflow(); $this->assertTrue($startid > 0); }
If the workflow execution suspends (pauses), the execution id is returned. If the workflow completed, start() returns null.
If the workflow suspended, we know the execution state has been saved to the tied database. We could resume execution at a later time by using that execution id.
Next we see if the execution state is what we think it should be. slide1 completed, but so did slide fail1. We therefore expect to see slide fail1.
public function testFailPoint() { $startid = $this->startWorkflow(); $this->verifySlide('fail1'); }
We know (after the fact) that when we resume, we resume with the node that failed. We do NOT resume with the next node. Verify this behavior. That is, when we resume, we expect the slide to remain fail1.
public function testResume() { $startid = $this->startWorkflow(); $resumeid = $this->wrapper->resume(array(), $startid); $this->verifySlide('fail1'); }
In other words, we will NEVER get past the failing node unless the node itself decides to stop failing. That’s why we built determineResponse() in the SlideProvider to “stop failing” after the second resume.
We’re not doing this just to show that we can write test doubles. The point here is that we want to understand and characterize the Zeta Workflow behavior. We need to verify what happens if the node DOES stop failing, and what happens if the node does NOT stop failing.
public function testResumeContinue() { $startid = $this->startWorkflow(); $resumeid1 = $this->wrapper->resume(array(), $startid); $resumeid2 = $this->wrapper->resume(array(), $resumeid1); $this->verifySlide('slide2'); }
Assuming the above test passes, and it does with certain Zeta Workflow fixes in place, we have continued to the end of the workflow. The last slide was slide2.
Finally, verify that we have indeed ended execution.
public function testResumeContinueEnds() { $startid = $this->startWorkflow(); $resumeid1 = $this->wrapper->resume(array(), $startid); $resumeid2 = $this->wrapper->resume(array(), $resumeid1); $this->assertTrue(null === $resumeid2); }
Since the workflow is not suspended, resume() returns null.
Next up: Exceptions are different than failures. In fact, until we did some fixes, a workflow exception locks up the sqlite database table: http://otscripts.com/zeta-workflow-04-exception-handling-walkthrough/.