Resilience
Utilities for the two things that go wrong when work is spread across processes: something is not ready yet, and something must only happen once.
Why
Both problems usually get a quick fix: sleep() in a loop, or a "lock" made of a cache key.
The loop waits too long or not long enough and makes tests slow; the cache-key lock lets two
processes run at once, or stays locked forever after a crash.
- A lock that is actually a lock.
Mutexis built on an atomic set-if-absent, with an owner token so a process only releases its own lock, and a TTL so a crashed process does not hold it forever. - Waiting with a real ceiling.
ExponentialBackoffWaitergrows the delay and stops at a maximum total wait, not after a fixed number of tries. - Tested in virtual time. The clock and the sleep are injectable, so a policy that waits seconds in production is verified in milliseconds.
- Declarative.
#[ExponentialBackoff]and#[Mutex]attach either policy to a method.
Key Features
- Exponential backoff - poll for a value with a growing delay and a real ceiling on total wait time
- Distributed mutex - a named lock with owner tokens and TTL-based crash recovery
- Pluggable lock stores - built on an atomic compare-and-swap contract, not on best-effort caching
- Attribute-driven - attach either policy declaratively to a method
- Testable timing - injectable clock and sleep, so multi-second policies verify in virtual time
Quick Start
Wait for something that is not there yet:
use ByJG\Resilience\ExponentialBackoffWaiter;
$waiter = new ExponentialBackoffWaiter(
initialDelayMicros: 200_000, // 200ms
maxTotalWaitMicros: 3_500_000, // 3.5s
multiplier: 2.0
);
$reward = $waiter->wait(fn() => $rewardRepo->get($id));
Make sure something happens only once:
use ByJG\Cache\Psr16\RedisCacheEngine;
use ByJG\Resilience\Lock\CacheEngineLockStore;
use ByJG\Resilience\Mutex;
$mutex = new Mutex(new CacheEngineLockStore(new RedisCacheEngine()));
$result = $mutex->execute('rebuild-report', fn() => rebuildTheReport(), ttlSeconds: 60);
Documentation
- Exponential Backoff - the waiter, its timing guarantees, and testing it
- Mutex - acquiring, releasing, refreshing, and choosing a TTL
- Lock Stores - the store contract, the shipped adapter, and what a lock does not guarantee
- Attributes -
#[ExponentialBackoff]and#[Mutex]with their executors
Requirements
PHP 8.3 or later.
The Mutex needs a store that can perform an atomic set-if-absent. The shipped adapter covers
byjg/cache-engine engines backed by Redis or Memcached:
composer require byjg/cache-engine
A plain PSR-16 cache is deliberately not accepted — see Lock Stores for why.
Installation
composer require byjg/resilience
Running Tests
vendor/bin/phpunit
Tests covering the lock stores need Redis and Memcached, and skip cleanly when they are absent:
docker compose up -d
vendor/bin/phpunit
docker compose down