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
Version 7.0 adds CompareAndSwapInterface, the conditional-write primitive needed to build correct
distributed locks on top of a cache engine, and repairs three genuine race conditions in the
existing atomic operations.
The races were not theoretical. A forked-process test reproduces each one against 6.x: twenty
concurrent increment() calls on Memcached ended at 8 instead of 20, and twenty concurrent
add() calls on Redis duplicated the seed value 16 times while dropping appends.
Breaking Changes
| Area | Before (6.x) | After (7.0) | Description |
|---|---|---|---|
| Redis atomic operations | INCR/RPUSH + separate EXPIRE | Lua scripts (EVAL) | increment(), decrement() and add() are now single scripts so the value and its TTL cannot be split apart. Requires Lua scripting to be enabled on the Redis server; some managed offerings restrict EVAL. |
Redis add() TTL | $ttl accepted and silently ignored | $ttl applied | A list built with add($key, $value, 60) now actually expires. Code that relied on the list living forever despite passing a TTL will see it expire. |
ShmopCacheEngine removed | ShmopCacheEngine, Factory::createShmopPool() | removed | Deprecated since 5.0 in favour of TmpfsCacheEngine, which keeps the data in RAM the same way. The ext-shmop suggestion is gone with it. |
Session clear() | session_destroy() | removes only the entries of the engine's prefix | SessionCacheEngine::clear() destroyed the whole session — the login and everything else the application kept there. It now unsets only its own <prefix>-* entries. Code that used it to end the session must call session_destroy() itself. |
| FileSystem atomic operations without a TTL | dropped the expiry of the key | keep it | increment(), decrement() and add() called without a TTL made a key that had an expiry permanent. They now leave the expiry alone, as Redis does. |
| FileSystem atomic TTL | raw $ttl written as the expiry timestamp | converted with addToNow() | increment($key, 1, 60) wrote 60 into the expiry file — a moment in 1970 — so the entry was already expired when written. It now means 60 seconds from now, and the value survives as intended. |
NoCacheEngine::lock() / unlock() removed | public no-op methods | removed | No interface declared them and no other engine had them, so nothing could call them through an abstraction. Code calling them directly on a NoCacheEngine must drop the call. |
MemcachedEngine logger argument | untyped; a non-LoggerInterface value silently became a NullLogger | ?LoggerInterface | Matches the other engines. Passing anything other than a logger or null now throws TypeError instead of quietly disabling logging. |
New Features
CompareAndSwapInterface
A new interface for conditional writes that the storage engine resolves in one indivisible step:
setIfAbsent(string $key, mixed $value, DateInterval|int|null $ttl = null): bool— stores only if the key is free; exactly one of any number of concurrent callers getstrue. The TTL is applied in the same step as the write, so a process that dies mid-operation cannot leave a key with no expiry behind.deleteIfEquals(string $key, mixed $value): bool— deletes only while the key still holds your value, so an owner whose TTL quietly lapsed cannot destroy the entry someone else has taken over.expireIfEquals(string $key, mixed $value, DateInterval|int|null $ttl): bool— extends the TTL under the same guard.
Implemented by: RedisCacheEngine (SET NX EX plus Lua for the guarded operations) and
MemcachedEngine (native add() plus cas()).
FileSystemCacheEngine deliberately does not implement it. flock() attaches to an inode
rather than a path, and this engine unlinks the file on delete, so two processes can hold "the
lock" on two different inodes at the same path. The file system is not a reliable substrate for
mutual exclusion and the engine does not pretend otherwise.
This is a separate interface rather than an addition to AtomicOperationInterface, so nothing that
already implements the latter breaks. Probe with instanceof and degrade explicitly:
if (!$cache instanceof \ByJG\Cache\CompareAndSwapInterface) {
throw new RuntimeException('This engine cannot guarantee mutual exclusion');
}
Documentation
- New Compare and Swap guide, including a worked lock example and an explicit section on what the interface does not give you (no fairness, no reentrancy, the TTL is still a guess).
- Atomic Operations now documents the TTL semantics — including what
each engine does when the TTL is omitted on a key that already has one — the return values, and
what happens when
add()is called on a key previously written byset(). - Documented what was public but unexplained:
isAvailable(), theCachePoolbuffer ($bufferSize,getBufferSize(),setBufferSize()) andGarbageCollectorInterface::getTtl().
Bug Fixes
Memcached: non-atomic seeding in every atomic operation
increment(), decrement() and add() all initialised a missing key with get() === false
followed by set(). Memcached's own increment() is atomic, but those two preparatory calls are
not, and the interleaving loses updates:
P1: get() === false P2: get() === false
P1: set(0)
P1: increment() -> 1
P2: set(0) <- resets the counter
P2: increment() -> 1 <- the same value issued twice
All three now seed with Memcached::add(), which the server resolves in a single step: exactly one
caller creates the key and the rest move on.
add() was additionally hardened — it no longer dereferences the CAS token when the key expired
mid-loop, and it claims an absent key with add() instead of set().
Redis: add() corrupted the list when converting from a plain value
The first add() to a key written by set() has to turn a string into a list. That was done as
GET → DEL → re-push → RPUSH with no protection, so concurrent callers each deleted a list the
others were mid-way through rebuilding. The conversion is now a compare-guarded script that only
rewrites the key while it still holds the value that was read; losing that compare means another
process already converted it.
Redis: TTL applied as a separate command
increment() and decrement() set the expiry with a follow-up EXPIRE. A crash between the two
left a counter with no expiry at all. Both now apply it inside the script.
psr/simple-cache 3.0 is now allowed
The engines already declare the PSR-16 3.0 signatures, but the constraint stopped at ^2.0, so the
package could not be installed alongside anything requiring psr/simple-cache ^3.0. The constraint
is now ^2.0|^3.0.
Redis: has() and clear() never opened the connection
Both read $this->redis without calling lazyLoadRedisServer() first. Every other public method
established the connection; these two were missed. Calling either as the first operation on a new
instance died with:
Error: Call to a member function exists() on null
It went unnoticed because in practice something else — isAvailable(), get(), set() — almost
always ran first and left the connection open. A regression test now exercises each entry point on
an instance that has never been touched.
Memcached: TTL ignored on a counter that already existed
increment() and decrement() only applied the TTL on the call that created the key. Passing one
for a counter that was already there did nothing, so the counter kept its old expiry — or none.
Redis and FileSystem applied it on every call. Memcached now does too, with a touch() right after
the increment.
FileSystem: atomic operations carried on from an expired value
An expired entry is only removed when it is read. increment(), decrement() and add() arriving
first read the stale file and continued from it — a counter that expired at 7 answered 8. An
expired entry is now treated as absent and the operation starts over.
clear(), collectGarbage() and isAvailable() failed with a PSR-11 container
With withKeysFromContainer() set, these died with Key '...' not found in container:
RedisCacheEngine::clear()deleted every scanned key throughdelete(), which looked the stored key up in the container.FileSystemCacheEngine(andTmpfsCacheEngine) built the*pattern ofclear()andcollectGarbage(), and the probe path ofisAvailable(), through the same lookup.
They act on the whole engine and no longer go through the container.
Session: has() did not start the session
SessionCacheEngine::has() read $_SESSION without starting the session, unlike get(), set()
and delete(). Called first in a request it always answered false, and called right after
clear() it still saw the entries that had just been destroyed.
FileSystem: expiry dropped outside the lock
putContents() deleted the .ttl file before acquiring the lock, leaving the value briefly
immortal — a reader arriving in that window saw an entry that should already have expired. The
delete now happens under the lock.
PSR-6: save() named a class that does not exist
Saving an item that is not a ByJG\Cache\Psr6\CacheItem threw an exception asking for
\ByJG\Cache\Psr\CacheItem, a namespace that does not exist. The message now names the real
class.
Memcached: a wrong logger was swallowed
MemcachedEngine::__construct() took $logger untyped and replaced anything that was not a
LoggerInterface with a NullLogger, so a mistake there silently turned logging off. The argument
is now typed ?LoggerInterface, as in every other engine (see Breaking Changes).
Testing
tests/CompareAndSwapTest.php— 18 tests across both engines covering expiry, non-owner rejection and the stale-owner-versus-new-owner case.tests/ConcurrencyTest.php— forks twenty real processes at a synchronised barrier. Sequential tests cannot prove atomicity; these fail against 6.x and pass on 7.0.- TTL regression tests added to
tests/CachePSR16Test.phpcovering every engine that implementsAtomicOperationInterface, for a new key and for one that already exists. clear(),collectGarbage()andisAvailable()are tested on every engine with a PSR-11 container set.
Note for anyone writing similar tests: forked children inherit the parent's Memcached socket and close it as they exit, killing the parent's connection. Reconnect in the parent before asserting.
Migration Path from 6.x to 7.0
Step 1: Confirm Lua scripting is available on your Redis server
increment(), decrement() and add() now use EVAL. This is enabled by default in Redis, but
some managed and proxied deployments restrict it. Verify with:
redis-cli EVAL "return 1" 0 # should print (integer) 1
If EVAL is unavailable in your environment, stay on 6.x or open an issue.
Step 2: Update the dependency
composer require byjg/cache-engine:^7.0
composer update
Step 3: Review any use of add() with a TTL on Redis
The TTL was previously ignored, so lists built this way never expired. They now do. If you were relying on the old behaviour, drop the TTL argument:
$cache->add('my-key', 'value', 3600); // now expires after an hour
$cache->add('my-key', 'value'); // never expires, as 6.x behaved
Step 4: Review any use of atomic operations with a TTL on FileSystem
These were broken and returned already-expired entries, so working code is unlikely to depend on them. If you worked around the bug by omitting the TTL, you can now pass it.
Step 5: Replace ShmopCacheEngine
// Before
$cache = new \ByJG\Cache\Psr16\ShmopCacheEngine();
$pool = \ByJG\Cache\Factory::createShmopPool();
// After
$cache = new \ByJG\Cache\Psr16\TmpfsCacheEngine();
$pool = \ByJG\Cache\Factory::createTmpfsCachePool();
The max-size and default-permission options have no equivalent; entries are files in /dev/shm.
Step 6: Review any use of SessionCacheEngine::clear()
It no longer destroys the session. If you relied on that to log the user out, call
session_destroy() yourself.
Step 7: Adopt compare-and-swap where you were hand-rolling a lock
If you have code shaped like this, replace it — it has a race between the two calls:
// Before - two operations with a gap between them
if (!$cache->has('lock')) {
$cache->set('lock', $token, 30);
}
// After - one indivisible operation
if ($cache->setIfAbsent('lock', $token, 30)) {
try {
// ...
} finally {
$cache->deleteIfEquals('lock', $token);
}
}
Common Migration Issues
Issue: NOSCRIPT or "unknown command EVAL" errors from Redis
Solution: Lua scripting is disabled or proxied away in your deployment. See Step 1.
Issue: Cached lists built with add() now disappear
Solution: You are passing a TTL that was previously ignored. See Step 3.
Issue: $cache->setIfAbsent() does not exist
Solution: The engine does not implement CompareAndSwapInterface. Only RedisCacheEngine and
MemcachedEngine do. Probe with instanceof before calling.
Notes
AtomicOperationInterfaceis unchanged; existing implementations continue to work untouched.- No changes to the PSR-6 or PSR-16 interface implementations.
- Existing cache data remains compatible across versions.
- PHP requirement is now
>=8.3 <8.7(see Requirements below).
Links
Requirements
- PHP 8.3, 8.4, 8.5 and 8.6 are now supported:
"php": ">=8.3 <8.7". The previous<8.6upper bound excluded PHP 8.6, since<8.6is exclusive.
Toolchain
-
PHPUnit updated to
^12.5. -
Psalm is installed as
psalm/phar(^6.16) instead ofvimeo/psalm.vimeo/psalmlists the PHP versions it supports and no published release includes 8.6, so as a dev dependency it madecomposer installfail on the 8.6 build job before any test ran.psalm/pharrequires onlyphp ^8.2and bundles its own dependencies, so it installs on every PHP version in the matrix and cannot conflict with the project's. Psalm itself still refuses to run on 8.6, which is why the Psalm job uses 8.5.composer psalmruns it.PHPUnit 13 is deliberately not used. It requires PHP
>=8.4.1, which would break the 8.3 floor.
Continuous Integration
- The build matrix now includes PHP 8.6.
- The Psalm job now runs on PHP 8.5. Psalm declares
~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0and therefore does not run on PHP 8.6.
Housekeeping
phpunit.xml.distrenamed tophpunit.xml.