diff --git a/.gitattributes b/.gitattributes index 2755315..e741dd4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ benchmarks export-ignore docs export-ignore examples export-ignore tests export-ignore +tools export-ignore .editorconfig export-ignore .gitattributes export-ignore diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index a311bcc..31caf44 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -60,7 +60,7 @@ jobs: run: php -d error_reporting=E_ALL smoke.php runwire-consumer: - name: Runwire 2.1 consumer - PHP ${{ matrix.php }} - ${{ matrix.dependencies }} + name: Runwire 3.0 consumer - PHP ${{ matrix.php }} - ${{ matrix.dependencies }} runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -75,7 +75,7 @@ jobs: tools: composer:v2 extensions: pdo, pdo_sqlite coverage: none - - name: Resolve CacheLayer with Runwire 2.1 + - name: Resolve CacheLayer with Runwire 3.0 working-directory: tools/release/runwire-consumer shell: bash run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad81ec1..c7331d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,24 @@ Run the complete project validation suite before opening a pull request: composer ic:ci ``` +The complete cache suite requires CLI APCu at PHP startup and the integration +services selected by `.github/workflows/security-standards.yml`. Enable the +correct setting in the CLI INI file, or add a temporary INI scan directory so +Composer's PHP subprocesses receive the same setting: + +```bash +mkdir -p /tmp/cachelayer-php-ini +printf 'apc.enable_cli=1\n' > /tmp/cachelayer-php-ini/apcu.ini +PHP_INI_SCAN_DIR=:/tmp/cachelayer-php-ini composer ic:ci +``` + +The leading colon retains PHP's default INI scan directory. `ini_set()` cannot +enable CLI APCu after startup, and `php -d` options on the Composer process do +not carry over to its child PHP processes. PHPForge's +`composer ic:services:up` command manages its configured local services; export +the matching service endpoints and credentials before testing. Missing required +services fail discovery rather than silently reducing the test matrix. + When `composer ic:ci` passes, running the same checks individually is unnecessary. Use focused commands while developing or when the complete suite cannot run: diff --git a/README.md b/README.md index cb67f5b..57c8302 100644 --- a/README.md +++ b/README.md @@ -237,9 +237,9 @@ $runtime->consume(); Failed local invalidation stops consumption without advancing the cursor; operators can repair the cause and retry. A poison event can only be skipped explicitly with `skipEventAfterClear()`, which clears the local namespace before advancing. Plain key invalidation cannot fence an in-flight resolver, so mutable read-through data that requires ordering should also use a tag generation. It does not replicate values and is not a distributed lock, session store, or counter system. -## Runwire 2.1 integration +## Runwire 3.0 integration -CacheLayer 4.0 ships optional Runwire 2.1 integration without making Runwire a core dependency. A framework shares its active `RuntimeContext` at worker/application bootstrap and the current `RequestContext` / `CoroutineScope` at the request or task boundary. CacheLayer then uses relevant capabilities automatically; when Runwire is absent, inactive, or missing a capability, the ordinary CacheLayer path remains in use. +CacheLayer supports optional Runwire 3.0 integration without making Runwire a core dependency. A framework shares its active `RuntimeContext` at worker/application bootstrap and the current `RequestContext` / `CoroutineScope` at the request or task boundary. CacheLayer then uses relevant capabilities automatically; when Runwire is absent, inactive, or missing a capability, the ordinary CacheLayer path remains in use. Runwire keeps ownership of listeners, workers, supervisors, and event loops. `RunwireWorkerIntegration` can place bounded Cluster invalidation consumption and Node SQLite maintenance inside a host-provided task/service worker scope; CacheLayer never starts background work merely because Runwire is installed. Synchronous backend calls remain synchronous. diff --git a/composer.json b/composer.json index bd3a55b..429ce1d 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "infocyph/runwire": "2.1", + "infocyph/runwire": "^3.0", "mongodb/mongodb": "*" }, "suggest": { @@ -56,7 +56,7 @@ "ext-pdo_sqlite": "For default SQLite usage via Cache::pdo(...) or Cache::sqlite(...)", "ext-redis": "For Redis-based caching (persistent, networked)", "ext-sysvshm": "For shared-memory caching via SharedMemoryCacheAdapter", - "infocyph/runwire": "For optional Runwire 2.1 request/task isolation and worker-owned invalidation/maintenance integration", + "infocyph/runwire": "For optional Runwire 3.0 request/task isolation and worker-owned invalidation/maintenance integration", "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter" }, "minimum-stability": "stable", diff --git a/docs/adapters/apcu.rst b/docs/adapters/apcu.rst index 1d7b7ff..0f333da 100644 --- a/docs/adapters/apcu.rst +++ b/docs/adapters/apcu.rst @@ -10,7 +10,9 @@ Requirements: * ``ext-apcu`` * APCu enabled (``apcu_enabled()``) -* for CLI usage/tests: ``apcu.enable_cli=1`` +* for CLI usage/tests: start PHP with ``-d apc.enable_cli=1`` or configure + ``apc.enable_cli=1`` in the CLI INI file; this setting cannot be enabled with + ``ini_set()`` after startup Highlights: diff --git a/docs/adapters/redis-cluster.rst b/docs/adapters/redis-cluster.rst index 7ad8db4..8aeb838 100644 --- a/docs/adapters/redis-cluster.rst +++ b/docs/adapters/redis-cluster.rst @@ -17,6 +17,8 @@ Requirements: Highlights: * 128 fixed hash-tag buckets for cross-slot-safe grouped operations +* expiring bulk writes use per-bucket ``MULTI`` transactions, supported by + phpredis RedisCluster without pipeline-mode warnings * namespace clear replaces each opaque bucket generation * no permanent key index, stale membership, or cluster-wide scan * atomic ``setIfAbsent()``, ``compareAndSet()``, and ``getAndDelete()`` through diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index 1ce0a85..5b35613 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -489,8 +489,8 @@ Supervised long-running worker Supervisor, Kubernetes, or your framework queue/worker runtime. It calls bounded ``consume()`` loops and polls/sleeps between iterations. -Runwire 2.1 task/service worker - When the application already uses Runwire 2.1, bind the active runtime and +Runwire 3.0 task/service worker + When the application already uses Runwire 3.0, bind the active runtime and let :doc:`/runwire` place bounded consumption inside the host-owned worker scope. Runwire retains worker/event-loop ownership and cancellation/drain. If the needed capability is unavailable, keep the explicit ``consume()`` diff --git a/docs/node/_content.inc b/docs/node/_content.inc index 91580ec..b236e4e 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -394,7 +394,7 @@ platform scheduler instead when that is how application jobs are operated. Only one maintenance task is needed per local SQLite file; it is safe to use a bounded prune limit if overlap cannot be ruled out. -Applications already running Runwire 2.1 may instead use +Applications already running Runwire 3.0 may instead use ``RunwireWorkerIntegration::startNodeMaintenance()`` in a host-owned task/service worker. CacheLayer does not create a worker or event loop; see :doc:`/runwire` for lifecycle and topology requirements. diff --git a/docs/runwire.rst b/docs/runwire.rst index 0f9b4a5..240099a 100644 --- a/docs/runwire.rst +++ b/docs/runwire.rst @@ -1,14 +1,14 @@ ======================= -Runwire 2.1 integration +Runwire 3.0 integration ======================= -CacheLayer 4.0 ships an optional integration with Runwire 2.1. Runwire is not a +CacheLayer supports an optional integration with Runwire 3.0. Runwire is not a core dependency: ordinary PHP-FPM, CLI, and other consumers continue to use the normal CacheLayer paths without installing it. Install Runwire only in applications that already use it:: - composer require infocyph/runwire:^2.1 + composer require infocyph/runwire:^3.0 Ownership contract ================== @@ -63,6 +63,15 @@ The host shares the actual request/task scope around application work: fn () => $application->handle($request), ); +An explicitly supplied request can also provide its runtime when no runtime has +been bound. Completed requests are rejected before the callback runs; cancelled +or expired requests propagate Runwire's cancellation exception. Checkpoints and +positive sleeps check request cancellation even without a coroutine scope. + +Release or rebind discards existing execution bindings. A suspended or nested +callback may unwind afterwards, but its cleanup cannot restore a discarded +binding or replace the new runtime context. + When a request context is available, memoize(), object remember(), and once() use request-owned memoizers. Concurrent requests therefore cannot observe or flush each other's memoized state. If a persistent concurrent runtime is bound @@ -75,8 +84,10 @@ normal process-local behavior. Cooperative waits ================= -Existing bounded polling waits can use the shared CoroutineScope when the -active runtime exposes RUNWIRE_COROUTINES. CacheLayer does not wrap synchronous +Explicit ``RunwireIntegration::sleep()`` calls use the shared CoroutineScope when +the active runtime exposes RUNWIRE_COROUTINES. The built-in File, PDO, Redis and +Memcached lock providers still poll with blocking ``usleep()`` calls. CacheLayer +does not wrap synchronous PDO, filesystem, Redis, MongoDB, Memcached, Scylla, or other native client calls and does not claim that those calls become asynchronous. @@ -179,7 +190,7 @@ storage format depends on Runwire. HTTP scheduling =============== -Runwire 2.1 adaptive HTTP scheduling is a host-level concern. CacheLayer does +Runwire 3.0 adaptive HTTP scheduling is a host-level concern. CacheLayer does not select FIXED, LATENCY, THROUGHPUT, or AUTO and does not attribute HTTP throughput changes to cache storage. Keep Runwire's certified host defaults unless the application measures and chooses another policy. diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 6272905..ab8197b 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -16,7 +16,7 @@ final class RedisClusterCacheAdapter extends AbstractCacheAdapter implements Ato private const int BUCKET_COUNT = 128; - private const int PIPELINE_MODE = 2; + private const int TRANSACTION_MODE = 1; private readonly object $cluster; @@ -368,14 +368,14 @@ private function saveBucket(int $bucket, array $items): bool /** @param list $records */ private function saveExpiring(array $records): bool { - $pipeline = $this->call('multi', self::PIPELINE_MODE); - if (!is_object($pipeline)) { + $transaction = $this->call('multi', self::TRANSACTION_MODE); + if (!is_object($transaction)) { return false; } foreach ($records as [$key, $ttl, $blob]) { - $this->callObject($pipeline, 'setex', $key, $ttl, $blob); + $this->callObject($transaction, 'setex', $key, $ttl, $blob); } - $results = $this->callObject($pipeline, 'exec'); + $results = $this->callObject($transaction, 'exec'); return is_array($results) && count($results) === count($records) diff --git a/src/Integration/Runwire/RunwireExecutionContext.php b/src/Integration/Runwire/RunwireExecutionContext.php index 913d2c0..16fe525 100644 --- a/src/Integration/Runwire/RunwireExecutionContext.php +++ b/src/Integration/Runwire/RunwireExecutionContext.php @@ -20,6 +20,10 @@ public function __construct( if ($request !== null && $request->runtime() !== $runtime) { throw new LogicException('Runwire request context is bound to a different runtime.'); } + if ($request?->completed()) { + throw new LogicException('Completed request context cannot be shared.'); + } + $request?->cancellation->throwIfCancelled(); } public function supports(RuntimeCapability $capability): bool diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index 959ac73..c6258aa 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -11,7 +11,6 @@ use Infocyph\Runwire\RequestContext; use Infocyph\Runwire\Runtime\Enum\RuntimeCapability; use Infocyph\Runwire\RuntimeContext; -use LogicException; use WeakMap; final class RunwireIntegration @@ -41,6 +40,7 @@ public static function bind(RuntimeContext $runtime): void public static function checkpoint(): void { $context = self::current(); + $context?->request?->cancellation->throwIfCancelled(); if ( $context?->scope !== null && $context->supports(RuntimeCapability::RUNWIRE_COROUTINES) @@ -154,15 +154,11 @@ public static function share( callable $callback, ?RuntimeContext $runtime = null, ): mixed { - $runtime ??= self::runtime(); + $runtime ??= self::runtime() ?? $request?->runtime(); if ($runtime === null) { return $callback(); } - if ($request !== null && $request->runtime() !== $runtime) { - throw new LogicException('Runwire request context is bound to a different runtime.'); - } - $context = new RunwireExecutionContext($runtime, $request, $scope); $fiber = Fiber::getCurrent(); if ($fiber === null) { @@ -172,22 +168,25 @@ public static function share( try { return $callback(); } finally { - self::$rootContext = $previous; + if (self::$rootContext === $context) { + self::$rootContext = $previous; + } } } - self::$fiberContexts ??= new WeakMap(); - $hadPrevious = isset(self::$fiberContexts[$fiber]); - $previous = $hadPrevious ? self::$fiberContexts[$fiber] : null; - self::$fiberContexts[$fiber] = $context; + $contexts = self::$fiberContexts ??= new WeakMap(); + $previous = $contexts[$fiber] ?? null; + $contexts[$fiber] = $context; try { return $callback(); } finally { - if ($hadPrevious && $previous instanceof RunwireExecutionContext) { - self::$fiberContexts[$fiber] = $previous; - } else { - unset(self::$fiberContexts[$fiber]); + if (self::$fiberContexts === $contexts) { + if ($previous instanceof RunwireExecutionContext) { + $contexts[$fiber] = $previous; + } else { + unset($contexts[$fiber]); + } } } } @@ -198,6 +197,7 @@ public static function sleep(float $seconds): void return; } + self::checkpoint(); $context = self::current(); if ( $context?->scope !== null diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 6f340a8..d3eb2b1 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -284,7 +284,7 @@ public function saveItems(array $items): bool #[\Override] public function storeTagGenerations(array $generations): bool { - if (!$this->l2->storeTagGenerations($generations)) { + if (!$this->attempt(fn(): bool => $this->l2->storeTagGenerations($generations), false, 'l2_failure')) { return false; } if (!$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface)) { @@ -314,6 +314,7 @@ private function attempt(callable $operation, mixed $fallback, string $failureMe try { return $operation(); } catch (Throwable $failure) { + $this->disableL1(); $this->metric($failureMetric); if (!$this->failOpen) { throw $failure; @@ -343,8 +344,11 @@ private function disableL1(): void } $this->l1Readable = false; - if ($this->l1 !== null) { - $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_clear_failure'); + + try { + $this->l1?->clear(); + } catch (Throwable) { + $this->metric('l1_clear_failure'); } } @@ -448,13 +452,14 @@ private function saveInto( array $items, string $failureMetric, ): bool { - $targets = []; - foreach ($items as $item) { - $key = $item->getKey(); - $targets[$key] = $this->copyItemTo($pool, $item); - } + return $this->attempt(function () use ($pool, $items): bool { + $targets = []; + foreach ($items as $item) { + $targets[$item->getKey()] = $this->copyItemTo($pool, $item); + } - return $this->attempt(fn(): bool => $pool->saveItems($targets), false, $failureMetric); + return $pool->saveItems($targets); + }, false, $failureMetric); } private function saveOneInto( @@ -462,8 +467,6 @@ private function saveOneInto( CacheItemInterface $item, string $failureMetric, ): bool { - $target = $this->copyItemTo($pool, $item); - - return $this->attempt(fn(): bool => $pool->save($target), false, $failureMetric); + return $this->attempt(fn(): bool => $pool->save($this->copyItemTo($pool, $item)), false, $failureMetric); } } diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 56c0878..3894079 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -6,8 +6,8 @@ * tests/ApcuCachePoolTest.php * * Run these only when the APCu extension is loaded **and** - * enabled in CLI (apcu.enable_cli=1). Otherwise the whole - * suite is skipped. + * enabled at CLI startup (apc.enable_cli=1). + * Discovery fails when this required prerequisite is missing. */ use Infocyph\CacheLayer\Cache\Cache; @@ -15,11 +15,10 @@ use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -/* ── skip entirely if APCu unavailable ─────────────────────────────── */ +/* ── require CLI APCu ─────────────────────────────────────────────── */ if (! extension_loaded('apcu')) { throw new RuntimeException('APCu is required for the configured cache test matrix.'); } -ini_set('apcu.enable_cli', 1); if (! apcu_enabled()) { throw new RuntimeException('APCu must be enabled for CLI tests.'); } diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 8923707..c3c87f0 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -13,16 +13,16 @@ public int $mgetCalls = 0; - public int $pipelineCalls = 0; + public int $transactionCalls = 0; public int $setexCalls = 0; - public bool $failPipelineCommand = false; + public bool $failTransactionCommand = false; - private bool $pipelined = false; + private bool $transactional = false; /** @var list */ - private array $pipelineResults = []; + private array $transactionResults = []; public function del(string|array $keys): int { @@ -146,9 +146,12 @@ public function mset(array $values): bool public function multi(int $mode): self { - $this->pipelineCalls++; - $this->pipelined = $mode > 0; - $this->pipelineResults = []; + if ($mode !== 1) { + throw new RuntimeException('Redis Cluster supports MULTI transactions only.'); + } + $this->transactionCalls++; + $this->transactional = $mode > 0; + $this->transactionResults = []; return $this; } @@ -168,8 +171,8 @@ public function setex(string $key, int $ttl, string $value): bool { $this->setexCalls++; $this->values[$key] = ['value' => $value, 'expires' => time() + max(1, $ttl)]; - if ($this->pipelined) { - $this->pipelineResults[] = !$this->failPipelineCommand; + if ($this->transactional) { + $this->transactionResults[] = !$this->failTransactionCommand; } return true; @@ -178,9 +181,9 @@ public function setex(string $key, int $ttl, string $value): bool /** @return list */ public function exec(): array { - $this->pipelined = false; + $this->transactional = false; - return $this->pipelineResults; + return $this->transactionResults; } /** @return list */ @@ -235,13 +238,13 @@ private function prune(string $key): void expect($this->cache->get('ttl'))->toBeNull(); }); -test('redis cluster adapter rejects a partial expiring pipeline write', function () { - $this->cluster->failPipelineCommand = true; +test('redis cluster adapter rejects a partial expiring transaction write', function () { + $this->cluster->failTransactionCommand = true; expect($this->cache->setMultiple(['first' => 1, 'second' => 2], 60))->toBeFalse(); }); -test('redis cluster groups expiring bulk writes into bounded bucket pipelines', function () { +test('redis cluster groups expiring bulk writes into bounded bucket transactions', function () { $values = []; $buckets = []; for ($index = 0; $index < 100; $index++) { @@ -252,7 +255,7 @@ private function prune(string $key): void expect($this->cache->setMultiple($values, 60))->toBeTrue() ->and($this->cluster->setexCalls)->toBe(100) - ->and($this->cluster->pipelineCalls)->toBe(count($buckets)); + ->and($this->cluster->transactionCalls)->toBe(count($buckets)); }); test('redis cluster clear rotates bucket generations without a permanent key index', function () { diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php index b0786e2..a589d5f 100644 --- a/tests/Integration/RunwireIntegrationTest.php +++ b/tests/Integration/RunwireIntegrationTest.php @@ -7,6 +7,9 @@ use Infocyph\Runwire\Coroutine\CoroutineRuntime; use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\Exception\CancelledException; +use Infocyph\Runwire\Runtime\Enum\CancellationReason; +use Infocyph\Runwire\Runtime\RequestExecutionPolicy; use Infocyph\Runwire\Runtime\Enum\RuntimeCapability; use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; use Infocyph\Runwire\RuntimeCapabilities; @@ -328,3 +331,96 @@ static function (): void { }, $runtime); expect(RunwireIntegration::runtime())->toBeNull(); }); + +it('does not restore a discarded runtime when nested shares unwind', function (bool $replace): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + $replacement = $replace ? cacheLayerRunwireContext(concurrent: true) : null; + RunwireIntegration::bind($runtime); + $request = RequestContext::create($runtime); + + RunwireIntegration::share($request, null, static function () use ($request, $replacement): void { + RunwireIntegration::share($request, null, static function () use ($replacement): void { + $replacement === null ? RunwireIntegration::release() : RunwireIntegration::bind($replacement); + }); + expect(RunwireIntegration::current())->toBeNull() + ->and(RunwireIntegration::runtime())->toBe($replacement); + }); + + expect(RunwireIntegration::runtime())->toBe($replacement); +})->with([false, true]); + +it('unwinds suspended nested Fibers after the host discards their binding', function (bool $replace, bool $throw): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $request = RequestContext::create($runtime); + $fiber = new Fiber(static fn() => RunwireIntegration::share($request, null, static function () use ($request, $throw): void { + RunwireIntegration::share($request, null, static function () use ($throw): void { + Fiber::suspend(); + if ($throw) { + throw new RuntimeException('callback failure'); + } + }); + })); + $fiber->start(); + $replacement = $replace ? cacheLayerRunwireContext(concurrent: true) : null; + $replacement === null ? RunwireIntegration::release() : RunwireIntegration::bind($replacement); + + if ($throw) { + expect(fn() => $fiber->resume())->toThrow(RuntimeException::class, 'callback failure'); + } else { + $fiber->resume(); + } + + expect($fiber->isTerminated())->toBeTrue() + ->and(RunwireIntegration::current())->toBeNull() + ->and(RunwireIntegration::runtime())->toBe($replacement); +})->with([false, true])->with([false, true]); + +it('rejects a completed request before application work executes', function (): void { + $runtime = cacheLayerRunwireContext(); + $request = RequestContext::create($runtime); + $request->complete(); + $ran = false; + + expect(fn() => RunwireIntegration::share($request, null, function () use (&$ran): void { + $ran = true; + }, $runtime))->toThrow(LogicException::class, 'Completed request'); + expect($ran)->toBeFalse(); +}); + +it('rejects cancelled or expired requests before application work executes', function (bool $expired): void { + $runtime = cacheLayerRunwireContext(); + $request = $expired + ? RequestContext::create($runtime, new RequestExecutionPolicy(maxExecutionSeconds: 0.001), startNanoseconds: (int) hrtime(true) - 2_000_000) + : RequestContext::create($runtime); + if (!$expired) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + $ran = false; + + expect(fn() => RunwireIntegration::share($request, null, function () use (&$ran): void { + $ran = true; + }, $runtime))->toThrow(CancelledException::class); + expect($ran)->toBeFalse(); +})->with([false, true]); + +it('checks request cancellation without a coroutine scope', function (): void { + $runtime = cacheLayerRunwireContext(); + $request = RequestContext::create($runtime); + + RunwireIntegration::share($request, null, static function () use ($request): void { + $request->cancel(CancellationReason::HOST_CANCELLED); + expect(fn() => RunwireIntegration::checkpoint())->toThrow(CancelledException::class) + ->and(fn() => RunwireIntegration::sleep(0.001))->toThrow(CancelledException::class); + }, $runtime); +}); + +it('derives a borrowed runtime from an explicitly supplied request', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + $request = RequestContext::create($runtime); + RunwireIntegration::share($request, null, static function () use ($runtime, $request): void { + expect(RunwireIntegration::runtime())->toBe($runtime) + ->and(RunwireIntegration::current()?->request)->toBe($request); + }); + expect(RunwireIntegration::runtime())->toBeNull(); +}); diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 7a2c195..b561e23 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -4,6 +4,7 @@ use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\ApcuCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; @@ -17,6 +18,7 @@ use Infocyph\CacheLayer\Node\Maintenance\NodeCachePruner; use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Infocyph\CacheLayer\Tests\Cache\Support\FaultingFileCacheAdapter; use Psr\Cache\CacheItemInterface; beforeEach(function () { @@ -30,6 +32,147 @@ ); }); +test('node fences L1 after a mutation fails under either failure policy', function (string $operation, bool $failOpen, bool $throws) { + $l1 = new FaultingFileCacheAdapter('node.failure', $this->nodeCacheDirectory . '/l1'); + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($this->nodeConfig), 'node.failure'); + $adapter = new NodeCacheAdapter($l1, $l2, $failOpen); + $cache = new Cache($adapter, options: new CacheOptions(failOpen: $failOpen)); + $cache->set('key', 'old', 300); + $l1->failure = $operation; + $l1->throws = $throws; + + $mutate = match ($operation) { + 'save' => fn() => $cache->set('key', 'new', 300), + 'saveItems' => fn() => $cache->setMultiple(['key' => 'new'], 300), + 'deleteItem' => fn() => $cache->delete('key'), + 'deleteItems' => fn() => $cache->deleteMultiple(['key']), + 'clear' => fn() => $cache->clear(), + }; + + if ($throws && !$failOpen) { + expect($mutate)->toThrow(RuntimeException::class); + } else { + expect($mutate())->toBeFalse(); + } + + expect($adapter->isAuthoritative())->toBeTrue() + ->and($cache->get('key'))->toBe($l2->getItem('key')->get()); +})->with(['save', 'saveItems', 'deleteItem', 'deleteItems', 'clear']) + ->with([false, true])->with([false, true]); + +test('node preserves the mutation failure when L1 cleanup also throws', function () { + $l1 = new class('node.cleanup', $this->nodeCacheDirectory . '/l1') extends \Infocyph\CacheLayer\Cache\Adapter\FileCacheAdapter { + public bool $fault = false; + + public function save(CacheItemInterface $item): bool + { + if ($this->fault) { + throw new RuntimeException('original write failure'); + } + + return parent::save($item); + } + + public function clear(): bool + { + if ($this->fault) { + throw new RuntimeException('cleanup failure'); + } + + return parent::clear(); + } + }; + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($this->nodeConfig), 'node.cleanup'); + $adapter = new NodeCacheAdapter($l1, $l2, false); + $item = $adapter->createItem('key')->set('old'); + $adapter->save($item); + $l1->fault = true; + + expect(fn() => $adapter->save($item->set('new')))->toThrow(RuntimeException::class, 'original write failure') + ->and($adapter->getItem('key')->get())->toBe('new'); +}); + +test('node fences failed single and bulk promotions under either failure policy', function (bool $bulk, bool $failOpen, bool $throws) { + $l1 = new FaultingFileCacheAdapter('node.promotion', $this->nodeCacheDirectory . '/l1'); + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($this->nodeConfig), 'node.promotion'); + $adapter = new NodeCacheAdapter($l1, $l2, $failOpen); + $cache = new Cache($adapter, options: new CacheOptions(failOpen: $failOpen)); + $cache->set('key', 'value'); + $l1->clear(); + $l1->failure = $bulk ? 'saveItems' : 'save'; + $l1->throws = $throws; + $read = $bulk ? fn() => $cache->getMultiple(['key']) : fn() => $cache->get('key'); + + if ($throws && !$failOpen) { + expect($read)->toThrow(RuntimeException::class); + } else { + expect($read())->toBe($bulk ? ['key' => 'value'] : 'value'); + } + + expect($adapter->isAuthoritative())->toBeTrue() + ->and($cache->get('key'))->toBe('value'); +})->with([false, true])->with([false, true])->with([false, true]); + +test('node fences L1 when copying a committed value into L1 throws', function (bool $bulk) { + $l1 = new class('node.copy', $this->nodeCacheDirectory . '/l1') extends \Infocyph\CacheLayer\Cache\Adapter\FileCacheAdapter { + public bool $fault = false; + + public function createItem(string $key): CacheItemInterface + { + if ($this->fault) { + throw new RuntimeException('copy failure'); + } + + return parent::createItem($key); + } + }; + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($this->nodeConfig), 'node.copy'); + $adapter = new NodeCacheAdapter($l1, $l2, false); + $cache = new Cache($adapter, options: new CacheOptions(failOpen: false)); + $cache->set('key', 'old'); + $l1->fault = true; + + expect(fn() => $bulk ? $cache->setMultiple(['key' => 'new']) : $cache->set('key', 'new')) + ->toThrow(RuntimeException::class) + ->and($cache->get('key'))->toBe('new'); +})->with([false, true]); + +test('node fences L1 when a tag metadata mutation throws', function (string $operation) { + $l1 = new class('node.tags.' . uniqid()) extends ApcuCacheAdapter { + public ?string $failure = null; + + public function rotateTagGenerations(array $tags): bool + { + if ($this->failure === 'rotate') { + throw new RuntimeException('tag rotation failure'); + } + + return parent::rotateTagGenerations($tags); + } + + public function storeTagGenerations(array $generations): bool + { + if ($this->failure === 'store') { + throw new RuntimeException('tag storage failure'); + } + + return parent::storeTagGenerations($generations); + } + }; + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($this->nodeConfig), 'node.tags'); + $adapter = new NodeCacheAdapter($l1, $l2, false); + $cache = new Cache($adapter, options: new CacheOptions(failOpen: false)); + $cache->setTagged('key', 'old', ['group']); + $l1->failure = $operation; + + expect(fn() => $operation === 'rotate' + ? $cache->invalidateTag('group') + : $adapter->storeTagGenerations(['group' => bin2hex(random_bytes(16))])) + ->toThrow(RuntimeException::class) + ->and($adapter->isAuthoritative())->toBeTrue() + ->and($cache->get('key'))->toBeNull(); +})->with(['rotate', 'store']); + afterEach(function () { if (!is_dir($this->nodeCacheDirectory)) { return; diff --git a/tools/release/redis-cluster-smoke.php b/tools/release/redis-cluster-smoke.php index cfe3207..b7e621e 100644 --- a/tools/release/redis-cluster-smoke.php +++ b/tools/release/redis-cluster-smoke.php @@ -7,6 +7,10 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; +set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); +}); + $rawSeeds = getenv('CACHELAYER_REDIS_CLUSTER_SEEDS'); if (!is_string($rawSeeds) || $rawSeeds === '') { throw new RuntimeException('CACHELAYER_REDIS_CLUSTER_SEEDS is required.'); diff --git a/tools/release/runwire-consumer/composer.json b/tools/release/runwire-consumer/composer.json index 2b24556..c5f715a 100644 --- a/tools/release/runwire-consumer/composer.json +++ b/tools/release/runwire-consumer/composer.json @@ -6,7 +6,7 @@ "ext-pdo": "*", "ext-pdo_sqlite": "*", "infocyph/cachelayer": "4.0.x-dev", - "infocyph/runwire": "^2.1" + "infocyph/runwire": "^3.0" }, "repositories": [ { diff --git a/tools/release/runwire-consumer/smoke.php b/tools/release/runwire-consumer/smoke.php index c7f6b8c..74fcc56 100644 --- a/tools/release/runwire-consumer/smoke.php +++ b/tools/release/runwire-consumer/smoke.php @@ -13,8 +13,8 @@ } $version = InstalledVersions::getPrettyVersion('infocyph/runwire'); -if (!is_string($version) || !str_starts_with(ltrim($version, 'v'), '2.1')) { - throw new RuntimeException('The release integration gate requires Runwire 2.1.x.'); +if (!is_string($version) || !str_starts_with(ltrim($version, 'v'), '3.')) { + throw new RuntimeException('The release integration gate requires Runwire 3.x.'); } putenv('CACHELAYER_EXAMPLE_AUTOLOAD=' . __DIR__ . '/vendor/autoload.php');