Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
},
"require-dev": {
"infocyph/phpforge": "dev-main@dev",
"infocyph/runwire": "2.1",
"infocyph/runwire": "^3.0",
"mongodb/mongodb": "*"
},
"suggest": {
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion docs/adapters/apcu.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/adapters/redis-cluster.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/cluster/_content.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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()``
Expand Down
2 changes: 1 addition & 1 deletion docs/node/_content.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 17 additions & 6 deletions docs/runwire.rst
Original file line number Diff line number Diff line change
@@ -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
==================
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
10 changes: 5 additions & 5 deletions src/Cache/Adapter/RedisClusterCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -368,14 +368,14 @@ private function saveBucket(int $bucket, array $items): bool
/** @param list<array{0:string, 1:int, 2:string}> $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)
Expand Down
4 changes: 4 additions & 0 deletions src/Integration/Runwire/RunwireExecutionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 15 additions & 15 deletions src/Integration/Runwire/RunwireIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);
}
}
}
}
Expand All @@ -198,6 +197,7 @@ public static function sleep(float $seconds): void
return;
}

self::checkpoint();
$context = self::current();
if (
$context?->scope !== null
Expand Down
27 changes: 15 additions & 12 deletions src/Node/Adapter/NodeCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}

Expand Down Expand Up @@ -448,22 +452,21 @@ 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(
InternalCachePoolInterface $pool,
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);
}
}
7 changes: 3 additions & 4 deletions tests/Cache/ApcuCachePoolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@
* 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;
use Infocyph\CacheLayer\Cache\CacheOptions;
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.');
}
Expand Down
Loading
Loading