Changelog - Version 7.0
Status: in development. This document tracks changes landing on the
7.0branch. Nothing here is released yet, and the contents may still change.
Overview
First release of byjg/resilience. There is no earlier version to migrate from — the version
number starts at 7.0 to line up with byjg/cache-engine
7.0, which supplies the atomic primitive the Mutex is built on.
The library covers two problems that show up whenever work is spread across processes: something is not ready yet, and something must happen only once.
Requirements
| PHP | >=8.3 <8.7 — 8.3, 8.4, 8.5 and 8.6 |
| Required | psr/simple-cache ^3.0 |
| Optional | byjg/cache-engine ^7.0 — needed by CacheEngineLockStore, and therefore by any real use of Mutex |
What's Included
ExponentialBackoffWaiter
Polls an operation on a growing delay until it succeeds or a total budget is spent.
$waiter = new ExponentialBackoffWaiter(
initialDelayMicros: 200_000,
maxTotalWaitMicros: 3_500_000,
multiplier: 2.0
);
$reward = $waiter->wait(fn() => $rewardRepo->get($id));
maxTotalWaitMicros is a ceiling on elapsed wall-clock time, not merely on when the loop decides to
stop. Each sleep is trimmed against the remaining budget, so the default configuration sleeps
200 / 400 / 800 / 1600 / 500 ms and returns at exactly 3.5 s. An implementation that let the last
delay run its full 3200 ms would return after 6.2 s — nearly double what the caller asked for.
sleepFn and timeFn are injectable, so a suite covering a multi-second policy runs instantly on a
virtual clock.
The jitter parameter takes the Jitter enum (None, the default, Full or Equal) and randomises
each sleep so concurrent callers do not retry in lockstep; the delay series itself still grows
deterministically and the total budget still holds. randomFn is injectable for deterministic tests.
Mutex
A named lock with an owner token and a TTL for crash recovery. tryAcquire (non-blocking),
acquire (blocking, via the waiter above), release, refresh, and an execute helper that
releases in a finally block.
$mutex = new Mutex(new CacheEngineLockStore(new RedisCacheEngine()));
$result = $mutex->execute('rebuild-report', fn() => rebuildTheReport(), ttlSeconds: 60);
LockStoreInterface and CacheEngineLockStore
The lock is only as sound as its store, so the store is its own contract: acquireIfAbsent must be
atomic, the TTL must be applied in the same step as the write, and release/refresh must act only on
a token match.
CacheEngineLockStore adapts any byjg/cache-engine engine implementing CompareAndSwapInterface
— currently RedisCacheEngine and MemcachedEngine.
Attributes
#[ExponentialBackoff] and #[Mutex], applied through ExponentialBackoffExecutor and
MutexExecutor. Attributes are metadata: calling the method directly bypasses them entirely, and
the executor is what reads and honours them.
callFunctionWithAttributes() on both executors honours the attribute on every callable form: a
function name, a 'Class::method' string, an array callable ([Class::class, 'method'] or
[$object, 'method']), a first-class callable, a closure declared with the attribute, and an
invokable object (attribute on __invoke()). Earlier drafts reflected only a function-name string:
a 'Class::method' string threw ReflectionException, and every other form silently ran once with
no retry and no locking.
Design Decisions Worth Knowing
A plain PSR-16 cache is not accepted by Mutex
Mutex::__construct() takes a LockStoreInterface and nothing else. PSR-16 has no atomic
set-if-absent operation, so a lock built on has() then set() lets two processes both find the key
absent and both write their token — the second wins, the first believes it holds a lock it does not.
Reading the value back narrows the window without closing it.
An earlier draft shipped such a store and documented the caveat. Documenting a hazard is weaker than removing it, and the constructor signature made the unsafe path the shortest one to write. The type system now rejects it instead.
The Mutex is not a general-purpose lock manager
Correct acquisition gets you one thing: at most one holder at a time. It does not give you fairness,
reentrancy, or protection against a TTL that lapses mid-operation — releaseIfOwner will correctly
refuse the stale release, but by then the work has run twice. See
Lock Stores.
ExponentialBackoffWaiter is a poller, not a retry policy
It reacts to a returned value failing a predicate. If the operation throws, the exception propagates immediately with no retry. That is the right shape for "the record has not been written yet" and the wrong shape for "the HTTP call returned 503", which needs to know which exceptions are retryable and which will fail identically forever.
Retry-on-exception and circuit breaking are not part of this release.
Documentation
Testing
docker compose up -d
vendor/bin/phpunit
docker compose down
Tests needing Redis or Memcached skip cleanly when the services are absent, and read
REDIS_SERVER / MEMCACHED_SERVER so CI can point them at service containers.
Mutual exclusion is verified by forking twenty processes at a synchronised barrier and asserting exactly one acquires the lock. Sequential assertions cannot distinguish a correct lock from a racy one — a naive implementation passes every single-process test that can be written for it.
Platform and Toolchain
- PHP widened to
>=8.3 <8.7. The previous<8.6is exclusive and excluded PHP 8.6. - PHPUnit pinned to
^12.5. PHPUnit 13 requires PHP>=8.4.1, which breaks the 8.3 floor. - Psalm is installed as
psalm/pharinstead ofvimeo/psalm.vimeo/psalmlists the PHP versions it supports and no release lists 8.6, so as a dev dependency it madecomposer installfail on the 8.6 build before any test ran.psalm/pharrequires onlyphp ^8.2and bundles its own dependencies, so it installs on every PHP version in the matrix; Psalm itself still does not run on 8.6, so its job uses 8.5.composer psalmruns it. - CI now builds on 8.6, and Psalm runs in its own job on 8.5 rather than inside the build matrix.
phpunit.xml.distrenamed tophpunit.xml;psalm.xmlgainedcacheDirectory="/tmp/psalm", matching the other components.