esroyo/backoff-async

Non-blocking retry functionality with multiple backoff strategies and jitter support

dev-master / 1.0.0.x-dev 2021-11-26 19:53 UTC

This package is auto-updated.

Last update: 2024-03-27 22:29:49 UTC


README

builds.sr.ht status Software License Total Downloads

Easily wrap your code with retry functionality in a non-blocking way.

This library extends stechstudio/backoff thus inheriting options and strategies. We are highlighting in this document only the advantages and key differences. Please check the PHP Backoff README to get the general usage ideas.

Motivation

PHP Backoff is great, until you need to run some instances at the same time. On that situation only one Backoff can run at the same time because of the PHP's synchronous nature. 6 Backoffs that take 10s each means 60s to run complete. What if we could make the most out of the process by running a Backoff while another sleeps? For that we need an event loop like ReactPHP's EventLoop.

Installation

composer require esroyo/backoff-async

Differences with synchronous Backoff use

First, there is only one key difference in parameters: along with the usual callable you need to pass in a companion \React\EventLoop\LoopInterface instance. Easy to remember: always follow the callable with the loop instance.

When using Backoff as a class:

use \Esroyo\BackoffAsync\Backoff;

$backoff = new Backoff(10, 'exponential', 10000, true);
$promise = $backoff->run(function () {
    return doSomeWorkThatMightFail();
}, $loop);

When using the helper function:

use \Esroyo\BackoffAsync;

$promise = BackoffAsync\backoff(function () {
    return doSomeWorkThatMightFail();
}, $loop);

An instance of LoopInterface is created automatically when the $loop parameter is not present. That instance can be obtained with the \Esroyo\BackoffAsync\loop() helper function.

A second difference: the returned value either from the run($callable, $loop) method or the backoff($callable, $loop, ...) helper function is always a promise. You must use then() to attach actions that will be executed once the backoff succeeds or definitely fails:

$promise = BackoffAsync\backoff(function () {
    return doSomeWorkThatMightFail();
}, $loop);

$promise->then(function ($result) {

    // doSomeWorkThatMightFail() has
    // finally returned some result

    echo "Success: $result \n";

}, function ($reason) {

    // doSomeWorkThatMightFail() failed
    // usually $reason will be an Exception

    echo "Failure: {$reason->getMessage()} \n";
});

Usage within an evented application

When you are already working with an event loop the usage of this package will fit naturally. Usually you won't need to worry on when an how to run the event loop because that will be already implicit in your application flow.

Before instantiating any backoff object you may pass in the event loop to the loop() helper, so you can omit the $loop parameter later on:

use \Esroyo\BackoffAsync;
use \Esroyo\BackoffAsync\Backoff;

$myLoop = React\EventLoop\Factory::create();

// Here you pass in the event loop instance once
BackoffAsync\loop($myLoop);

...

// When using as a class the `$loop` parameter can be
// completely ommitted and the Backoff will use $myLoop
$backoff = new Backoff();
$promise = $backoff->run(function () {
    ...
});

// When working with the helper function you will still
// need to specify the $loop parameter as null
$promise = BackoffAsync\backoff(function () {
    ...
}, null, 10, 'constant');

// Unless you use the defaults
$promise = BackoffAsync\backoff(function () {
    ...
});

// At some point in you application the loop will run
$myLoop->run();

Usage within a synchronous application

A traditional, synchronous (blocking) application can still take advantage of the asynchronous resolution of several backoffs. For instance when doing/retrying some work on a MVC framework controller you absolutely need the work to finish before returning an HTTP response. One very easy way to do that is with the await functionality provided by clue/block-react:

use \Esroyo\BackoffAsync;
use \Clue\React\Block;

function blockingExample()
{
    // Store the promises returned by all Backoffs
    $promises = [];

    $promises[] = BackoffAsync\backoff(function () {
        return doSomeWorkThatMightFail();
    );

    $promises[] = BackoffAsync\backoff(function () {
        return doAnotherWorkThatMightFail();
    );
    
    try {
        // Keep the loop running (i.e. block) until all promises resolve
        $allResults = Block\awaitAll($promises, BackoffAsync\loop());
        
        // Work with $allResults, each item contains the result of each work
		...
    } catch (\Exception $e) {
        // One of the works failed ...
    }
}

Functions Clue\Block\awaitAny(array $promises, LoopInterface $loop, $timeout = null) and Clue\Block\awaitAll(array $promises, LoopInterface $loop, $timeout = null) are a good fit for the use case. The former can be used to wait for ANY of the given promises to resolve, and the later to wait for ALL of the given promises to resolve. However in both cases if a work fails thus rejecting the promise, they will try to cancel() all remaining promises and throw an Exception.

That approach is inconvenient if you want all the works to finish (successfully or not) to later inspect the results. For that you'll need a similar blocking settle functionality provided by esroyo/block-react-settle:

use \Esroyo\BackoffAsync;
use \Esroyo\React\Block;

function blockingExample()
{
    // Store the promises returned by all Backoffs
    $promises = [];

    $promises[] = BackoffAsync\backoff(function () {
        return doSomeWorkThatMightFail();
    );

    $promises[] = BackoffAsync\backoff(function () {
        return doAnotherWorkThatMightFail();
    );
    
    // Keep the loop running (i.e. block) until all promises resolve
    // this will never throw an exception
    $allResults = Block\settle($promises, BackoffAsync\loop());

    // $allResults, each item contains the result of work or the rejection reason
    echo $allResults[0]['state'];
    // > "fulfilled"
    echo $allResults[0]['value'];
    // > "This is the result of doSomeWorkThatMightFail()"
    echo $allResults[1]['state'];
    // > "rejected"
    echo $allResults[1]['reason'];
    // > "This is the reason why doAnotherWorkThatMightFail() failed (probably an Exception)"
}

Jitter

If you have lots of Backoffs starting at the same time with same timing/strategy it is a good idea to add randomness. You'll get more performance from the event loop by avoiding concentration of tasks on the same time points. See here for a good explanation:

https://www.awsarchitectureblog.com/2015/03/backoff.html

You can enable jitter by passing true in as the sixth argument to the backoff helper function, or by using the enableJitter() method on the Backoff class.