├── logs └── .gitkeep ├── .scrutinizer.yml ├── .gitignore ├── watch.sh ├── run_tests.sh ├── run_mutation.sh ├── docker-compose.yml ├── docker ├── watch.sh └── run_tests.sh ├── src ├── Exceptions │ ├── TaleException.php │ ├── FailureToBuildStep.php │ └── FailedApplyingAllCompensations.php ├── State │ ├── CloneableStateHelper.php │ └── CloneableState.php ├── Steps │ ├── NamedStep.php │ ├── FinalisingStep.php │ ├── Step.php │ └── LambdaStep.php ├── Execution │ ├── TransactionResult.php │ ├── CompletedStep.php │ ├── Failure.php │ └── Success.php ├── StepBuilder.php └── Transaction.php ├── infection.json.dist ├── tests ├── State │ └── FakeState.php ├── Steps │ ├── Mocks │ │ ├── FailingStep.php │ │ ├── MockStep.php │ │ ├── StepWithFailingCompensate.php │ │ └── MockFinalisingStep.php │ └── LambdaStepTest.php ├── LoggingTest.php ├── FinalisationTest.php ├── StateTest.php ├── StepBuilderTest.php └── TransactionTest.php ├── .travis.yml ├── phpunit.xml ├── Dockerfile ├── composer.json ├── CHANGELOG.md ├── LICENSE ├── psalm.xml └── README.md /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | php: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea/ 3 | composer.lock 4 | logs/* -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | docker-compose run tale docker/watch.sh -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | docker-compose run tale docker/run-tests.sh -------------------------------------------------------------------------------- /run_mutation.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | docker-compose run tale /tools/infection.phar -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | tale: 5 | build: 6 | context: ./ 7 | volumes: 8 | - ./:/app/ 9 | -------------------------------------------------------------------------------- /docker/watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | # Rerun the tests when any php file changes 5 | find . -name '*.php' | entr ./docker/run_tests.sh 6 | -------------------------------------------------------------------------------- /src/Exceptions/TaleException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | tests 8 | 9 | 10 | 11 | 12 | 13 | src/ 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Execution/TransactionResult.php: -------------------------------------------------------------------------------- 1 | add($mockStep); 17 | $transaction->run("starting_state"); 18 | 19 | $this->assertTrue($mockLogger->log->countRecordsWithLevel("debug") > 0); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer:1.7.2 2 | 3 | RUN apk add --no-cache $PHPIZE_DEPS \ 4 | && pecl install xdebug-2.6.1 \ 5 | && docker-php-ext-enable xdebug 6 | 7 | RUN apk add entr # Used by the test watcher 8 | 9 | WORKDIR /tools 10 | 11 | RUN wget https://github.com/infection/infection/releases/download/0.10.3/infection.phar 12 | RUN wget https://github.com/infection/infection/releases/download/0.10.3/infection.phar.asc 13 | RUN chmod +x infection.phar 14 | 15 | WORKDIR /app 16 | 17 | # Grab the composer.* files first so we can cache this layer when 18 | # the dependencies haven't changed 19 | COPY composer.json /app/composer.json 20 | RUN composer install 21 | 22 | COPY . /app/ -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mead-steve/tale", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "Steve Brazier", 7 | "email": "meadsteve@gmail.com" 8 | } 9 | ], 10 | "require": { 11 | "php": ">=7.1, <8.0", 12 | "psr/log": "^1.0" 13 | }, 14 | 15 | "require-dev": { 16 | "phpunit/phpunit": "^7.2", 17 | "phpstan/phpstan": "^0.11.5", 18 | "squizlabs/php_codesniffer": "^3.3", 19 | "monolog/monolog": "^1.23", 20 | "vimeo/psalm": "^3.2", 21 | "gamez/psr-testlogger": "^3.0" 22 | }, 23 | 24 | "autoload": { 25 | "psr-4": { 26 | "MeadSteve\\Tale\\": "src/", 27 | "MeadSteve\\Tale\\Tests\\": "tests/" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Steps/Mocks/MockStep.php: -------------------------------------------------------------------------------- 1 | executedState = $state; 15 | return $state; 16 | } 17 | 18 | public function compensate($state): void 19 | { 20 | $this->revertedState = $state; 21 | } 22 | 23 | /** 24 | * @return string the public name of this step 25 | */ 26 | public function stepName(): string 27 | { 28 | return "mock step"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Steps/Step.php: -------------------------------------------------------------------------------- 1 | step = $step; 32 | $this->state = $state; 33 | $this->stepId = $stepId; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Execution/Failure.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 16 | } 17 | 18 | /** 19 | * @throws \Throwable 20 | */ 21 | public function throwFailures() 22 | { 23 | throw $this->exception; 24 | } 25 | 26 | /** 27 | * Whatever the final state was after finishing the transaction 28 | * @return mixed 29 | */ 30 | public function finalState() 31 | { 32 | return $this->exception; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/FailedApplyingAllCompensations.php: -------------------------------------------------------------------------------- 1 | caughtExceptions = $caughtExceptions; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Execution/Success.php: -------------------------------------------------------------------------------- 1 | finalState = $finalState; 20 | } 21 | 22 | public function throwFailures() 23 | { 24 | // Nothing to be thrown as this is a success 25 | return $this; 26 | } 27 | 28 | public function finalState() 29 | { 30 | if ($this->finalState instanceof CloneableState) { 31 | return $this->finalState->cloneState(); 32 | } 33 | return $this->finalState; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Steps/Mocks/MockFinalisingStep.php: -------------------------------------------------------------------------------- 1 | executedState = $state; 17 | return $state; 18 | } 19 | 20 | public function compensate($state): void 21 | { 22 | $this->revertedState = $state; 23 | } 24 | 25 | /** 26 | * @return string the public name of this step 27 | */ 28 | public function stepName(): string 29 | { 30 | return "mock finalising step"; 31 | } 32 | 33 | public function finalise($state): void 34 | { 35 | $this->finalisedState = $state; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/StepBuilder.php: -------------------------------------------------------------------------------- 1 | executeHandler = $execute; 25 | $this->compensateHandler = $compensate ?? function () { 26 | }; 27 | 28 | $this->name = $name ?? "anonymous lambda"; 29 | } 30 | 31 | public function execute($state) 32 | { 33 | $function = $this->executeHandler; 34 | return $function($state); 35 | } 36 | 37 | public function compensate($state): void 38 | { 39 | $function = $this->compensateHandler; 40 | $function($state); 41 | } 42 | 43 | /** 44 | * @return string the public name of this step 45 | */ 46 | public function stepName(): string 47 | { 48 | return $this->name; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/FinalisationTest.php: -------------------------------------------------------------------------------- 1 | add($mockStep); 18 | 19 | $transaction 20 | ->run("expected_result") 21 | ->finalState(); 22 | 23 | $this->assertEquals("expected_result", $mockStep->finalisedState); 24 | } 25 | 26 | public function testFinaliseMethodsArentCalledIfTheTransactionFails() 27 | { 28 | 29 | $mockStep = new MockFinalisingStep(); 30 | $transaction = (new Transaction()) 31 | ->add($mockStep) 32 | ->add(new FailingStep()); 33 | 34 | $transaction 35 | ->run("expected_result") 36 | ->finalState(); 37 | 38 | $this->assertNull($mockStep->finalisedState); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Steps/LambdaStepTest.php: -------------------------------------------------------------------------------- 1 | state = "not-run"; 20 | $this->testedStep = new LambdaStep(function ($state) { 21 | $this->state = "run with " . $state; 22 | return "second-state"; 23 | }, function ($state) { 24 | $this->state = "compensated with " . $state; 25 | }, $name); 26 | } 27 | 28 | public function testExecuteReturnsTheResultOfTheWrappedLambda() 29 | { 30 | $this->setupLamda(); 31 | $this->assertEquals( 32 | "second-state", 33 | $this->testedStep->execute("whatever") 34 | ); 35 | } 36 | 37 | public function testExecutePassesTheProvidedStateToTheLambda() 38 | { 39 | $this->setupLamda(); 40 | $this->testedStep->execute("provided-state"); 41 | $this->assertEquals("run with provided-state", $this->state); 42 | } 43 | 44 | public function testCompensatePassesTheProvidedStateToTheLambda() 45 | { 46 | $this->setupLamda(); 47 | $this->testedStep->compensate("state-to-revert"); 48 | $this->assertEquals("compensated with state-to-revert", $this->state); 49 | } 50 | public function testLambdaStepsCanBeNamed() 51 | { 52 | $this->setupLamda("my name"); 53 | $this->assertEquals("my name", $this->testedStep->stepName()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/StateTest.php: -------------------------------------------------------------------------------- 1 | helloFrom = "stateOne"; 27 | $stateOne = $state; 28 | return $state; 29 | } 30 | ); 31 | $stepTwo = new LambdaStep( 32 | function ($state) use (&$stateTwo) { 33 | $state->helloFrom = "stateTwo"; 34 | $stateTwo = $state; 35 | return $state; 36 | } 37 | ); 38 | $transaction = (new Transaction()) 39 | ->add($stepOne) 40 | ->add($stepTwo); 41 | 42 | $startingState = new FakeState(); 43 | $finalState = $transaction->run($startingState)->finalState(); 44 | $finalState->helloFrom = "afterwards"; 45 | $startingState->helloFrom = "beforeButAfter"; 46 | 47 | 48 | $this->assertNotEquals($startingState, $stateOne); 49 | $this->assertNotEquals($stateOne, $stateTwo); 50 | $this->assertNotEquals($stateTwo, $finalState); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/StepBuilderTest.php: -------------------------------------------------------------------------------- 1 | assertSame($outputStep, $mockStep); 19 | } 20 | 21 | public function testTurnsTwoCallablesIntoALambdaStep() 22 | { 23 | $functionOne = function () { 24 | return "function_one"; 25 | }; 26 | $functionTwo = function () { 27 | return "function_two"; 28 | }; 29 | 30 | $outputStep = StepBuilder::build($functionOne, $functionTwo); 31 | 32 | $this->assertInstanceOf(LambdaStep::class, $outputStep); 33 | } 34 | 35 | public function testTurnsAStringAndTwoCallablesIntoANamedLambdaStep() 36 | { 37 | $functionOne = function () { 38 | return "function_one"; 39 | }; 40 | $functionTwo = function () { 41 | return "function_two"; 42 | }; 43 | 44 | /** @var NamedStep $outputStep */ 45 | $outputStep = StepBuilder::build($functionOne, $functionTwo, "my_function"); 46 | 47 | $this->assertInstanceOf(LambdaStep::class, $outputStep); 48 | $this->assertEquals("my_function", $outputStep->stepName()); 49 | } 50 | 51 | public function testAnExceptionIsThrownIfItsNotPossibleToBuildAStep() 52 | { 53 | $this->expectExceptionMessage('Not sure how to build a step from provided data'); 54 | $this->expectException(FailureToBuildStep::class); 55 | 56 | StepBuilder::build("Hugin", "Munin", []); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tale 2 | ==== 3 | [![Build Status](https://travis-ci.org/meadsteve/Tale.svg?branch=master)](https://travis-ci.org/meadsteve/Tale) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/meadsteve/Tale/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/meadsteve/Tale/?branch=master) 5 | 6 | ## What? 7 | Tale is a small library to help write a "distributed transaction like" 8 | object across a number of services. It's loosely based on the saga pattern. 9 | A good intro is available on the couchbase blog: 10 | https://blog.couchbase.com/saga-pattern-implement-business-transactions-using-microservices-part/ 11 | 12 | ## Installation 13 | ```bash 14 | composer require mead-steve/tale 15 | ``` 16 | 17 | ## Example Usage 18 | An example use case of this would be some holiday booking software broken 19 | down into a few services. 20 | 21 | Assuming we have the following services: Flight booking API, Hotel booking API, 22 | and a Customer API. 23 | 24 | We'd write the following steps: 25 | 26 | ```php 27 | class DebitCustomerBalanceStep implements Step 28 | { 29 | //.. Some constructor logic for initialising the api etc... 30 | 31 | public function execute(CustomerPurchase $state) 32 | { 33 | $paymentId = $this->customerApi->debit($state->Amount); 34 | return $state->markAsPaid($paymentId); 35 | } 36 | 37 | public function compensate($state): void 38 | { 39 | $this->customerApi->refundAccountForPayment($state->paymentId) 40 | } 41 | ``` 42 | 43 | ```php 44 | class BookFlightStep implements Step 45 | { 46 | //.. Some constructor logic for initialising the api etc... 47 | 48 | public function execute(FlightPurchase $state) 49 | { 50 | $flightsBookingRef = $this->flightApi->buildBooking( 51 | $state->Destination, 52 | $state->Origin, 53 | self::RETURN, 54 | $this->airline 55 | ); 56 | if ($flightsBookingRef=== null) { 57 | raise \Exception("Unable to book flights"); 58 | } 59 | return $state->flightsBooked($flightsBookingRef); 60 | } 61 | 62 | public function compensate($state): void 63 | { 64 | $this->customerApi->cancelFlights($state->flightsBookingRef) 65 | } 66 | ``` 67 | 68 | and so on for any of the steps needed. Then in whatever is handling the user's 69 | request a distributed transaction can be built: 70 | 71 | ```php 72 | $transaction = (new Transaction()) 73 | ->add(new DebitCustomerBalance($user)) 74 | ->add(new BookFlightStep($airlineOfChoice)) 75 | ->add(new BookHotelStep()) 76 | ->add(new EmailCustomerDetailsOfBookingStep()) 77 | 78 | $result = $transaction 79 | ->run($startingData) 80 | ->throwFailures() 81 | ->finalState(); 82 | ``` 83 | 84 | If any step along the way fails then the compensate method on each step 85 | is called in reverse order until everything is undone. 86 | 87 | ## State immutability 88 | The current state is passed from one step to the next. This same state is also 89 | used to compensate for the transactions in the event of a failure further on 90 | in the transaction. Since this is the case it is important that implementations 91 | consider making the state immutable. 92 | 93 | Tale provides a `CloneableState` interface to help with this. Any state implementing 94 | this interface will have its `cloneState` method called before being passed to a step 95 | ensuring that steps won't share references to the same state. 96 | ```php 97 | class FakeState implements CloneableState 98 | { 99 | public function cloneState() 100 | { 101 | return clone $this; 102 | } 103 | } 104 | 105 | $stepOne = new LambdaStep( 106 | function (MyStateExample $state) { 107 | $state->mutateTheState = "step one" 108 | return $state; 109 | } 110 | ); 111 | $stepTwo = new LambdaStep( 112 | function (MyStateExample $state) { 113 | $state->mutateTheState = "step two" 114 | return $state; 115 | } 116 | ); 117 | $transaction = (new Transaction()) 118 | ->add($stepOne) 119 | ->add($stepTwo); 120 | 121 | $startingState = new MyStateExample(); 122 | $finalState = $transaction->run($startingState)->finalState(); 123 | ``` 124 | In the example above `$startingState`, `$finalState` and `$state` given to both function 125 | calls are all clones of each other so changing one won't affect any earlier states. 126 | 127 | ## Testing / Development 128 | Contributions are very welcome. Please open an issue first if the change is large or will 129 | break backwards compatibility. 130 | 131 | All builds must pass the travis tests before merge. 132 | Running `./run_tests.sh` will run the same tests as travis.yml but locally. 133 | 134 | The dockerfile provides an environment that can execute all the tests & static analysis. -------------------------------------------------------------------------------- /src/Transaction.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 32 | } 33 | 34 | 35 | /** 36 | * Adds a step to the transaction. With the following args: 37 | * 38 | * (Step) -> Adds the step to transaction 39 | * (Closure, Closure) -> Creates a step with the first lambda 40 | * as the execute and the second as compensate 41 | * (Closure, Closure, str) -> Same as above but named 42 | * 43 | * @param mixed ...$args 44 | * @return Transaction 45 | */ 46 | public function add(...$args): Transaction 47 | { 48 | $step = StepBuilder::build(...$args); 49 | return $this->addStep($step); 50 | } 51 | 52 | /** 53 | * Runs each step in the transaction 54 | * 55 | * @param mixed $startingState the state to pass in to the first step 56 | * @return TransactionResult 57 | */ 58 | public function run($startingState = []): TransactionResult 59 | { 60 | $this->logger->debug("Running transaction"); 61 | $state = $startingState; 62 | $completedSteps = []; 63 | foreach ($this->steps as $key => $step) { 64 | try { 65 | $state = $this->executeStep($key, $step, $state); 66 | $completedSteps[] = new CompletedStep($step, $state, $key); 67 | } catch (\Throwable $failure) { 68 | $this->logger->debug("Failed executing {$this->stepName($step)} step [$key]"); 69 | $this->revertCompletedSteps($completedSteps); 70 | $this->logger->debug("Finished compensating all previous steps"); 71 | return new Failure($failure); 72 | } 73 | } 74 | $this->finaliseSteps($completedSteps); 75 | return new Success($state); 76 | } 77 | 78 | private function addStep(Step $step): Transaction 79 | { 80 | $this->logger->debug("Adding {$this->stepName($step)} to transaction definition"); 81 | $this->steps[] = $step; 82 | return $this; 83 | } 84 | 85 | /** 86 | * @param CompletedStep[] $completedSteps 87 | */ 88 | private function revertCompletedSteps(array $completedSteps): void 89 | { 90 | $errors = []; 91 | foreach (array_reverse($completedSteps) as $completedStep) { 92 | $step = $completedStep->step; 93 | $stepId = $completedStep->stepId; 94 | try { 95 | $this->logger->debug("Compensating for step {$this->stepName($step)} [{$stepId}]"); 96 | $step->compensate($completedStep->state); 97 | $this->logger->debug("Compensation complete for step {$this->stepName($step)} [{$stepId}]"); 98 | } catch (\Throwable $error) { 99 | $this->logger->debug("Compensation failed for step {$this->stepName($step)} [{$stepId}]"); 100 | $errors[] = $error; 101 | } 102 | } 103 | if (sizeof($errors) !== 0) { 104 | throw new FailedApplyingAllCompensations($errors); 105 | } 106 | } 107 | 108 | /** 109 | * @param CompletedStep[] $completedSteps 110 | */ 111 | private function finaliseSteps($completedSteps): void 112 | { 113 | foreach ($completedSteps as $completedStep) { 114 | $step = $completedStep->step; 115 | if ($step instanceof FinalisingStep) { 116 | $stepId = $completedStep->stepId; 117 | $this->logger->debug("Finalising step {$this->stepName($step)} [{$stepId}]"); 118 | $step->finalise($completedStep->state); 119 | $this->logger->debug("Finalising step {$this->stepName($step)} [{$stepId}]"); 120 | } 121 | } 122 | } 123 | 124 | private function stepName(Step $step): string 125 | { 126 | if ($step instanceof NamedStep) { 127 | return "`{$step->stepName()}`"; 128 | } 129 | return "anonymous step"; 130 | } 131 | 132 | private function executeStep($number, Step $step, $state) 133 | { 134 | if ($state instanceof CloneableState) { 135 | $state = $state->cloneState(); 136 | } 137 | $this->logger->debug("Executing {$this->stepName($step)} step [$number]"); 138 | $state = $step->execute($state); 139 | $this->logger->debug("Execution of {$this->stepName($step)} step [$number] complete"); 140 | return $state; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/TransactionTest.php: -------------------------------------------------------------------------------- 1 | add($mockStep); 20 | $transaction->run("starting_state"); 21 | 22 | $this->assertEquals("starting_state", $mockStep->executedState); 23 | } 24 | 25 | public function testExecutesEachStepInTurn() 26 | { 27 | $stepOne = new LambdaStep( 28 | function ($state) { 29 | return $state . "|one"; 30 | } 31 | ); 32 | $stepTwo = new LambdaStep( 33 | function ($state) { 34 | return $state . "|two"; 35 | } 36 | ); 37 | $transaction = (new Transaction()) 38 | ->add($stepOne) 39 | ->add($stepTwo); 40 | 41 | $this->assertEquals("zero|one|two", $transaction->run("zero")->finalState()); 42 | } 43 | 44 | public function testAfterAFailedStepEachPreviousStepIsReverted() 45 | { 46 | $events = []; 47 | $stepOne = new LambdaStep( 48 | function ($state) use (&$events) { 49 | $events[] = "Ran step 1 with: " . $state; 50 | return "$state|one"; 51 | }, 52 | function ($stateToRevert) use (&$events) { 53 | $events[] = "Reverted step 1 from: " . $stateToRevert; 54 | } 55 | ); 56 | 57 | $stepTwo = new LambdaStep( 58 | function ($state) use (&$events) { 59 | $events[] = "Ran step 2 with: " . $state; 60 | return "$state|two"; 61 | }, 62 | function ($stateToRevert) use (&$events) { 63 | $events[] = "Reverted step 2 from: " . $stateToRevert; 64 | } 65 | ); 66 | 67 | $transaction = (new Transaction()) 68 | ->add($stepOne) 69 | ->add($stepTwo) 70 | ->add(new FailingStep()); 71 | 72 | $transaction->run("zero"); 73 | 74 | $expectedEvents = [ 75 | 'Ran step 1 with: zero', 76 | 'Ran step 2 with: zero|one', 77 | 'Reverted step 2 from: zero|one|two', 78 | 'Reverted step 1 from: zero|one' 79 | ]; 80 | $this->assertEquals($events, $expectedEvents); 81 | } 82 | 83 | public function testErrorsAreCaughtAsWellAsExceptions() 84 | { 85 | $failureStep = new LambdaStep( 86 | function ($state) { 87 | throw new \Error("I'm a little error. Short and bad."); 88 | }, 89 | function ($stateToRevert) { 90 | } 91 | ); 92 | 93 | $transaction = (new Transaction()) 94 | ->add($failureStep); 95 | 96 | $result = $transaction->run(); 97 | 98 | $this->assertInstanceOf(\Error::class, $result->finalState()); 99 | } 100 | 101 | public function testAFailObjectWithTheFailingExceptionIsReturned() 102 | { 103 | 104 | $transaction = (new Transaction()) 105 | ->add(new FailingStep()); 106 | 107 | $result = $transaction->run("zero"); 108 | 109 | $this->assertInstanceOf(Failure::class, $result); 110 | $this->assertInstanceOf(\Exception::class, $result->exception); 111 | } 112 | 113 | public function testTransactionFailuresCanBeRethrown() 114 | { 115 | 116 | $transaction = (new Transaction()) 117 | ->add(new FailingStep()); 118 | 119 | $this->expectException(\Exception::class); 120 | $this->expectExceptionMessage("I always fail"); 121 | 122 | $transaction 123 | ->run("zero") 124 | ->throwFailures(); 125 | } 126 | 127 | public function testThrowingASuccessDoesNothingButPassTheResultThrough() 128 | { 129 | 130 | $transaction = (new Transaction()) 131 | ->add(new MockStep()); 132 | 133 | $result = $transaction 134 | ->run("expected_result") 135 | ->throwFailures() 136 | ->finalState(); 137 | 138 | $this->assertEquals("expected_result", $result); 139 | } 140 | 141 | public function testFailuresInCompensationAreCaught() 142 | { 143 | 144 | $firstStep = new MockStep(); 145 | $transaction = (new Transaction()) 146 | ->add($firstStep) 147 | ->add(new StepWithFailingCompensate()) 148 | ->add(new FailingStep()); 149 | 150 | $this->expectException(FailedApplyingAllCompensations::class); 151 | $this->expectExceptionMessage("Failed applying all compensation steps"); 152 | 153 | try { 154 | $transaction->run("some payload"); 155 | } finally { 156 | # The reverted state should not be null as it should have 157 | # been compensated 158 | $this->assertNotNull($firstStep->revertedState); 159 | } 160 | } 161 | } 162 | --------------------------------------------------------------------------------