Pular para o conteúdo principal

Changelog - Version 7.0

Status: in development. This document tracks changes landing on the 7.0 branch. 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​

AreaBefore (6.x)After (7.0)Description
Redis atomic operationsINCR/RPUSH + separate EXPIRELua 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 appliedA 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 removedShmopCacheEngine, Factory::createShmopPool()removedDeprecated 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 prefixSessionCacheEngine::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 TTLdropped the expiry of the keykeep itincrement(), 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 TTLraw $ttl written as the expiry timestampconverted 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() removedpublic no-op methodsremovedNo 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 argumentuntyped; a non-LoggerInterface value silently became a NullLogger?LoggerInterfaceMatches 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 gets true. 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 by set().
  • Documented what was public but unexplained: isAvailable(), the CachePool buffer ($bufferSize, getBufferSize(), setBufferSize()) and GarbageCollectorInterface::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 through delete(), which looked the stored key up in the container.
  • FileSystemCacheEngine (and TmpfsCacheEngine) built the * pattern of clear() and collectGarbage(), and the probe path of isAvailable(), 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.php covering every engine that implements AtomicOperationInterface, for a new key and for one that already exists.
  • clear(), collectGarbage() and isAvailable() 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​

  • AtomicOperationInterface is 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).

Requirements​

  • PHP 8.3, 8.4, 8.5 and 8.6 are now supported: "php": ">=8.3 <8.7". The previous <8.6 upper bound excluded PHP 8.6, since <8.6 is exclusive.

Toolchain​

  • PHPUnit updated to ^12.5.

  • Psalm is installed as psalm/phar (^6.16) instead of vimeo/psalm.

    vimeo/psalm lists the PHP versions it supports and no published release includes 8.6, so as a dev dependency it made composer install fail on the 8.6 build job before any test ran. psalm/phar requires only php ^8.2 and 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 psalm runs 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.0 and therefore does not run on PHP 8.6.

Housekeeping​

  • phpunit.xml.dist renamed to phpunit.xml.