v0.4.0 2019-04-20 07:22 UTC

This package is auto-updated.

Last update: 2024-10-06 02:19:18 UTC


README

Build Status Scrutinizer Code Quality

What?

Tale is a small library to help write a "distributed transaction like" object across a number of services. It's loosely based on the saga pattern. A good intro is available on the couchbase blog: https://blog.couchbase.com/saga-pattern-implement-business-transactions-using-microservices-part/

Installation

composer require mead-steve/tale

Example Usage

An example use case of this would be some holiday booking software broken down into a few services.

Assuming we have the following services: Flight booking API, Hotel booking API, and a Customer API.

We'd write the following steps:

class DebitCustomerBalanceStep implements Step
{
    //.. Some constructor logic for initialising the api etc...
    
    public function execute(CustomerPurchase $state)
    {
        $paymentId = $this->customerApi->debit($state->Amount);
        return $state->markAsPaid($paymentId);
    }

    public function compensate($state): void
    {
        $this->customerApi->refundAccountForPayment($state->paymentId)
    }
class BookFlightStep implements Step
{
    //.. Some constructor logic for initialising the api etc...
    
    public function execute(FlightPurchase $state)
    {
        $flightsBookingRef = $this->flightApi->buildBooking(
            $state->Destination, 
            $state->Origin,
            self::RETURN,
            $this->airline
        );
        if ($flightsBookingRef=== null) {
            raise \Exception("Unable to book flights");
        }
        return $state->flightsBooked($flightsBookingRef);
    }

    public function compensate($state): void
    {
        $this->customerApi->cancelFlights($state->flightsBookingRef)
    }

and so on for any of the steps needed. Then in whatever is handling the user's request a distributed transaction can be built:

       $transaction = (new Transaction())
            ->add(new DebitCustomerBalance($user))
            ->add(new BookFlightStep($airlineOfChoice))
            ->add(new BookHotelStep())
            ->add(new EmailCustomerDetailsOfBookingStep())

        $result = $transaction
            ->run($startingData)
            ->throwFailures()
            ->finalState();

If any step along the way fails then the compensate method on each step is called in reverse order until everything is undone.

State immutability

The current state is passed from one step to the next. This same state is also used to compensate for the transactions in the event of a failure further on in the transaction. Since this is the case it is important that implementations consider making the state immutable.

Tale provides a CloneableState interface to help with this. Any state implementing this interface will have its cloneState method called before being passed to a step ensuring that steps won't share references to the same state.

        class FakeState implements CloneableState
        {
                public function cloneState()
                {
                    return clone $this;
                }
        }
        
        $stepOne = new LambdaStep(
            function (MyStateExample $state) {
                $state->mutateTheState = "step one"
                return $state;
            }
        );
        $stepTwo = new LambdaStep(
            function (MyStateExample $state) {
                $state->mutateTheState = "step two"
                return $state;
            }
        );
        $transaction = (new Transaction())
            ->add($stepOne)
            ->add($stepTwo);

        $startingState = new MyStateExample();
        $finalState = $transaction->run($startingState)->finalState();

In the example above $startingState, $finalState and $state given to both function calls are all clones of each other so changing one won't affect any earlier states.

Testing / Development

Contributions are very welcome. Please open an issue first if the change is large or will break backwards compatibility.

All builds must pass the travis tests before merge. Running ./run_tests.sh will run the same tests as travis.yml but locally.

The dockerfile provides an environment that can execute all the tests & static analysis.