Skip to content

7.0: first release — exponential backoff, distributed mutex, jitter - #1

Open
byjg wants to merge 11 commits into
masterfrom
7.0
Open

7.0: first release — exponential backoff, distributed mutex, jitter#1
byjg wants to merge 11 commits into
masterfrom
7.0

Conversation

@byjg

@byjg byjg commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

First release of byjg/php-resilience. master holds only the initial scaffold commit, so this PR
is 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 Mutex is 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 a
budget is spent.

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, 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 an execute helper that releases in a finally.

LockStoreInterface + CacheEngineLockStore — the lock is only as sound as its store, so the
store is its own contract: acquireIfAbsent must be atomic, the TTL must land in the same step as
the write, and release/refresh must act only on a token match. The adapter maps those onto
cache-engine's setIfAbsent / deleteIfEquals / expireIfEquals.

JitterJitter::Full and Jitter::Equal, randomising the sleep taken while the delay series
keeps 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 lock
built on has() then set() lets two processes both find the key absent and both write their
token. 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.

ExponentialBackoffWaiter is a poller, not a retry policy. It reacts to a returned value failing
a 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 now
capped at the total budget. Removing that guard fails six tests.

Dependency note

byjg/cache-engine is require-dev + suggest, not 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 cache-engine 7.0
branch 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.

byjg added 11 commits August 7, 2026 11:38
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant