From 236fefcad6945cc4e76d25b4ef271aa69bab0e5e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 13:54:59 +0600 Subject: [PATCH 1/2] :sparkles: upgrade(deps): upgrade Runwire integration to v3.0 and fix related execution issues - Update Runwire dependency constraint to ^3.0 and bump consumer integration configuration :arrow_up: - Fix node cache L1 cache consistency and fault handling after failed mutations :bug: - Correct APCu CLI INI documentation and startup requirements :memo: - Use transaction mode for Redis Cluster expiring bulk writes :zap: - Improve Runwire request lifecycle checks and nested cleanup safety :recycle: --- .gitattributes | 1 + .github/workflows/release-verification.yml | 4 +- CONTRIBUTING.md | 18 + README.md | 4 +- composer.json | 24 +- docs/adapters/apcu.rst | 4 +- docs/adapters/redis-cluster.rst | 2 + docs/cluster/_content.inc | 4 +- docs/node/_content.inc | 2 +- docs/release-plan-4.0.2.md | 384 ++++++++++++++++++ docs/runwire.rst | 23 +- .../Adapter/RedisClusterCacheAdapter.php | 10 +- .../Runwire/RunwireExecutionContext.php | 4 + .../Runwire/RunwireIntegration.php | 30 +- src/Node/Adapter/NodeCacheAdapter.php | 27 +- tests/Cache/ApcuCachePoolTest.php | 7 +- tests/Cache/RedisClusterCachePoolTest.php | 33 +- tests/Integration/RunwireIntegrationTest.php | 96 +++++ tests/Node/NodeCacheTest.php | 143 +++++++ tools/release/redis-cluster-smoke.php | 4 + tools/release/runwire-consumer/composer.json | 2 +- tools/release/runwire-consumer/smoke.php | 4 +- 22 files changed, 760 insertions(+), 70 deletions(-) create mode 100644 docs/release-plan-4.0.2.md diff --git a/.gitattributes b/.gitattributes index 27553158..e741dd40 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 a311bcc5..31caf446 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 ad81ec11..c7331d21 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 cb67f5bb..57c83023 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 bd3a55b5..3afe35a6 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", @@ -74,6 +74,26 @@ "Infocyph\\CacheLayer\\Tests\\": "tests/" } }, + "archive": { + "exclude": [ + "/.agents", + "/.aws", + "/.codex", + "/.idea", + "/.phpunit.cache", + "/.psalm-cache", + "/.vscode", + "/benchmarks", + "/composer.lock", + "/docs", + "/examples", + "/graphify-out", + "/tests", + "/tools", + "/var", + "/vendor" + ] + }, "config": { "allow-plugins": { "ergebnis/composer-normalize": true, diff --git a/docs/adapters/apcu.rst b/docs/adapters/apcu.rst index 1d7b7fff..0f333daf 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 7ad8db47..8aeb838a 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 1ce0a856..5b35613f 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 91580ec6..b236e4e8 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/release-plan-4.0.2.md b/docs/release-plan-4.0.2.md new file mode 100644 index 00000000..8d4ca791 --- /dev/null +++ b/docs/release-plan-4.0.2.md @@ -0,0 +1,384 @@ +# CacheLayer audit and proposed 4.0.2 release plan + +Audit date: 2026-10-30 (Asia/Dhaka). Base revision: +`4322becf18eaec017e8301a372770ec258b298b0`. The changes described below are +uncommitted. The latest published release verified through GitHub is +[4.0.1](https://github.com/infocyph/CacheLayer/releases/tag/4.0.1). + +**Decision: local fixes are implemented; release qualification remains open.** Prefer 4.0.2 for the +dependency refresh and compatible correctness fixes. Use 4.1 only if the scope +adds public capabilities. An intentional breaking contract change requires its +own versioning decision; a minor version does not make such a change compatible. + +This plan follows the installed PHPForge +[engineering principles](../vendor/infocyph/phpforge/resources/engineering-principles.md): +correctness and security first, existing owners, the smallest compatible diff, +regression evidence, unchanged quality thresholds, and measured sustained host +throughput before performance acceptance. It does not authorize publication. + +## Work completed + +- Changed the root development requirement and the independent integration + consumer to `infocyph/runwire:^3.0`. +- Installed published Runwire `3.0`, reference + `054737bc6a8d4025a8d05fabb8ad390f66ee47ae`. Only Runwire changed in the root + dependency resolution. The root lockfile is intentionally ignored by the + existing repository policy; it was refreshed locally, not added to Git. +- Updated the consumer's version assertion to accept stable Runwire 3.x, the + release-verification job names, README, and current integration documentation. + The 4.0 release notes and migration guide retain their historical 2.1 references. +- Kept Runwire optional. A clean `--no-dev` installation contains only + `opis/closure`, `psr/cache`, and `psr/simple-cache`, plus CacheLayer itself. +- Preserved a separate `.gitattributes` workspace edit adding + `tools export-ignore`, which appeared during the audit. + +Follow-up implementation completed F1-F5 below and the additional Redis Cluster +finding F6. The initial reproductions remain recorded for review. Release +qualification, including the unavailable MongoDB environment, remains separate +from source completion. + +| Finding | Follow-up status | +| --- | --- | +| F1 Node stale L1 after exceptions | Fixed in existing attempt/copy/cleanup paths; original failures survive cleanup errors | +| F2 Runwire cleanup restores discarded contexts | Fixed with root-context and captured WeakMap identity checks | +| F3 request lifecycle/cancellation | Completed/cancelled/expired requests rejected; scope-free cancellation checked; request can supply its borrowed runtime | +| F4 archive contamination | Explicit Composer exclusions; final archive inspected and installed independently | +| F5 APCu startup instructions | Corrected docs, discovery comments and contributor setup; strict prerequisite remains | +| F6 Redis Cluster pipeline warning | Corrected to per-bucket MULTI; native smoke rejects warnings | + +## Review scope and security result + +The inventory contains 114 runtime source files and 43 tracked test/support +files. Repository-wide PHPForge checks cover syntax, references, duplicates, +comments, style, architecture, static analysis, and security analysis. Manual +review concentrated on the facade and atomic contracts, all adapter families, +payload and Closure serialization, filesystem trust, locks, counters, Node L1/L2, +cluster publication/cursors/recovery/outbox, memoizers, Runwire contexts and worker +ownership, consumers, workflows, documentation, and package contents. + +Current Composer audits returned **zero known advisories** for both the full +locked set and the production-only set. The full set reports the abandoned +development dependency `doctrine/annotations`, reached through +`phpbench/phpbench`; the runtime set has no abandoned packages. All installed +direct dependencies are current according to `composer show --direct --latest`. +This is dependency evidence, not a proof that the library has no vulnerabilities. +No remote exploit was established during this review. + +Existing protections checked include bound payload signatures before decoding, +disabled object/Closure payloads by default, bounded payload/decompression and +array traversal, key/namespace validation, parameterized SQL with identifier +validation, token-checked lock release, capability-gated optional integrations, +and host ownership of workers and event loops. + +## Findings and remediation + +### F1 — P1: Node Cache can serve stale L1 after a throwing mutation + +Owner: `src/Node/Adapter/NodeCacheAdapter.php`, especially `save()` at line 236, +`saveItems()` at line 259, and `attempt()` at line 312. Review `clear()`, deletes, +tag updates and read promotions for the same exception ordering. + +With `failOpen: false`, L2 is changed first. If the subsequent L1 operation +throws, `attempt()` rethrows before `disableL1()` runs. The old L1 remains +readable. A probe using the existing `FaultingFileCacheAdapter` fixture and +in-memory Node SQLite reproduced: + +```text +set key=old +make L1 save throw +set key=new -> CacheBackendException +get key -> old +direct L2 get key -> new +``` + +This is a consistency defect in the failure path; security impact depends on +what the application caches. Existing Node tests cover a false L1 write result, +but do not cover the throwing fail-closed case. + +Implemented approach: + +1. Add the failing regression to `tests/Node/NodeCacheTest.php` using the existing + fault-injection support. Cover single/bulk save, clear/delete/tag operations, + promotion, false returns, exceptions, and both failure policies where relevant. +2. Disable and clear L1 before an operational exception can escape or turn into a + fallback. Enclose child-item copying and metadata storage in that same error + boundary. Cleanup cannot replace the original failure. Keep the fix in the + existing Node owner without a new hierarchy. +3. Preserve successful-path behavior, deferred-item ownership, TTLs and tags. + After any partial failure, reads must use valid L2 state or a safe miss. + +Acceptance: the regression fails before the fix and passes afterwards; all Node, +cluster and tiered regressions pass; static analysis stays within class/function +cognitive-complexity limits 80/12; measured host throughput meets the release +budget below. + +### F2 — P1: Runwire cleanup can restore a released context or throw + +Owner: `src/Integration/Runwire/RunwireIntegration.php:134-192`. + +`release()` and `bind()` reset the execution-context registry, but an older +`share()` invocation unconditionally restores its captured previous context in +`finally`. Two probes reproduced: + +- On the root execution path, release from an inner `share()` restores the old + outer context: `runtime()` returns the released runtime after inner cleanup. +- Suspend inside nested shares in a Fiber, release the bound runtime from the + host, then resume the Fiber. Cleanup throws: + `TypeError: Cannot auto-initialize an array inside property ...::$fiberContexts of type ?WeakMap`. + +Implemented approach: + +1. Add root and overlapping/nested Fiber regressions for release and rebind + during a callback, suspension and exception unwinding. +2. Restore the root context only while it is still the current invocation's + context; restore Fiber entries only while the captured WeakMap remains the + active registry. Preserve explicit borrowed runtimes and ordinary nesting. +3. Preserve ordinary nesting, Fiber isolation, explicit runtime forwarding, + no-Runwire fallback, and host ownership. Cleanup must not resurrect discarded + request state or mask a callback's original exception. + +Acceptance: no old context becomes current after release/rebind; suspended +callbacks unwind without registry errors; integration tests, consumer smoke, +certification and soak pass on Runwire 3.0. + +### F3 — P2: shared request lifecycle and cancellation checks are incomplete + +Owner: `RunwireIntegration::share()`, `checkpoint()`, and +`RunwireExecutionContext::__construct()`. + +The integration checks request/runtime identity but accepts a completed request. +It also lets an already cancelled request pass `checkpoint()` when no coroutine +scope was supplied. Both callback executions were reproduced. Runwire 3.0's +`RequestContext` exposes `completed()` and its cancellation token, so the +integration can enforce this boundary without creating a runtime or event loop. + +Implemented approach: + +1. Add completed, cancelled and expired-request regressions, including requests + without coroutine scopes and callbacks that mutate cache state. +2. Validate the supplied request before invoking application work; check request + cancellation/deadline independently of optional coroutine capability. +3. Preserve fresh `RequestContext::create()` usage and explicit runtime + forwarding. Define the rejection contract and document it. Do not invent an + activation requirement or a scope-inspection API that Runwire does not expose. + +Acceptance: completed/cancelled/expired input cannot execute the protected +callback; cancellation propagates without fallback replay; normal scope-free +and coroutine-assisted calls continue to pass. + +### F4 — P2: Composer archives and path consumers include local payloads + +Owner: root `composer.json` archive policy, `.gitattributes`, and release consumer +setup. The checked-in Git source archive and a local Composer archive differ. + +`composer archive --format=zip --dir=/tmp --file=cachelayer-audit` produced an +archive with 23,102 entries and 439,357,398 uncompressed bytes at the time of the +probe. It included root `vendor/`, `graphify-out/`, local metadata directories, +and generated consumer dependencies. The first path consumer copied about +249 MiB; creating a second consumer copied that generated payload again. A +`--no-dev` path consumer therefore did not demonstrate clean package contents. + +`git archive HEAD` excluded untracked vendor/graph data. Consequently this probe +does **not** establish that a published GitHub/Packagist archive leaked these +files. The separate `tools export-ignore` edit helps Git exports but does not +alone define all Composer exclusions. + +Implemented approach: + +1. Declare explicit Composer archive exclusions for dependencies, generated + graphs/caches, consumer outputs, local agent/configuration directories and + development-only payloads. Preserve every tracked runtime file and required + package metadata. +2. Build and inspect the actual intended distribution. Compare its runtime + entries against `git ls-files src`; verify no local/development payload enters + the package. Verify Git exports separately. +3. Resolve fresh consumers outside the working tree or from that artifact, then + remove and reinstall their locked vendors with `--no-dev`. Run representative + storage and optional-Runwire smoke checks against the artifact. + +Acceptance: a populated development checkout cannot contaminate the package or +consumer; no runtime file is missing; both consumers pass with only their +declared production dependencies. + +The final archive has 120 entries, all 114 runtime files, and 144,144 compressed +bytes. Its SHA-256 is +`7da6353353640535c75c4627915f895a97f7e641f2d66f6c8ae756cb18af9fef`. +Runtime file bytes and the packaged manifest were compared with the final tree. +An artifact consumer, installed outside the repository, passed its smoke and +Runwire 3.0 certification/soak checks. This local archive digest does not replace +inspection of the eventual tagged distribution. + +### F5 — P2: APCu CLI setup documents the wrong INI key + +Owners: `docs/adapters/apcu.rst:13`, APCu test setup and contributor instructions. +The docs and discovery guard use `apcu.enable_cli`; the real setting is +`apc.enable_cli`. The guard's `ini_set()` does not enable CLI APCu. The normal +host quality command fails discovery, while `php -d apc.enable_cli=1 ...` gets +past that prerequisite. + +Implemented: correct the documented key and test comments, require the setting at PHP +startup, document the required services and canonical command, and keep the +strict discovery guard. Do not turn missing required services into skipped tests +or alter global host PHP configuration as part of a library fix. + +### F6 — P2: Redis Cluster expiring batches request unsupported pipeline mode + +Real-cluster verification emitted warnings from phpredis 6.2.0 because +`saveExpiring()` requested pipeline mode. The native client still completed the +original smoke, so a success-only check did not catch the warnings. The upstream +[RedisCluster documentation](https://github.com/phpredis/phpredis/blob/6.2.0/cluster.md#pipelining) +states that pipelining is unsupported and documents MULTI/EXEC transactions. + +Changed the existing bucket writer to MULTI mode; every batch already shares one +hash slot. The test client now rejects unsupported modes. Before the fix, the +stricter client suite had 1 failure/16 passes; after it, 17 tests/53 assertions +pass. The real-cluster smoke also passes with warnings converted to exceptions. +Documented the batch behavior without changing public methods or key formats. + +## Initial audit evidence, before follow-up fixes + +| Check | Current evidence | +| --- | --- | +| PHPForge doctor/config resolution | Healthy; bundled configuration; PHP matrix 8.4/8.5 | +| Composer validate / stable constraints | Passed | +| Locked and production advisory audits | Zero advisories; development abandonment noted above | +| Required `composer ic:process` | Passed with local loopback access for Rector | +| Detailed quality checks after the bump | Syntax: 171 PHP files; references: 124 symbols/3,245 references; comments, Pint, PHPCS, Deptrac, PHPStan, Psalm and Rector passed | +| Duplicate detector | Passed configured gate: 27 groups, 1,165 duplicated lines, 7.08%; no threshold changes | +| Normal `composer ic:tests:details` / `ic:release:guard` | Failed at Pest discovery: `APCu must be enabled for CLI tests.` | +| Full Pest with startup APCu enabled | Failed discovery: required Memcached service unavailable | +| Explicit local test selection with startup APCu enabled | 350 passed, 2 failed, 1,403 assertions; both failures require Scylla Alternator. Included integration, memoization, Node, cluster, serializers and local adapter/security tests. This was not the complete required backend matrix. | +| Root core smoke | Passed with CLI OPcache off and on, PHP 8.5.4 | +| Separate core consumer | Passed | +| Clean `/tmp` copy, `composer install --no-dev --classmap-authoritative` | Passed platform requirements and core smoke; installed package inventory confirms only the three runtime dependencies | +| Runwire 3.0 independent consumer smoke | Passed | +| Existing Runwire certification | Passed 4,000 operations, 250 warmups, no errors, no measured memory delta; integrated/unintegrated component RPM ratio approximately 0.901 | +| Existing Runwire soak | Passed 2,048 sequential/256 concurrent requests, 2,277 validated requests, 10 intentional failures, 9 cancellations, 8 deadlines; allocated-memory growth 8 MiB, at its existing limit | +| Quick PHPBench | 30 subjects, zero failures/errors; supporting component measurements only | +| Sphinx `-W --keep-going` | Passed from a temporary uv environment | +| Git whitespace check | Passed | + +The existing certification's 0.50 RPM-ratio floor compares integrated and +unintegrated component calls. Its result is neither a 2.1-to-3.0 release +regression measurement nor production HTTP RPM evidence. The soak is request +count bounded, not a sustained-duration production soak. + +The original host Python lacked Sphinx and ensurepip; temporary uv tooling +resolved the documentation prerequisite without changing the host. Generated +consumer vendors/locks were removed after validation. One analyzer attempt was +stopped because those generated nested vendors contaminated its scope; the +subsequent clean-tree run produced the detailed results above. + +## Follow-up verification + +- Focused Node/Runwire/cluster/tiered/Redis Cluster validation: **160 passed, + 744 assertions**. This includes mutation and promotion failures under both + policies, tag storage/rotation, item-copy failures, cleanup-error preservation, + nested release/rebind, suspended Fiber exceptions and request lifecycle checks. +- With isolated cached services, all available-backend tests passed: initially + **461 passed, 1,861 assertions**, excluding only the real MongoDB file. The + complete gate with a MongoDB endpoint configured reached the same 461 passes + and **two real MongoDB connection failures**. Eight final promotion regressions + were then added. Both final `ic:tests:details` and `ic:release:guard` reached + **469 passed, two failed, 1,885 assertions**. The only failures remain the two + real MongoDB connection cases; the release guard exits nonzero. +- Real MySQL/MariaDB identity/collation and transaction-publication tests, + PostgreSQL, Redis/Valkey, Memcached and Scylla Alternator checks passed. +- Real three-node Redis Cluster smoke passed with warnings treated as failures. +- Independent upstream PSR suites against the archive: **338 tests, 469 + assertions, two existing long-key skips** from the unchanged CI fixtures. Those + established interoperability exceptions are distinct from the required Pest + matrix, whose strict policy remains unchanged. +- PHP 8.5.4 core smoke passed with OPcache off/on. The archive's production + consumer also passed in the cached PHP 8.4 container. This PHP 8.4 smoke is not + the full PHP 8.4 dependency/backend matrix. +- The final archive consumer passed Runwire 3.0 certification and soak: no + certification errors, no pending invalidations, 2,277 validated soak requests, + and 8 MiB allocated-memory growth. The final component RPM ratio was about + 0.906 against the unintegrated path; no old-release host RPM claim is made. +- Quick PHPBench on the fixed sources passed **30 subjects, zero errors or + failures**. Final Sphinx documentation builds passed with warnings as errors. +- PHPForge processors, syntax, references, comments, duplicates, style, + architecture, maximum-level PHPStan, Psalm and Rector passed after the runtime + fixes. The active complexity limits remain 80/12. + +MongoDB is an environment blocker, not a demonstrated CacheLayer regression: +the configured MongoDB image exits before accepting connections and reports: +`Linux kernel versions 6.19 and newer has a known incompatibility with this +version of MongoDB` (SERVER-121912). Its compatibility check was not bypassed, +no backend was substituted, and no required test was skipped to pass a gate. +The Docker project is isolated as `cachelayer_audit_20261030`; temporary services +are removed after validation. Native Scylla CQL, Windows, lowest dependencies, +the complete PHP 8.4 matrix and production host throughput still require the +release environment/hosted jobs. + +## Release validation plan + +1. Retain the implemented F1-F6 fixes and failing-before/passing-after + regressions in the candidate. Keep + changes within existing cohesive owners and preserve public parameter names, + storage formats, PSR behavior and host runtime ownership. +2. Run focused Node/Runwire/package tests during implementation, then the + required PHPForge flow: doctor/config review, `ic:process`, + `ic:tests:details`, and `ic:release:guard`. Keep all detectors, paths and + thresholds active; do not add suppressions, baselines or skipped tests. +3. Resolve the host/image MongoDB incompatibility and execute the complete + backend suite again. Live MySQL/MariaDB, PostgreSQL, Redis/Valkey, Memcached, + Redis Cluster and Scylla Alternator have local evidence. Real MongoDB and + native Scylla CQL remain open. Test doubles and SQLite do not substitute for + those remaining production engines. +4. Re-run existing release jobs on PHP 8.4 and 8.5, lowest/stable Runwire + dependencies, Windows/Linux core smoke, independent PSR contracts, real Redis + Cluster/CQL, production consumers, and docs. Record unavailable native runtime + capabilities and any next intended PHP target separately. +5. Before accepting performance-sensitive fixes, collect matched baseline and + candidate trials on the same stable production-equivalent host. Compare the + old/new release with equivalent features and validated results. Use several + warmed trials and concurrency levels for hit/miss/fill, tagged and bulk calls, + lock contention, Node/cluster invalidation and persistent request isolation. + Capture successful RPM/RPS, variance, p50/p95/p99, errors/timeouts, CPU, + continuously sampled process-tree RSS, connections and queue growth. Start + measurements after readiness; include a sustained soak with lifecycle cleanup. + Use the principles' default maximum **2% median successful-RPM regression** + for stable critical workloads. Investigate noise with unchanged-revision + reruns; do not tighten the existing component test blindly or relax a gate. +6. Commit the reviewed candidate and obtain hosted evidence for that exact SHA. + Before tagging, inspect the final artifact and repeat the artifact consumer + smoke checks. Publish only after the required gates are green. + +Hosted evidence currently applies only to the unchanged base revision: seven +associated workflow runs succeeded, including +[Security & Standards](https://github.com/infocyph/CacheLayer/actions/runs/40158043693) +and [Release Verification](https://github.com/infocyph/CacheLayer/actions/runs/38780089343). +Those runs do not validate this uncommitted dependency bump and runtime fixes. +No commit, push, tag or release was created during this audit. + +## Deferred improvements + +- Address `doctrine/annotations` through the owning PHPForge/PHPBench dependency + chain when an upstream-compatible solution exists; do not remove benchmarks or + pin an unrelated local workaround. +- Review measured hot paths before changing metrics defaults, batching, codecs, + memoizer limits or adapter structure. A passing duplicate detector does not + justify consolidating distinct backend protocols solely to reduce line count. +- The Runwire documentation now states the actual lock-provider boundary: + File/PDO/polling helpers call `usleep()` directly. Consider cooperative waits with + contention/cancellation tests and throughput measurements. Such integration + must propagate cancellation instead of entering a fail-open resolver fallback. + +## Local evidence locations + +Audit-session logs are under `/tmp/cachelayer-*` and are ephemeral. Key files: +`cachelayer-final-details-clean.log`, `cachelayer-release-guard.log`, +`cachelayer-local-pest.log`, `cachelayer-probe-results.log`, +`cachelayer-runwire-{update,smoke,certify,soak}.log`, +`cachelayer-production-{install,platform,smoke}.log`, `cachelayer-bench.log`, +`cachelayer-docs.log`, `cachelayer-audit.json`, and +`cachelayer-runtime-audit.json`. The executable failure probes are in +`/tmp/cachelayer-audit-probes.php`; their scenarios and expected regressions are +recorded above so the plan remains useful after temporary files expire. + +Final follow-up logs use `cachelayer-completed-{process,details,guard}.log`, +`cachelayer-final-focused.log`, `cachelayer-candidate-{certify,soak,bench,docs}.log`, +`cachelayer-redis-cluster-fixed.log`, `cachelayer-psr-tests.log`, +`cachelayer-php84-consumer.log`, and `cachelayer-services-up.log`. diff --git a/docs/runwire.rst b/docs/runwire.rst index 0f9b4a53..240099ac 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 6272905e..ab8197be 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 913d2c08..16fe525c 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 959ac73e..c6258aad 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 6f340a85..d3eb2b16 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 56c0878e..38940796 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 8923707d..c3c87f04 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 b0786e22..a589d5fc 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 7a2c1958..b561e23e 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 cfe3207b..b7e621e2 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 2b24556d..c5f715a4 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 c7f6b8c7..74fcc56f 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'); From 5e2c82edd5a6bec034fc862cc53c502f9612002d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 19:38:46 +0600 Subject: [PATCH 2/2] :fire: chore(config): remove unused archive exclusions from composer configuration - Cut outdated archive ignore patterns from composer.json :fire: - Remove release plan documentation file for version 4.0.2 :fire: --- composer.json | 20 -- docs/release-plan-4.0.2.md | 384 ------------------------------------- 2 files changed, 404 deletions(-) delete mode 100644 docs/release-plan-4.0.2.md diff --git a/composer.json b/composer.json index 3afe35a6..429ce1d8 100644 --- a/composer.json +++ b/composer.json @@ -74,26 +74,6 @@ "Infocyph\\CacheLayer\\Tests\\": "tests/" } }, - "archive": { - "exclude": [ - "/.agents", - "/.aws", - "/.codex", - "/.idea", - "/.phpunit.cache", - "/.psalm-cache", - "/.vscode", - "/benchmarks", - "/composer.lock", - "/docs", - "/examples", - "/graphify-out", - "/tests", - "/tools", - "/var", - "/vendor" - ] - }, "config": { "allow-plugins": { "ergebnis/composer-normalize": true, diff --git a/docs/release-plan-4.0.2.md b/docs/release-plan-4.0.2.md deleted file mode 100644 index 8d4ca791..00000000 --- a/docs/release-plan-4.0.2.md +++ /dev/null @@ -1,384 +0,0 @@ -# CacheLayer audit and proposed 4.0.2 release plan - -Audit date: 2026-10-30 (Asia/Dhaka). Base revision: -`4322becf18eaec017e8301a372770ec258b298b0`. The changes described below are -uncommitted. The latest published release verified through GitHub is -[4.0.1](https://github.com/infocyph/CacheLayer/releases/tag/4.0.1). - -**Decision: local fixes are implemented; release qualification remains open.** Prefer 4.0.2 for the -dependency refresh and compatible correctness fixes. Use 4.1 only if the scope -adds public capabilities. An intentional breaking contract change requires its -own versioning decision; a minor version does not make such a change compatible. - -This plan follows the installed PHPForge -[engineering principles](../vendor/infocyph/phpforge/resources/engineering-principles.md): -correctness and security first, existing owners, the smallest compatible diff, -regression evidence, unchanged quality thresholds, and measured sustained host -throughput before performance acceptance. It does not authorize publication. - -## Work completed - -- Changed the root development requirement and the independent integration - consumer to `infocyph/runwire:^3.0`. -- Installed published Runwire `3.0`, reference - `054737bc6a8d4025a8d05fabb8ad390f66ee47ae`. Only Runwire changed in the root - dependency resolution. The root lockfile is intentionally ignored by the - existing repository policy; it was refreshed locally, not added to Git. -- Updated the consumer's version assertion to accept stable Runwire 3.x, the - release-verification job names, README, and current integration documentation. - The 4.0 release notes and migration guide retain their historical 2.1 references. -- Kept Runwire optional. A clean `--no-dev` installation contains only - `opis/closure`, `psr/cache`, and `psr/simple-cache`, plus CacheLayer itself. -- Preserved a separate `.gitattributes` workspace edit adding - `tools export-ignore`, which appeared during the audit. - -Follow-up implementation completed F1-F5 below and the additional Redis Cluster -finding F6. The initial reproductions remain recorded for review. Release -qualification, including the unavailable MongoDB environment, remains separate -from source completion. - -| Finding | Follow-up status | -| --- | --- | -| F1 Node stale L1 after exceptions | Fixed in existing attempt/copy/cleanup paths; original failures survive cleanup errors | -| F2 Runwire cleanup restores discarded contexts | Fixed with root-context and captured WeakMap identity checks | -| F3 request lifecycle/cancellation | Completed/cancelled/expired requests rejected; scope-free cancellation checked; request can supply its borrowed runtime | -| F4 archive contamination | Explicit Composer exclusions; final archive inspected and installed independently | -| F5 APCu startup instructions | Corrected docs, discovery comments and contributor setup; strict prerequisite remains | -| F6 Redis Cluster pipeline warning | Corrected to per-bucket MULTI; native smoke rejects warnings | - -## Review scope and security result - -The inventory contains 114 runtime source files and 43 tracked test/support -files. Repository-wide PHPForge checks cover syntax, references, duplicates, -comments, style, architecture, static analysis, and security analysis. Manual -review concentrated on the facade and atomic contracts, all adapter families, -payload and Closure serialization, filesystem trust, locks, counters, Node L1/L2, -cluster publication/cursors/recovery/outbox, memoizers, Runwire contexts and worker -ownership, consumers, workflows, documentation, and package contents. - -Current Composer audits returned **zero known advisories** for both the full -locked set and the production-only set. The full set reports the abandoned -development dependency `doctrine/annotations`, reached through -`phpbench/phpbench`; the runtime set has no abandoned packages. All installed -direct dependencies are current according to `composer show --direct --latest`. -This is dependency evidence, not a proof that the library has no vulnerabilities. -No remote exploit was established during this review. - -Existing protections checked include bound payload signatures before decoding, -disabled object/Closure payloads by default, bounded payload/decompression and -array traversal, key/namespace validation, parameterized SQL with identifier -validation, token-checked lock release, capability-gated optional integrations, -and host ownership of workers and event loops. - -## Findings and remediation - -### F1 — P1: Node Cache can serve stale L1 after a throwing mutation - -Owner: `src/Node/Adapter/NodeCacheAdapter.php`, especially `save()` at line 236, -`saveItems()` at line 259, and `attempt()` at line 312. Review `clear()`, deletes, -tag updates and read promotions for the same exception ordering. - -With `failOpen: false`, L2 is changed first. If the subsequent L1 operation -throws, `attempt()` rethrows before `disableL1()` runs. The old L1 remains -readable. A probe using the existing `FaultingFileCacheAdapter` fixture and -in-memory Node SQLite reproduced: - -```text -set key=old -make L1 save throw -set key=new -> CacheBackendException -get key -> old -direct L2 get key -> new -``` - -This is a consistency defect in the failure path; security impact depends on -what the application caches. Existing Node tests cover a false L1 write result, -but do not cover the throwing fail-closed case. - -Implemented approach: - -1. Add the failing regression to `tests/Node/NodeCacheTest.php` using the existing - fault-injection support. Cover single/bulk save, clear/delete/tag operations, - promotion, false returns, exceptions, and both failure policies where relevant. -2. Disable and clear L1 before an operational exception can escape or turn into a - fallback. Enclose child-item copying and metadata storage in that same error - boundary. Cleanup cannot replace the original failure. Keep the fix in the - existing Node owner without a new hierarchy. -3. Preserve successful-path behavior, deferred-item ownership, TTLs and tags. - After any partial failure, reads must use valid L2 state or a safe miss. - -Acceptance: the regression fails before the fix and passes afterwards; all Node, -cluster and tiered regressions pass; static analysis stays within class/function -cognitive-complexity limits 80/12; measured host throughput meets the release -budget below. - -### F2 — P1: Runwire cleanup can restore a released context or throw - -Owner: `src/Integration/Runwire/RunwireIntegration.php:134-192`. - -`release()` and `bind()` reset the execution-context registry, but an older -`share()` invocation unconditionally restores its captured previous context in -`finally`. Two probes reproduced: - -- On the root execution path, release from an inner `share()` restores the old - outer context: `runtime()` returns the released runtime after inner cleanup. -- Suspend inside nested shares in a Fiber, release the bound runtime from the - host, then resume the Fiber. Cleanup throws: - `TypeError: Cannot auto-initialize an array inside property ...::$fiberContexts of type ?WeakMap`. - -Implemented approach: - -1. Add root and overlapping/nested Fiber regressions for release and rebind - during a callback, suspension and exception unwinding. -2. Restore the root context only while it is still the current invocation's - context; restore Fiber entries only while the captured WeakMap remains the - active registry. Preserve explicit borrowed runtimes and ordinary nesting. -3. Preserve ordinary nesting, Fiber isolation, explicit runtime forwarding, - no-Runwire fallback, and host ownership. Cleanup must not resurrect discarded - request state or mask a callback's original exception. - -Acceptance: no old context becomes current after release/rebind; suspended -callbacks unwind without registry errors; integration tests, consumer smoke, -certification and soak pass on Runwire 3.0. - -### F3 — P2: shared request lifecycle and cancellation checks are incomplete - -Owner: `RunwireIntegration::share()`, `checkpoint()`, and -`RunwireExecutionContext::__construct()`. - -The integration checks request/runtime identity but accepts a completed request. -It also lets an already cancelled request pass `checkpoint()` when no coroutine -scope was supplied. Both callback executions were reproduced. Runwire 3.0's -`RequestContext` exposes `completed()` and its cancellation token, so the -integration can enforce this boundary without creating a runtime or event loop. - -Implemented approach: - -1. Add completed, cancelled and expired-request regressions, including requests - without coroutine scopes and callbacks that mutate cache state. -2. Validate the supplied request before invoking application work; check request - cancellation/deadline independently of optional coroutine capability. -3. Preserve fresh `RequestContext::create()` usage and explicit runtime - forwarding. Define the rejection contract and document it. Do not invent an - activation requirement or a scope-inspection API that Runwire does not expose. - -Acceptance: completed/cancelled/expired input cannot execute the protected -callback; cancellation propagates without fallback replay; normal scope-free -and coroutine-assisted calls continue to pass. - -### F4 — P2: Composer archives and path consumers include local payloads - -Owner: root `composer.json` archive policy, `.gitattributes`, and release consumer -setup. The checked-in Git source archive and a local Composer archive differ. - -`composer archive --format=zip --dir=/tmp --file=cachelayer-audit` produced an -archive with 23,102 entries and 439,357,398 uncompressed bytes at the time of the -probe. It included root `vendor/`, `graphify-out/`, local metadata directories, -and generated consumer dependencies. The first path consumer copied about -249 MiB; creating a second consumer copied that generated payload again. A -`--no-dev` path consumer therefore did not demonstrate clean package contents. - -`git archive HEAD` excluded untracked vendor/graph data. Consequently this probe -does **not** establish that a published GitHub/Packagist archive leaked these -files. The separate `tools export-ignore` edit helps Git exports but does not -alone define all Composer exclusions. - -Implemented approach: - -1. Declare explicit Composer archive exclusions for dependencies, generated - graphs/caches, consumer outputs, local agent/configuration directories and - development-only payloads. Preserve every tracked runtime file and required - package metadata. -2. Build and inspect the actual intended distribution. Compare its runtime - entries against `git ls-files src`; verify no local/development payload enters - the package. Verify Git exports separately. -3. Resolve fresh consumers outside the working tree or from that artifact, then - remove and reinstall their locked vendors with `--no-dev`. Run representative - storage and optional-Runwire smoke checks against the artifact. - -Acceptance: a populated development checkout cannot contaminate the package or -consumer; no runtime file is missing; both consumers pass with only their -declared production dependencies. - -The final archive has 120 entries, all 114 runtime files, and 144,144 compressed -bytes. Its SHA-256 is -`7da6353353640535c75c4627915f895a97f7e641f2d66f6c8ae756cb18af9fef`. -Runtime file bytes and the packaged manifest were compared with the final tree. -An artifact consumer, installed outside the repository, passed its smoke and -Runwire 3.0 certification/soak checks. This local archive digest does not replace -inspection of the eventual tagged distribution. - -### F5 — P2: APCu CLI setup documents the wrong INI key - -Owners: `docs/adapters/apcu.rst:13`, APCu test setup and contributor instructions. -The docs and discovery guard use `apcu.enable_cli`; the real setting is -`apc.enable_cli`. The guard's `ini_set()` does not enable CLI APCu. The normal -host quality command fails discovery, while `php -d apc.enable_cli=1 ...` gets -past that prerequisite. - -Implemented: correct the documented key and test comments, require the setting at PHP -startup, document the required services and canonical command, and keep the -strict discovery guard. Do not turn missing required services into skipped tests -or alter global host PHP configuration as part of a library fix. - -### F6 — P2: Redis Cluster expiring batches request unsupported pipeline mode - -Real-cluster verification emitted warnings from phpredis 6.2.0 because -`saveExpiring()` requested pipeline mode. The native client still completed the -original smoke, so a success-only check did not catch the warnings. The upstream -[RedisCluster documentation](https://github.com/phpredis/phpredis/blob/6.2.0/cluster.md#pipelining) -states that pipelining is unsupported and documents MULTI/EXEC transactions. - -Changed the existing bucket writer to MULTI mode; every batch already shares one -hash slot. The test client now rejects unsupported modes. Before the fix, the -stricter client suite had 1 failure/16 passes; after it, 17 tests/53 assertions -pass. The real-cluster smoke also passes with warnings converted to exceptions. -Documented the batch behavior without changing public methods or key formats. - -## Initial audit evidence, before follow-up fixes - -| Check | Current evidence | -| --- | --- | -| PHPForge doctor/config resolution | Healthy; bundled configuration; PHP matrix 8.4/8.5 | -| Composer validate / stable constraints | Passed | -| Locked and production advisory audits | Zero advisories; development abandonment noted above | -| Required `composer ic:process` | Passed with local loopback access for Rector | -| Detailed quality checks after the bump | Syntax: 171 PHP files; references: 124 symbols/3,245 references; comments, Pint, PHPCS, Deptrac, PHPStan, Psalm and Rector passed | -| Duplicate detector | Passed configured gate: 27 groups, 1,165 duplicated lines, 7.08%; no threshold changes | -| Normal `composer ic:tests:details` / `ic:release:guard` | Failed at Pest discovery: `APCu must be enabled for CLI tests.` | -| Full Pest with startup APCu enabled | Failed discovery: required Memcached service unavailable | -| Explicit local test selection with startup APCu enabled | 350 passed, 2 failed, 1,403 assertions; both failures require Scylla Alternator. Included integration, memoization, Node, cluster, serializers and local adapter/security tests. This was not the complete required backend matrix. | -| Root core smoke | Passed with CLI OPcache off and on, PHP 8.5.4 | -| Separate core consumer | Passed | -| Clean `/tmp` copy, `composer install --no-dev --classmap-authoritative` | Passed platform requirements and core smoke; installed package inventory confirms only the three runtime dependencies | -| Runwire 3.0 independent consumer smoke | Passed | -| Existing Runwire certification | Passed 4,000 operations, 250 warmups, no errors, no measured memory delta; integrated/unintegrated component RPM ratio approximately 0.901 | -| Existing Runwire soak | Passed 2,048 sequential/256 concurrent requests, 2,277 validated requests, 10 intentional failures, 9 cancellations, 8 deadlines; allocated-memory growth 8 MiB, at its existing limit | -| Quick PHPBench | 30 subjects, zero failures/errors; supporting component measurements only | -| Sphinx `-W --keep-going` | Passed from a temporary uv environment | -| Git whitespace check | Passed | - -The existing certification's 0.50 RPM-ratio floor compares integrated and -unintegrated component calls. Its result is neither a 2.1-to-3.0 release -regression measurement nor production HTTP RPM evidence. The soak is request -count bounded, not a sustained-duration production soak. - -The original host Python lacked Sphinx and ensurepip; temporary uv tooling -resolved the documentation prerequisite without changing the host. Generated -consumer vendors/locks were removed after validation. One analyzer attempt was -stopped because those generated nested vendors contaminated its scope; the -subsequent clean-tree run produced the detailed results above. - -## Follow-up verification - -- Focused Node/Runwire/cluster/tiered/Redis Cluster validation: **160 passed, - 744 assertions**. This includes mutation and promotion failures under both - policies, tag storage/rotation, item-copy failures, cleanup-error preservation, - nested release/rebind, suspended Fiber exceptions and request lifecycle checks. -- With isolated cached services, all available-backend tests passed: initially - **461 passed, 1,861 assertions**, excluding only the real MongoDB file. The - complete gate with a MongoDB endpoint configured reached the same 461 passes - and **two real MongoDB connection failures**. Eight final promotion regressions - were then added. Both final `ic:tests:details` and `ic:release:guard` reached - **469 passed, two failed, 1,885 assertions**. The only failures remain the two - real MongoDB connection cases; the release guard exits nonzero. -- Real MySQL/MariaDB identity/collation and transaction-publication tests, - PostgreSQL, Redis/Valkey, Memcached and Scylla Alternator checks passed. -- Real three-node Redis Cluster smoke passed with warnings treated as failures. -- Independent upstream PSR suites against the archive: **338 tests, 469 - assertions, two existing long-key skips** from the unchanged CI fixtures. Those - established interoperability exceptions are distinct from the required Pest - matrix, whose strict policy remains unchanged. -- PHP 8.5.4 core smoke passed with OPcache off/on. The archive's production - consumer also passed in the cached PHP 8.4 container. This PHP 8.4 smoke is not - the full PHP 8.4 dependency/backend matrix. -- The final archive consumer passed Runwire 3.0 certification and soak: no - certification errors, no pending invalidations, 2,277 validated soak requests, - and 8 MiB allocated-memory growth. The final component RPM ratio was about - 0.906 against the unintegrated path; no old-release host RPM claim is made. -- Quick PHPBench on the fixed sources passed **30 subjects, zero errors or - failures**. Final Sphinx documentation builds passed with warnings as errors. -- PHPForge processors, syntax, references, comments, duplicates, style, - architecture, maximum-level PHPStan, Psalm and Rector passed after the runtime - fixes. The active complexity limits remain 80/12. - -MongoDB is an environment blocker, not a demonstrated CacheLayer regression: -the configured MongoDB image exits before accepting connections and reports: -`Linux kernel versions 6.19 and newer has a known incompatibility with this -version of MongoDB` (SERVER-121912). Its compatibility check was not bypassed, -no backend was substituted, and no required test was skipped to pass a gate. -The Docker project is isolated as `cachelayer_audit_20261030`; temporary services -are removed after validation. Native Scylla CQL, Windows, lowest dependencies, -the complete PHP 8.4 matrix and production host throughput still require the -release environment/hosted jobs. - -## Release validation plan - -1. Retain the implemented F1-F6 fixes and failing-before/passing-after - regressions in the candidate. Keep - changes within existing cohesive owners and preserve public parameter names, - storage formats, PSR behavior and host runtime ownership. -2. Run focused Node/Runwire/package tests during implementation, then the - required PHPForge flow: doctor/config review, `ic:process`, - `ic:tests:details`, and `ic:release:guard`. Keep all detectors, paths and - thresholds active; do not add suppressions, baselines or skipped tests. -3. Resolve the host/image MongoDB incompatibility and execute the complete - backend suite again. Live MySQL/MariaDB, PostgreSQL, Redis/Valkey, Memcached, - Redis Cluster and Scylla Alternator have local evidence. Real MongoDB and - native Scylla CQL remain open. Test doubles and SQLite do not substitute for - those remaining production engines. -4. Re-run existing release jobs on PHP 8.4 and 8.5, lowest/stable Runwire - dependencies, Windows/Linux core smoke, independent PSR contracts, real Redis - Cluster/CQL, production consumers, and docs. Record unavailable native runtime - capabilities and any next intended PHP target separately. -5. Before accepting performance-sensitive fixes, collect matched baseline and - candidate trials on the same stable production-equivalent host. Compare the - old/new release with equivalent features and validated results. Use several - warmed trials and concurrency levels for hit/miss/fill, tagged and bulk calls, - lock contention, Node/cluster invalidation and persistent request isolation. - Capture successful RPM/RPS, variance, p50/p95/p99, errors/timeouts, CPU, - continuously sampled process-tree RSS, connections and queue growth. Start - measurements after readiness; include a sustained soak with lifecycle cleanup. - Use the principles' default maximum **2% median successful-RPM regression** - for stable critical workloads. Investigate noise with unchanged-revision - reruns; do not tighten the existing component test blindly or relax a gate. -6. Commit the reviewed candidate and obtain hosted evidence for that exact SHA. - Before tagging, inspect the final artifact and repeat the artifact consumer - smoke checks. Publish only after the required gates are green. - -Hosted evidence currently applies only to the unchanged base revision: seven -associated workflow runs succeeded, including -[Security & Standards](https://github.com/infocyph/CacheLayer/actions/runs/40158043693) -and [Release Verification](https://github.com/infocyph/CacheLayer/actions/runs/38780089343). -Those runs do not validate this uncommitted dependency bump and runtime fixes. -No commit, push, tag or release was created during this audit. - -## Deferred improvements - -- Address `doctrine/annotations` through the owning PHPForge/PHPBench dependency - chain when an upstream-compatible solution exists; do not remove benchmarks or - pin an unrelated local workaround. -- Review measured hot paths before changing metrics defaults, batching, codecs, - memoizer limits or adapter structure. A passing duplicate detector does not - justify consolidating distinct backend protocols solely to reduce line count. -- The Runwire documentation now states the actual lock-provider boundary: - File/PDO/polling helpers call `usleep()` directly. Consider cooperative waits with - contention/cancellation tests and throughput measurements. Such integration - must propagate cancellation instead of entering a fail-open resolver fallback. - -## Local evidence locations - -Audit-session logs are under `/tmp/cachelayer-*` and are ephemeral. Key files: -`cachelayer-final-details-clean.log`, `cachelayer-release-guard.log`, -`cachelayer-local-pest.log`, `cachelayer-probe-results.log`, -`cachelayer-runwire-{update,smoke,certify,soak}.log`, -`cachelayer-production-{install,platform,smoke}.log`, `cachelayer-bench.log`, -`cachelayer-docs.log`, `cachelayer-audit.json`, and -`cachelayer-runtime-audit.json`. The executable failure probes are in -`/tmp/cachelayer-audit-probes.php`; their scenarios and expected regressions are -recorded above so the plan remains useful after temporary files expire. - -Final follow-up logs use `cachelayer-completed-{process,details,guard}.log`, -`cachelayer-final-focused.log`, `cachelayer-candidate-{certify,soak,bench,docs}.log`, -`cachelayer-redis-cluster-fixed.log`, `cachelayer-psr-tests.log`, -`cachelayer-php84-consumer.log`, and `cachelayer-services-up.log`.