Conversation
Raise the floor to PHP 8.3, matching byjg/cache-engine, whose CompareAndSwapInterface the Mutex depends on. 8.1 and 8.2 leave the matrix. Nothing is released yet, so there is nobody to break. cache-engine is a require-dev and a suggest rather than a hard requirement, so the library stays usable with a hand-written LockStoreInterface over a raw client. minimum-stability is dev with prefer-stable, which lets ^7.0 resolve to the branch until 7.0.0 is tagged and then pick up the tag with no further change here; of 75 packages it is the only one that resolves to a dev version. The CI job runs inside a container, so the Redis and Memcached services are addressed by service name rather than loopback. Without REDIS_SERVER and MEMCACHED_SERVER the lock store tests would skip themselves and the build would report green having never touched a real engine.
Polls an operation on a growing delay until a predicate passes or a total budget is spent, replacing the hand-rolled usleep/multiply loop. maxTotalWaitMicros is a ceiling on elapsed wall-clock time, not merely on when the loop decides to stop: each sleep is clamped to the remaining budget. With the documented defaults that means sleeps of 200, 400, 800, 1600 and 500 ms, returning at exactly 3.5s. Letting the final delay run its full 3200 ms would return after 6.2s, nearly double what the caller asked for. Two tests assert the exact sleep sequence. maxDelayMicros caps any individual sleep and is applied to the initial delay as well, so a cap smaller than the initial delay is honoured from the first wait rather than only after the first multiplication. sleepFn and timeFn are injectable, so tests drive virtual time and a multi-second policy verifies instantly and deterministically. Both have to be supplied together - the clamp reads timeFn, so a fake sleep against a real clock would never advance.
A named lock with a random owner token and a TTL for crash recovery. tryAcquire, acquire (blocking via the waiter), release, refresh, and an execute helper that releases in a finally block. The lock is only as sound as its store, so the store is its own contract rather than an implementation detail. LockStoreInterface documents three requirements: acquireIfAbsent is atomic so exactly one of N concurrent callers wins, the TTL lands in the same step as the write, and release/refresh act only on a token match. That last one is why acquisition mints a token. Without it a caller whose TTL quietly lapsed mid-operation would delete the lock that has since passed to somebody else. The constructor accepts a LockStoreInterface and nothing else. A PSR-16 cache is deliberately not accepted: PSR-16 has no atomic set-if-absent, so has()-then-set() lets two processes both find the key absent and both write their token. Accepting one would have made the unsafe path the shortest to write, so the type system rejects it instead.
Maps the three LockStoreInterface operations onto the compare-and-swap primitives byjg/cache-engine 7.0 exposes: setIfAbsent, deleteIfEquals and expireIfEquals. Each is resolved by the storage engine in a single indivisible step, which is what makes the Mutex actually hold. The constructor takes CompareAndSwapInterface rather than a cache engine, so an engine that cannot make the guarantee is rejected by the type system. FileSystemCacheEngine is the case that matters: flock attaches to an inode while the engine deletes files by path, so one process can hold a lock on an unlinked file while another creates and locks a fresh file at the same path. A test asserts it raises a TypeError. Mutual exclusion is verified by forking twenty processes at a synchronised barrier and asserting exactly one acquires the Mutex, against real Redis and Memcached. Sequential assertions cannot tell a correct lock from a racy one - the naive PSR-16 implementation passes every single-process test that can be written for it. Engines are built from REDIS_SERVER and MEMCACHED_SERVER so the same tests reach docker-compose locally and service containers in CI.
#[ExponentialBackoff] and #[Mutex] keep the policy next to the method it governs instead of at every call site. PHP attributes are metadata and change nothing on their own, so each has a matching executor that reads them via reflection; calling the method directly bypasses the attribute entirely. A method with no attribute is invoked untouched, which makes routing calls through an executor safe. Both executors accept a waiterOverride so tests can replace the timing an attribute hard-codes rather than sitting through real delays. Closures cannot carry attributes in PHP, so callFunctionWithAttributes invokes them directly with no retry and no locking.
The repo already carried the byjg.github.io publishing job but had nothing for it to publish, so everything lived in a README that had grown to 167 lines. Four topic pages now carry the detail, using the same sidebar_position frontmatter as byjg/cache-engine, and the README drops to a landing page. Three things are documented here for the first time. What a Mutex does not guarantee: no fairness, no reentrancy, and a TTL that lapses mid-work means releaseIfOwner correctly refuses the stale release only after the work has already run twice. That release() returning false is a signal worth acting on rather than noise. And that ExponentialBackoffWaiter is a poller, not a retry policy - a thrown exception propagates immediately. The two executors also take their arguments in different orders ($args is third in one and fourth in the other), so every example uses named arguments and the docs say why. docker-compose.yml was added in an earlier commit for the same reason: the test instructions referenced services the repo did not define.
The backoff was deterministic, which synchronises callers rather than spreading them. Fifty workers whose calls fail at the same instant all sleep exactly 200ms, all retry together, all fail, all sleep exactly 400ms - a herd arriving in tight coordinated waves, the worst possible shape for a dependency trying to recover. This fixes both shipped features at once: Mutex::acquire() polls through the same waiter, so every contender for a busy lock also retried in lockstep. Jitter::Full is rand(0, delay) and Jitter::Equal guarantees half the delay before randomising the rest. Both randomise only the sleep actually taken - the delay series keeps doubling deterministically, so the backoff still escalates as configured. That is the AWS formulation, sleep = rand(0, base * 2^n) rather than base = rand(...). Default is Jitter::None. Randomising existing timing by default would surprise anyone already relying on it. randomFn is injectable, mirroring sleepFn and timeFn, so the two tests asserting exact sleep sequences to prove the deadline clamp keep asserting exact values instead of being weakened to ranges. Also caps the delay series at maxTotalWaitMicros. A single sleep is already clamped to the remaining budget, so growing past it buys nothing - and without the cap the series overflows: jitter can make each sleep far shorter than the delay it came from, so the loop iterates many more times than the series expects and the delay doubles past PHP_INT_MAX. Removing this guard fails six of the jitter tests. Decorrelated jitter is deliberately absent. It replaces the multiplier driven growth rather than transforming its output, so it belongs to a different backoff strategy rather than being a third setting on this one.
#[ExponentialBackoff] had no jitter parameter and the executor built its waiter without one, so jitter was unreachable from the declarative API entirely while looking perfectly configured. The executor constructed that waiter in two places, once for methods and once for functions, with identical constructor calls. That duplication is structurally why the parameter was missed: adding one to the attribute meant remembering to thread it through both. Extracted to a single waiterFrom(), so the next one can only be missed in one place. randomFn has no attribute equivalent and cannot have one - attribute arguments must be constant expressions and a callable is not one. The default random_int is what production wants; a test needing deterministic jitter passes a configured waiter through waiterOverride. Tested as a pair, since a variance assertion alone proves little: one test fixes the attempt count for an unjittered backoff, the other requires it to vary once jitter is set. The second fails against the unfixed executor.
Surveys the patterns beyond the shipped Mutex and ExponentialBackoff: what each solves, how it maps onto the primitives already here, and the traps in each. The TASKS section carries the agreed order and acceptance criteria; "Not planned" records what was rejected and why.
Widen the PHP constraint to ">=8.3 <8.7"; the previous "<8.6" is exclusive and excluded PHP 8.6. Pin PHPUnit to ^12.5 and move Psalm to tools/psalm/composer.json. Psalm enumerates supported PHP versions and no release lists 8.6, so as a require-dev it made "composer install" fail on the 8.6 build before any test ran. Psalm ran inside the build matrix, so it could not simply be dropped from require-dev: it now has its own job on 8.5, matching the other components. Add the scripts section this package never had, so "composer psalm" and "composer test" work here too. psalm.xml gains cacheDirectory="/tmp/psalm" - without it Psalm crashed trying to create its default cache directory. Housekeeping: rename phpunit.xml.dist to phpunit.xml, and correct the CHANGELOG-7.0.md requirement table, which still claimed ">=8.3 <8.6".
Every other component drops the "php-" prefix in its package name - the repository is php-serializer, the package is byjg/serializer. This one kept it, which broke the documentation link the dependency graph builds from the package name: byjg/php-resilience pointed at /docs/php/php- resilience, and the folder is /docs/php/resilience. Nothing depends on it and it is not on Packagist under either name, so the rename costs nothing now and would be a breaking change later. The github.com/byjg/php-resilience URLs in the README badges and the changelog links are unchanged - the repository really is named php-resilience.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First release of
byjg/php-resilience.masterholds only the initial scaffold commit, so this PRis the entire library rather than a diff against released code.
There is no earlier version to migrate from — the number starts at 7.0 to line up with the
coordinated move of the byjg PHP components, and specifically with
byjg/cache-engine 7.0, which supplies the atomic
primitive the
Mutexis built on.Covers two problems that appear whenever work is spread across processes: something is not ready
yet, and something must happen only once.
What's here
ExponentialBackoffWaiter— polls an operation on a growing delay until a predicate passes or abudget is spent.
maxTotalWaitMicrosis a ceiling on elapsed wall-clock time, not merely on when the loop decidesto stop. Each sleep is clamped to the remaining budget, so the documented defaults sleep
200 / 400 / 800 / 1600 / 500 ms and return at exactly 3.5s. Letting the final delay run its full
3200ms would return after 6.2s — nearly double what the caller asked for.
Mutex— a named lock with a random owner token and a TTL for crash recovery.tryAcquire,acquire,release,refresh, and anexecutehelper that releases in afinally.LockStoreInterface+CacheEngineLockStore— the lock is only as sound as its store, so thestore is its own contract:
acquireIfAbsentmust be atomic, the TTL must land in the same step asthe write, and release/refresh must act only on a token match. The adapter maps those onto
cache-engine's
setIfAbsent/deleteIfEquals/expireIfEquals.Jitter —
Jitter::FullandJitter::Equal, randomising the sleep taken while the delay serieskeeps doubling deterministically (the AWS formulation). Fixes both features at once:
Mutex::acquire()polls through the same waiter, so lock contenders were retrying in lockstep too.Attributes —
#[ExponentialBackoff]and#[Mutex]with their executors.Two decisions worth reviewing
A plain PSR-16 cache is not accepted by
Mutex. PSR-16 has no atomic set-if-absent, so a lockbuilt on
has()thenset()lets two processes both find the key absent and both write theirtoken. An earlier draft shipped such a store with a documented 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 rejects it instead.
ExponentialBackoffWaiteris a poller, not a retry policy. It reacts to a returned value failinga predicate; a thrown exception propagates immediately with no retry. That is the right shape for
"the record has not been written yet" and the wrong one for "the HTTP call returned 503", which
needs to know which exceptions are retryable. Retry-on-exception and circuit breaking are explicitly
not in this release.
Testing
41 tests, psalm clean.
Mutual exclusion is verified by forking twenty processes at a synchronised barrier and asserting
exactly one acquires the lock, against real Redis and Memcached. 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.
The jitter work surfaced a real overflow: with sleeps shortened by jitter the loop iterates far more
often than the delay series anticipates, and the delay doubled past
PHP_INT_MAX. The series is nowcapped at the total budget. Removing that guard fails six tests.
Dependency note
byjg/cache-engineisrequire-dev+suggest, not a hard requirement, so the library stays usablewith a hand-written
LockStoreInterfaceover a raw client.minimum-stabilityisdevwithprefer-stable, which lets^7.0resolve to the cache-engine 7.0branch until it is tagged, then pick up the tag with no change here. Of 75 packages it is the only
one resolving to a dev version. This should land after cache-engine #24.
Full detail in
CHANGELOG-7.0.md.