From fb0b8400257758e362b506cac354a6bdd71cae2a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 14:18:44 +0600 Subject: [PATCH 001/434] :memo: docs(cachelayer): add v4.0 security, correctness, and release plan - Add comprehensive audit plan outlining P1 and P2 findings, required fixes, implementation batches, and release acceptance criteria :memo: - Update development dependencies in composer.json to include infocyph/runwire 2.1 and wildcard mongodb support :arrow_up: --- composer.json | 3 +- ...achelayer-4.0-security-correctness-plan.md | 423 ++++++++++++++++++ 2 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 docs/plans/cachelayer-4.0-security-correctness-plan.md diff --git a/composer.json b/composer.json index d604bbfa..0a90ca86 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "mongodb/mongodb": "^1.20 || ^2.0" + "infocyph/runwire": "2.1", + "mongodb/mongodb": "*" }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md new file mode 100644 index 00000000..3abd2c03 --- /dev/null +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -0,0 +1,423 @@ +# CacheLayer security, correctness, and release plan + +Date: 2026-09-28 +Status: Planned for 4.0.0; audit completed, remediation not implemented\ +Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) +Release target: **4.0.0 — next major release** + +## Decision + +The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. + +Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. Deliver the coordinated changes to authenticated payload identity, persisted cursor scope, storage identity, and secure configuration contracts with explicit migration and mixed-version rules. Patch backports and an alternative minor release are outside this plan. The major-version target permits the necessary documented contract changes; it does not justify unrelated rewrites or gratuitous API breaks. + +Keep PHP 8.3 support unless a separate, justified compatibility decision changes it; add real PHP 8.3 coverage. A PHP floor increase is not required by these fixes. Preserve public named parameters and PSR interfaces wherever possible. + +Track Runwire 2.1 integration as an optional target for 4.0. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. An executable invalidation-worker example demonstrates this behavior if the workstream is selected for implementation. It is optional both as release scope and as a consumer dependency: deferring the entire workstream does not block 4.0.0. Retain PHP 8.3 support in the core. Any shipped integration requires a demonstrated need and the conditional gates below. + +This document follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and the applicable [PHPForge AGENTS.md workflow](../../vendor/infocyph/phpforge/resources/AGENTS.md). + +## Scope and evidence boundaries + +The review covered the 101 production PHP files by subsystem, the 32 test/support files, six benchmark files, public documentation, Composer configuration, and the CI wrapper. Areas reviewed include all adapter families, PSR-6/16 facade behavior, atomics, locks, serialization, counters, memoizers, Node Cache, Cluster Cache, invalidation transports, cursors, maintenance, and metrics. The existing graphify graph was used for orientation; findings were checked against current source and probes. + +The pre-existing `composer.json` edit changing `mongodb/mongodb` from `^1.20 || ^2.0` to `*` was preserved. No production code, test code, dependency versions, or release tags were changed by this audit. + +Evidence labels: + +- **Reproduced:** an isolated probe executed the library behavior locally. +- **Protocol reproduced:** the database behavior was reproduced with the SQL pattern used by the implementation; this is not a complete PHP integration test. +- **Source finding:** the code path is identified, but its affected production backend or race has not been exercised here. + +Security impact depends on the stated preconditions. This audit does not claim remote code execution without backend/filesystem access, a vulnerable application integration, or another specified trust-boundary violation. It is not a guarantee that every possible defect has been discovered. + +## Baseline checks + +| Check | Result and meaning | +| --- | --- | +| Normal host | PHP 8.5.4 CLI; Composer 2.10.3 | +| `composer ic:doctor` | Two warnings: missing `pdo_mysql` and `pdo_pgsql`; inherited runtime matrix 8.4/8.5 | +| `composer ic:list-config`, `composer ic:active-config` | Tool configuration resolves from installed PHPForge | +| `composer ic:tests:details` | **Failed overall** | +| Normal-host Pest within that run | **208 passed, 9 skipped, 645 assertions** | +| Skip-directive detector | **24 findings**: 8 PHPUnit and 16 Pest directives; these are separate from the nine runtime skips | +| Reference detector | **10 findings**: nine Cassandra-related references and one test-helper PSR-4 mismatch | +| Syntax, Pint, PHPCS, PHPStan, Psalm, Rector dry run, comment detector | Passed in the installed configuration | +| Duplicate detector | Passed its current gate, but reported **21 clone groups / 946 duplicated lines / 7.00%** | +| Deptrac | Zero violations, **677 uncovered dependencies**; passing this configuration does not establish meaningful package boundaries | +| `composer validate --strict`, `composer check-platform-reqs` | Passed; this does not prove optional backend prerequisites are available | +| `composer audit --locked --format=json` | Zero advisories; abandoned `doctrine/annotations` through development dependency `phpbench/phpbench` | +| `composer ic:release:constraints`, `composer ic:release:audit` | Passed; PHPForge reports abandonment as a non-blocking warning | +| Prepared Redis/Memcached/APCu test subset | **43 passed, 99 assertions**, using host PHP with `apc.enable_cli=1` and disposable services | +| Backend probes | Redis 8.10, Memcached 1.6.45, PostgreSQL 18 images already on the machine; all temporary containers stopped and removed | + +The prepared subset used: + +```sh +IC_REDIS_HOST=127.0.0.1 IC_REDIS_PORT= \ +IC_MEMCACHED_HOST=127.0.0.1 IC_MEMCACHED_PORT= \ +php -d apc.enable_cli=1 vendor/bin/pest \ + --configuration vendor/infocyph/phpforge/resources/pest.xml \ + --bootstrap vendor/autoload.php \ + tests/Cache/RedisCachePoolTest.php \ + tests/Cache/MemcachedCachePoolTest.php \ + tests/Cache/ApcuCachePoolTest.php +``` + +An initial direct Pest attempt without the bundled configuration failed with `Could not read XML from file "--cache-directory"`; the explicit configuration above resolved it. The sandbox could not start (`bubblewrap: mountinfo path is not absolute`), so approved host execution was used. These were tooling issues, not library test failures. + +No complete MySQL/MariaDB, MongoDB, Scylla CQL, Redis Cluster, Windows, PHP 8.3/8.4, clean production consumer, documentation build, sustained-RPM benchmark, or worker soak gate passed during this audit. `sphinx-build` was unavailable. PostgreSQL testing below exercised transaction ordering through `psql`, not the library's PDO driver. Current CI on a future final revision remains required. + +## Required findings + +P1 means fix before publishing the complete release because security, isolation, data integrity, or durable delivery is affected. P2 means a required correctness or reliability fix. These are engineering priorities, not CVSS scores. + +### R01 — P1: recursive values can terminate the worker + +**Reproduced.** `CachePayloadCodec::assertNativeValueSupported()` and `containsUnsupportedDecodedValue()` recurse through arrays without cycle detection or a traversal budget (`src/Cache/Adapter/CachePayloadCodec.php:114,144`). Native unserialization's depth limit does not prevent a shallow reference cycle from being traversed forever afterward. `CallableFingerprint::values()` has the same unbounded traversal pattern. + +A 156-byte native record containing a self-reference exhausted a 32 MB PHP process with `allowObjects=false`, `allowClosures=false`, and `maxPayloadBytes=1024`. Encoding the recursive value also exhausted memory. The decode precondition is a writable unsigned backend, or a trusted writer that can generate such data; integrity verification prevents an unsigned attacker from reaching decoding when signing is enabled. + +**Change:** make value traversal cycle-safe and bounded before recursive normalization. Preserve lossless supported values where practical; otherwise reject safely before process exhaustion. Apply the same invariant to fingerprint normalization. Keep byte limits and decompression limits; they solve different problems. + +**Acceptance:** isolated subprocess tests for direct/mutual references, very deep arrays, repeated references, and signed/unsigned/compressed records complete within explicit time and memory bounds. Ordinary nested data retains its exact type/value. No fatal error or unbounded output is acceptable. + +### R02 — P1: signatures do not bind values to their cache identity + +**Reproduced.** `CachePayloadCodec::attachSignature()` authenticates payload bytes, but the record contains neither the logical key nor the logical namespace (`CachePayloadCodec.php:77,133`; `AbstractCacheAdapter.php:153`). Copying Alice's signed SQLite payload onto Bob's row returned Alice's `['role' => 'admin']` under Bob's key while `hasPayloadIntegrity()` returned true. + +The attack requires backend write access and access to a valid payload signed with the same secret. This is payload substitution, not HMAC forgery. Signing also does not prevent replay of an older valid value under the same key. + +**Change:** introduce a versioned authenticated envelope bound to an unambiguous logical namespace/key and format purpose. Validate identity before value deserialization. Use logical identity that survives legitimate tier promotion. Define the authentication-state contract separately from ordinary cache integrity; do not imply replay resistance from HMAC alone. + +**Acceptance:** cross-key, cross-namespace, and cross-purpose copies are rejected; legitimate same-key tier promotion works. Wrong-key, unsigned, malformed, compressed, expired, and old-format cases have explicit behavior. Legacy unbound signed payloads must not silently satisfy the new bound-integrity contract. Document cold-cache migration and coordinated rollout. + +### R03 — P1: adapter reuse can silently replace a facade's security policy + +**Reproduced.** `AbstractCacheAdapter::configureOptions()` freezes policy only once the codec exists (`:50`). Constructing a strict, signed facade and then another facade over the same unused adapter replaces its options. The first facade still advertises payload integrity and accepts objects despite `allowObjects=false` in its own options. + +**Change:** establish immutable effective policy at initial binding, or reject conflicting adapter reuse before either facade is operational. Propagate this rule through tiered and Node adapters. Ensure capabilities report the policy actually enforced by storage; review weak-reference paths that do not instantiate the codec. + +**Acceptance:** two facades sharing an adapter cannot disagree about integrity, serialization policy, or backend behavior. Equal configurations remain usable if intentionally supported. Test configuration before first use and after scalar/object/weak-reference operations. + +### R04 — P1: file consume returns the value even when deletion fails + +**Reproduced for File; same code defect in PHP-files.** Both `atomicGetAndDelete()` methods ignore `deleteItemUnlocked()`'s boolean result (`FileCacheAdapter.php:51`; `PhpFilesCacheAdapter.php:50`). With a readable but non-writable data directory, two consumes returned `"usable"`, including with `failOpen=false`. + +**Change:** return a consumed value only after successful deletion while holding the key lock. Report backend failure through the configured policy. Audit other atomic mutations for unchecked write/delete results and incomplete writes. + +**Acceptance:** filesystem permission failure, failed unlink, disk-full/partial writes, and competing processes cannot yield multiple successful consumptions. Strict mode throws the backend exception; fail-open returns the defined miss/failure result, never an unconsumed value. Cover File and PHP-files independently. + +### R05 — P1: Node SQLite rolls back caller-owned transactions + +**Reproduced.** `NodeSqliteCacheAdapter::saveMany()` unconditionally starts a transaction and catches the already-active-transaction error by calling `rollBack()` (`:302`). The rollback helper rolls back any active transaction. A business insert made before `saveItems()` disappeared and `inTransaction()` became false. `deleteItems()` also rolls back on error without establishing ownership. + +**Change:** track transaction ownership explicitly. Prefer rejecting a caller-owned transaction before mutation, consistently with `PdoAtomicOperations`, unless savepoint participation has a concrete contract. Never commit or roll back work owned by the caller. + +**Acceptance:** a failed cache operation preserves the caller's transaction and business rows; independently owned cache transactions commit or roll back correctly. Include errors during batch preparation, execution, deletion, and commit. + +### R06 — P1: SQL outbox consumers can permanently miss late commits + +**Protocol reproduced on PostgreSQL.** `PdoInvalidationSchema` allocates `BIGSERIAL`/auto-increment IDs on insert. `PdoInvalidationTransport::consumeSql()` subsequently selects only `event_id > cursor` (`:149`). Transaction A allocated ID 1 and remained open; B allocated and committed ID 2; the consumer observed 2 and advanced; A committed, and the next query could never return 1. + +**Change:** make the publication/cursor protocol safe under commit reordering. Choose and document a concrete protocol: for example, per-cluster transactional serialization before allocation, or committed outbox relay into an ordered delivery log. Compare lock duration, crash recovery, idempotency, and sustained throughput before selecting. A larger integer, a fixed delay, or a fixed overlap window does not establish correctness. + +**Acceptance:** real PostgreSQL and MySQL tests with at least two independent connections cover reversed commit order, rollback, long-running transactions, process death, duplicate replay, retention, and consumer restart. No committed invalidation may be skipped. Provide schema, rollout, rollback, and mixed-version rules. + +### R07 — P1: cursor storage collides across namespaces on one node + +**Reproduced.** `SqliteCursorStore` keys cursors by `(cluster_name, node_id)` only (`:71,96`), while `InvalidationHandler` applies a single namespace. Two runtimes using the same SQLite file, node ID, and cluster but namespaces A/B produced `[A consumed=1, B consumed=0, B value="stale"]` for an invalidation addressed to B. + +**Change:** include the complete consumption scope in cursor identity, or use one consumer that handles every namespace before advancing a shared cursor. Document whether multiple concurrent consumers may share a scope and enforce the chosen model. Include recovery and administrative skip operations in that scope. + +**Acceptance:** independent namespaces, nodes, clusters, and transport identities cannot advance each other's progress. Test migration of existing cursor rows without skipping invalidations, concurrent consume, reset, restart, and lost/truncated history. + +### R08 — P1: memoizer identities can return another input's result + +**Reproduced.** `CallableFingerprint::closure()` hashes file/line/captures, which collides for distinct closures on the same source line (`:63`). Two functions returning A/B produced A/A. `value()` converts objects into ordinary strings (`:52`); an object and the matching literal string both returned the object's result. `OnceMemoizer` retains `spl_object_id()` in cache keys after the object dies (`:50,65`); a new object reusing the ID returned the previous object's `"alice"` instead of `"bob"`. + +**Change:** make normalized values explicitly type-tagged and closure/caller identity unambiguous. Use lifetime-safe weak identity tracking where object identity is intended. Retain a documented useful call-site contract for `once()`; do not accidentally turn it into a never-hitting cache. Bound normalization and retained state, and preserve `flush_memoizers()` as the request-boundary reset. + +**Acceptance:** same-line closures, object/string/resource lookalikes, reused object IDs, bound and static callbacks, reference captures, cyclic input, null results, and worker request resets remain isolated. Test collecting owner objects without retaining their results unintentionally. Security impact depends on applications memoizing user/tenant-dependent data. + +### R09 — P1: nested symlinks bypass filesystem hardening + +**Reproduced.** The File/PHP-files constructors inspect the base and `data`, `meta`, `locks` directories, but not the intervening `cache_` component (`FileCacheAdapter.php:202`; `PhpFilesCacheAdapter.php:202`). Pre-creating that component as a symlink allowed PHP-files construction and a write into its target. + +**Change:** verify each relevant path component and the intended ownership/trust boundary before reading or creating executable cache files. Account for trailing separators, existing files, SQLite targets, and lock/token paths. Reuse the existing filesystem helper where ownership is shared. Keep private trusted roots as a deployment requirement; do not claim portable PHP checks eliminate every filesystem race. + +**Acceptance:** final-component and ancestor symlinks, hostile pre-created namespace roots, permission changes, and ordinary legitimate private directories behave predictably. Test Linux and Windows/reparse behavior where supported. Document that PHP-files executes the file before payload HMAC validation and therefore requires a trusted executable-cache directory. The exploit precondition is control of a relevant filesystem path. + +### R10 — P2: Redis DSN errors disclose credentials + +**Reproduced.** `RedisCacheAdapter::connect()` (`:387`) and `AtomicCounters::connect()` (`:33`) embed the original DSN in exceptions. An invalid database path with synthetic credentials emitted `redis://user:audit-password@localhost/bad`. + +**Change:** use sanitized messages and redact secret-bearing public/constructor parameters with `#[SensitiveParameter]` where applicable. Audit passwords, integrity keys, signed-closure keys, MongoDB URIs, and nested previous exceptions. An attribute does not sanitize a message that already contains the secret. + +**Acceptance:** sentinel secrets never appear in error messages, exception chains, or rendered traces with argument capture enabled. Invalid scheme, port, database, and authentication failures remain diagnosable without leaking credentials. + +### R11 — P2: deferred writes violate read/delete/update ordering + +**Reproduced.** `AbstractCacheAdapter::saveDeferred()` stores an object in `$deferred`, but normal reads do not consult it and key deletions/immediate saves do not reconcile it (`:108`, plus adapter mutations). A deferred read returned a miss; delete followed by commit resurrected `"old"`; an immediate `"new"` save followed by commit restored `"old"`. + +**Change:** define one coherent deferred-state lifecycle across facade and direct PSR-6 pools. Reads must see pending state; deletion and later writes must supersede it; clear and failed/partial commit must preserve documented semantics. Decide snapshot behavior for caller mutation after queueing. Ensure pending entries eventually persist under the PSR-6 contract, while documenting crash durability limits. + +**Acceptance:** a common backend contract suite tests pending read/has/getItems, overwrite/delete/clear/commit ordering, expiration, null values, item mutation, failed commit and retry, and finalization. Preserve ownership validation for foreign items. + +### R12 — P2: numeric-string keys and tags break internal maps + +**Reproduced.** Public validation accepts `"123"`, but PHP converts numeric string array keys to integers. A numeric tag returned `setTagged=true` followed by a miss; tiered single-key get returned the value while `getMultiple(['123'])` returned a miss. Relevant owners are `Cache::setMultiple()`, `CacheTagSnapshots`, tag encoding/normalization, `TieredCacheAdapter::multiFetch()/saveIntoPool()`, and Node batch copying. + +**Change:** keep logical key/tag strings intact through mapping and batching. Use item keys or explicitly reversible internal encodings; do not tighten public validation to exclude previously valid PSR keys merely to avoid the problem. + +**Acceptance:** `0`, `123`, `-1`, `01`, 64-character keys, numeric tags, generators, multi-key operations, deferred writes, tier promotion, and atomic operations preserve supported semantics across adapters. + +### R13 — P2: skipping L1 write-through retains stale L1 values + +**Reproduced.** In `TieredCacheAdapter::save()/writeBatch()` (`:157,242`), `writeToL1=false` skips writing L1 but does not invalidate an already-promoted value. Write old, read/promote, write new, read returned old. + +**Change:** invalidate affected upper-tier entries when write-through is disabled. Handle partial tier failures and failed promotions without presenting stale values as authoritative. Apply to single, batch, and deferred writes. + +**Acceptance:** after a successful update, earlier promoted values cannot win a later read. Cover nulls, TTL changes, deletes, failed upper-tier invalidation, and concurrent promotion. + +### R14 — P1/P2: Redis counters are cleared with ordinary cache data and lose precision + +**Reproduced.** `RedisCacheAdapter::clear()` scans `:*` (`:183`), deleting `AtomicCounters` keys stored under `:counter:*`. A cache clear erased an existing counter. This can reset security-relevant rate-limit state when the same namespace is used. The Lua increment script returns an integer through Lua's numeric representation (`RedisAtomicCounterStore.php:13`): increment by `9007199254740993` returned `9007199254740992` while Redis stored the correct value. `get()` also saturated an out-of-range numeric string to `PHP_INT_MAX`. + +**Change:** separate ordinary cache clearing from the counter keyspace; return the exact decimal string from inside the same atomic Lua operation and validate its PHP integer range before conversion. Preserve first-creation TTL and overflow failure behavior. + +**Acceptance:** cache clear does not reset counters; boundaries around 2^53 and PHP integer limits are exact; malformed/out-of-range stored values fail safely. Concurrent initialization, decrement, expiration, and injected-client options are covered on Redis and Valkey. No outside-script GET may introduce a race into the returned increment result. + +### R15 — P2: Node APCu identity omits the SQLite store + +**Reproduced with CLI APCu enabled.** `NodeCache::createApcuAdapter()` uses only the namespace (`:56`). Two Node Cache instances with different SQLite files and the same namespace returned each other's L1 value. Documentation describes the namespace as a logical cache within the selected database. + +**Change:** scope APCu and coordination identity to the logical node store plus namespace, with a stable documented identity across intended workers. Expose the same effective namespace to facade locking. Account for independently running CLI/FPM/worker APCu domains when applying cluster invalidation; one CLI consumer does not automatically clear another SAPI's L1. + +Related source finding: `Cache` excludes only Tiered and Null adapters when calculating `isAuthoritative()`, so Node's L1/L2 adapter is currently classified as authoritative. The built-in Node factory does not enable signing, which prevents it from satisfying the complete documented authentication-state gate by default. Nevertheless, capability reporting must classify L1-backed/custom configurations honestly rather than relying on a different default option to make them ineligible. + +**Acceptance:** different stores remain isolated, intentionally shared stores coordinate correctly, and invalidation reaches every supported L1 domain. Test both APCu-enabled and disabled configurations and cross-process/SAPI topology. If a topology cannot maintain coherence, reject or explicitly constrain it rather than claiming node-wide invalidation. + +### R16 — P2: Memcached mishandles TTLs longer than 30 days + +**Reproduced.** `save()`, `saveItems()`, and atomic write paths pass relative seconds directly to Memcached. Setting a 31-day TTL returned true and immediately read as a miss. + +**Change:** centralize conversion of relative TTL to Memcached expiration semantics for ordinary, bulk, atomic, and lease paths. Preserve zero/forever and expired/no-op contracts and guard timestamp overflow. + +**Acceptance:** zero, one second, exactly 30 days, 30 days plus one second, 31 days, DateInterval, and absolute-date inputs have equivalent logical behavior in all applicable APIs. + +### R17 — P2: direct PSR-6 validation and APCu bulk delete are inconsistent + +**Reproduced.** Direct `ArrayCacheAdapter::getItem('invalid:key')` accepted a reserved PSR character, despite adapters being documented as directly usable PSR-6 pools. `Cache::apcu()->delete('absent')` returned true while `deleteMultiple(['absent'])` returned false (`ApcuCacheAdapter.php:66`). + +**Change:** validate every public PSR boundary consistently, preserving efficient internal already-validated paths. Normalize missing-key deletion success for bulk adapters, while distinguishing genuine backend failures. Include expired-item `get()/isHit()` consistency in the contract review. + +**Acceptance:** direct pools and facade pass a common PSR-6/PSR-16 interoperability matrix, including invalid keys, missing deletes, null values, expiration and deferred state. Use an independent standards test/consumer where practical. + +### R18 — P1, conditional source finding: SQL collation can collapse cache identities + +`PdoCacheSchema::install()` uses `VARCHAR(191)` identity columns on MySQL/MariaDB without an explicit case-sensitive/binary collation (`:21`). On a database whose default collation is case-insensitive, distinct supported namespaces/keys differing by case can compare equal. The invalidation schema's cluster/namespace/node columns also inherit database collation. This was not reproduced against MySQL in this audit. + +**Change:** use explicit byte-sensitive identity semantics appropriate to supported engines, and provide a migration for existing tables. Audit existing duplicate/collapsed identities before migration; changing only table-creation SQL leaves deployed tables unchanged. + +**Acceptance:** real MySQL/MariaDB with a case-insensitive default keeps `Tenant`/`tenant` and `Key`/`key` separate through set/get/bulk/delete/clear/tag/atomic/cluster paths. PostgreSQL and SQLite behavior remains consistent. Record the collation used by each test database. + +### R19 — P1 release blocker: verification does not cover the advertised contracts + +**Observed.** The baseline quality suite fails, the minimum declared PHP runtime is absent from the inherited matrix, and important backend tests rely on fakes or skipped prerequisites. Selecting ScyllaDB's Alternator service does not test this library's CQL adapter. MongoDB is absent from the workflow service list. Redis Cluster tests using a fake do not prove hash-slot behavior. The inherited Deptrac report leaves 677 dependencies uncovered. Benchmark code primarily measures component operations; some named hit benchmarks also include constructing/filling the cache. + +**Change:** repair test/helper structure and provision real backend prerequisites. Resolve Cassandra references with a verified driver/stub strategy that matches supported runtime APIs. Remove skip directives by arranging explicit applicable suites/jobs and hard prerequisite checks, not by disguising skips as returns or passing assertions. Add the full support matrix and meaningful architecture rules. Keep local host and prepared-service reports separate. + +**Acceptance:** every required detector runs with its intended scope and thresholds; no new suppressions, exclusions, expanded baselines, raised limits, or weakened assertions. Maintain complexity limits `function=12`, `class=80`, `dependency_tree=120`. A green gate must mean its intended behavior was actually exercised. + +## Implementation batches + +All checkboxes below are open. Each batch is a separately reviewable change with failing regression evidence first, the smallest correct implementation, and focused verification before the full release gate. + +1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** + - [ ] Add bounded adversarial subprocess and filesystem/transaction tests. + - [ ] Correct traversal, policy binding, deletion-result handling, transaction ownership, directory checks, and secret redaction in their existing owners. + - [ ] Deliver containment fixes in 4.0.0 and document any changed failure behavior; coordinate identity and counter fixes with their batches below. +2. **Authenticated storage and identity — R02, R15, R18.** + - [ ] Specify the new envelope and logical store/key identity, then test it across all codec-using backends. + - [ ] Bind Node L1/lock identity to its intended store; migrate SQL identity collation. + - [ ] Add per-node serialization/integrity options if needed; new optional parameters must retain existing parameter names. + - [ ] For 4.0, make executable/object deserialization an explicit policy choice and document the default. Include the changed default in the 3.x-to-4.0 migration guide. +3. **Durable invalidation — R06, R07 and R15 topology.** + - [ ] Choose a commit-safe publication protocol with a written failure-state model and measured contention cost. + - [ ] Scope cursors correctly, migrate stored progress, and handle empty/reset transport history conservatively. + - [ ] Test real multi-connection delivery, retention, crash recovery, poison events, administrative skips, and SAPI/L1 coherence. +4. **Cache contracts and memoization — R08, R11, R12, R13, R16, R17.** + - [ ] Add reusable cross-backend behavioral tests for keys, values, deferred operations, expiration, tagging, promotion, and atomic outcomes. + - [ ] Fix memoizer identity/lifecycle and test persistent workers with request resets. + - [ ] Correct numeric maps, upper-tier invalidation, Memcached expiration, and direct PSR pool boundaries. +5. **Counter and backend failure contracts — R14 plus targeted race review.** + - [ ] Isolate counter clearing and make integer handling exact. + - [ ] Audit stale-read cleanup so deleting an observed stale value cannot erase a concurrent replacement; use compare-delete or leave cleanup to bounded maintenance where appropriate. + - [ ] Audit tag initialization races, clear versus write/consume, lease loss, partial bulk failure, and Redis/Memcached false/error status handling. These races need deterministic interleaving tests; source inspection alone is not a completed gate. +6. **Tooling, documentation, and release verification — R19.** + - [ ] Resolve the recorded skip/reference findings and make architecture boundaries meaningful. + - [ ] Review clone groups and centralize genuinely shared invariants in existing owners; keep backend-specific atomic protocols explicit. Do not perform a broad inheritance rewrite or consolidate only to reduce file count. + - [ ] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. + - [ ] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. + +7. **Optional Runwire 2.1 integration — candidate scope, not a 4.0 release blocker.** + - [ ] If this workstream is selected, implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. + - [ ] Evaluate bounded maintenance scheduling and persistent-request lifecycle integration; implement where an actual consumer or the example establishes a concrete need. + - [ ] Evaluate Runwire for isolated crash/concurrency regression tests during earlier batches without making it a core dependency. + - [ ] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. + +## Optional Runwire 2.1 integration workstream + +### Scope and dependency decision + +The assessment inspected local Runwire tag `2.1` (`e6a954df1ec90aef98daf8248bd02f741f9324e3`). This establishes available APIs and platform requirements, not successful CacheLayer integration or a measured throughput benefit. Worker supervision, structured coroutines, and lifecycle support already existed before 2.1; the principal 2.1 additions concern adaptive HTTP scheduling. + +Runwire requires 64-bit PHP 8.4+, while CacheLayer supports PHP 8.3+. If selected, implement the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require` or make the default PHP 8.3 development/test installation require it. Add a Composer suggestion only when usable integration documentation exists. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the example; do not introduce a generic runtime abstraction speculatively. + +The core must remain usable without Runwire installed, including ordinary PHP-FPM execution. No supervisor, listener, timer, connection, or worker may start during autoload or cache construction. A host that already owns its process pool retains that ownership. The 4.0 major-version decision does not change these dependency and runtime boundaries. + +### Automatic capability selection and normal fallback + +**Execution contract:** Runwire loaded as the active runtime → use the relevant supported capability; Runwire absent, inactive, or lacking that capability → use the existing normal path. Callers keep the same CacheLayer APIs and do not select a Runwire-specific cache backend or enable each capability manually. Installation or an autoloadable Runwire class alone does not establish an active runtime, event loop, or request scope. + +Use the active runtime's public context and supported lifecycle hooks. Runwire 2.1 exposes `RuntimeContext` and explicit coroutine scopes; do not assume a process-global current-runtime/current-scope lookup exists. Where context must be supplied by the hosting application, bind it once at the runtime bootstrap boundary and attach the actual request/task scope at its lifecycle boundary. Capability selection within CacheLayer is automatic after that binding. The executable example must make this wiring concrete rather than promise unsupported discovery. + +- Use lifecycle cleanup and isolated request/task state when the active host provides them. In concurrent execution, preserve request isolation; never silently substitute a process-global memoizer or global reset for unavailable request-local state. A safe normal path may bypass request memoization when isolation cannot be established. +- Use cooperative timer waits for existing retry/poll intervals only inside an appropriate active scope, preserving timeout, cancellation, lease, and atomicity contracts. Elsewhere retain the normal bounded wait path. Backend calls remain synchronous unless their actual client integration supports cooperative I/O. +- Schedule already-configured invalidation or maintenance work on the runtime's available worker/loop lifecycle where ownership and blocking behavior permit. Do not create new background jobs merely because Runwire is present. Without those capabilities, keep the existing explicit consume/maintenance execution path. +- Resolve stable capabilities at worker bootstrap; resolve request/task ownership at the current scope. Refresh bindings after fork, worker replacement, or runtime shutdown. Do not retain one request's scope in a process-global cache or repeatedly scan/reflection-probe dependencies on every cache hit. +- Fall back when a capability is unavailable before starting an operation. Do not catch operational failure or cancellation and replay a potentially completed mutation through the normal path. Preserve error policy, one-time consumption, distributed locks, payload integrity, TTL, and cursor semantics across both paths. + +### Planned uses and prerequisites + +1. **Supervised cluster invalidation — first deliverable if selected.** Wrap existing `ClusterRuntime::consume()` calls in bounded scheduled work with explicit batch size, polling interval, backend timeouts, retry/backoff, and shutdown budgets. Preserve serial consumption within each complete cursor scope; independent scopes may run independently. Create backend connections in worker bootstrap after a fork. Expose consumed counts, failures, consumer lag, and restart behavior without unbounded metric labels. Resolve R06/R07 before relying on durable progress and R15 before claiming node-wide L1 coherence. A separate CLI consumer must not be described as clearing unrelated FPM/worker APCu domains automatically. +2. **Bounded maintenance — evaluate for inclusion.** Schedule existing `NodeCacheMaintenance::pruneExpired()`, `checkpoint()`, and `optimize()` at explicit operational intervals. Bound prune batches and prevent overlapping maintenance against the same store. Measure SQLite writer contention and choose heavier maintenance windows accordingly. Do not place full scans or maintenance on the request hot path. Supervision does not make an individual blocking database operation cancellable. +3. **Persistent request lifecycle — conditional on the host integration.** After R08 and the relevant deferred-state fixes, connect request-owned memoizer/state cleanup to Runwire's completion/reset lifecycle, including failure, cancellation, and deadline paths. `flush_memoizers()` is suitable only for a sequential lifecycle with an explicit ownership contract. Concurrent requests need isolated memoizer state, potentially through Runwire task-local context or an explicit request-owned instance; one request must not flush or observe another request's state. Preserve intentional cross-request cache data and resolve pending deferred writes under their documented contract. +4. **Crash and concurrency verification — usable during earlier batches.** Evaluate Runwire's bounded subprocess runner for recursive-payload probes and its worker supervision for real restart/concurrency tests. Set explicit PHP memory, execution-time, and output limits; execute validated argument vectors. `ProcessRunner` is synchronous and is not itself a parallel worker pool or OS sandbox. Retain a lightweight existing subprocess harness if adopting Runwire adds complexity without improving evidence. PHP 8.3 core regression coverage must remain available independently. + +Existing PDO, filesystem, and synchronous native-client calls remain blocking inside Runwire coroutines. Prefer existing backend bulk operations and bounded dedicated workers where suitable. Do not wrap each cache operation in a coroutine or process and claim asynchronous I/O or a speedup. Runwire's in-process coroutine synchronization also does not replace CacheLayer's cross-process/distributed lock and atomicity contracts. + +### Integration acceptance gates + +The entire Runwire workstream, including the invalidation-worker example, may be deferred without blocking 4.0.0. Record an inclusion/defer decision based on concrete need and evidence; the checkboxes in this section apply only to capabilities selected for shipping. Every shipped capability must pass its applicable gates; deferred capabilities must remain explicitly unadvertised. None of these decisions excuses any R01–R19 requirement. + +- [ ] Keep a clean PHP 8.3 consumer and the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. +- [ ] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. +- [ ] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. +- [ ] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. +- [ ] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. +- [ ] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. +- [ ] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. +- [ ] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. +- [ ] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. +- [ ] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. + +## Improvements that require measurement or a separate scope decision + +These are not substitutes for the required fixes: + +- Bound large Node SQLite read/delete/tag parameter lists and remote bulk payloads using verified backend limits. Test above the configured SQLite variable limit instead of assuming one universal limit. +- Bound Scylla's prepared-statement cache: variable-sized `IN`/batch shapes can grow its per-instance map in persistent workers. Choose a fixed chunking strategy or measured bounded cache. +- Add appropriate bounded expiry maintenance for file/PHP-files, in-memory stores, and MongoDB where physical retention can outlive logical expiration. Avoid request-path full scans. Never casually delete live lock files: replacing a locked inode can split the lock domain. +- Separate fixture construction/cold loading from warm cache hit measurements. Benchmark signed/plain/compressed records, batches, tagged hits, miss/fill, tier promotion, and contention. +- Measure default metrics overhead before adding a no-op option; keep observability hooks cheap. Do not claim a performance improvement from code shape alone. +- Review the moving PHPForge workflow reference and development dependency constraints for reproducible releases. Pin a reviewed workflow revision and record the resolved toolchain where compatible with project policy. Do not silently revert the user's MongoDB constraint edit. +- Track `doctrine/annotations` abandonment through PHPBench upstream. It is a development-maintenance warning, not a demonstrated runtime vulnerability; do not delete working benchmarks just to remove the warning. + +## Release acceptance + +### Correctness and security + +- [ ] Every R01–R19 item is resolved with targeted evidence or, for a suspected source finding, disproved with a documented test on the actual affected backend. +- [ ] Run real PHP 8.3, 8.4, and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. +- [ ] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. +- [ ] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. +- [ ] Verify executable-file and ordinary-file behavior with OPcache enabled/disabled, Linux permissions, and Windows where supported. Test failure paths without granting the cache process excess permissions. +- [ ] Run an independent PSR consumer/contract check. Keep cache/authentication-state topology and integrity/replay guarantees explicit. + +### Performance and worker stability + +- [ ] Before hot-path changes, record a reproducible baseline on production-equivalent hardware; correctness fixes remain required even if they add necessary work. +- [ ] Measure both component operations and representative host-application **successful RPM**. Do not convert a PHPBench microbenchmark into an application-throughput claim. +- [ ] Cover cold/warm initialization, hits/misses/fill, invalid/tampered inputs, signed/compressed payloads, bulk/tagged reads, atomic/counter contention, and invalidation consumption at several concurrency levels. +- [ ] Use at least three warmed steady-state runs per important workload; compare median sustained successful RPM and variance. Record RPS/RPM, duration, counts, errors/timeouts, validation failures, p50/p95/p99, CPU, memory, queue/consumer lag, connections, cache hit rate, and backend calls where relevant. +- [ ] Set workload-specific latency, memory, connection, and throughput budgets from that baseline before accepting optimizations. A provisional 2% RPM regression budget may be used only in a matching stable environment with noise below the decision threshold; define exact capacity limits in the recorded benchmark configuration. +- [ ] Run persistent-worker soak tests with changing tenants, collected/reused objects, request resets, cache churn, and dependency failures. Require bounded memory and lag and no stale identity reuse. + +### Tooling and packaging + +```sh +composer ic:doctor +composer ic:list-config +composer ic:active-config +# During implementation, not during this read-only audit: +composer ic:process +composer ic:tests:details +composer ic:release:guard +git diff --check +``` + +- [ ] Keep source-mutating processors sequential and review their diff. Parallelize only independent read-only checks with bounded concurrency. +- [ ] Build documentation with warnings as errors and test the examples relevant to changed public contracts. +- [ ] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. +- [ ] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. +- [ ] Require all configured CI checks on the **exact final commit**, including stable/lowest jobs, before creating a release tag. Historical CI does not validate later edits. + +### Migration and rollback + +- [ ] Publish a consolidated 3.x-to-4.0 upgrade guide covering changed defaults, public behavior, payload/storage formats, cursor migration, and optional Runwire requirements. Record all intentional breaks in the 4.0.0 release notes. +- [ ] Version and publish the authenticated record and cursor/schema changes. Preserve a clear distinction between disposable cache values and durable security/cursor state. +- [ ] Use separate namespaces/storage versions or a coordinated cutover where old/new readers cannot safely coexist. In the new integrity mode, do not silently accept unbound legacy records for compatibility. +- [ ] For cursor migration, clear/reconcile the affected local cache and establish a safe replay position; do not merely copy a shared cursor into several scopes and assume it proves delivery. +- [ ] Migrate SQL collations with explicit old-data inspection and rollback instructions. Preserve counters and authoritative replay/authorization state; do not treat deleting that state as ordinary cache cleanup. +- [ ] Rehearse rollback by activating the complete previous release and its compatible storage configuration. Record immutable commit/tag, PHP/extensions, tool versions, schema versions, and deployment assumptions. + +## Reproduction notes + +These short examples use an isolated test process. Do not run the deliberate exhaustion or filesystem fault probes in an application worker. + +```php +// Deferred deletion is undone by commit on the audited revision. +$cache = \Infocyph\CacheLayer\Cache\Cache::memory(); +$cache->saveDeferred($cache->getItem('x')->set('old')); +$cache->delete('x'); +$cache->commit(); +assert($cache->get('x') === 'old'); // Observed defect; fixed expectation is a miss. + +// A recursive input is tiny; a payload-byte limit cannot bound traversal. +$value = []; +$value['self'] = &$value; +$blob = 'cl2:' . serialize([ + 'format' => 2, 'encoding' => 'native', 'value' => $value, + 'expires' => null, 'tags' => [], 'namespace' => null, +]); +// Decode only in a subprocess with an OS timeout and PHP memory limit. + +// Distinct closures on one line collide in the audited memoizer. +$a = static fn() => 'A'; $b = static fn() => 'B'; +$memo = \Infocyph\CacheLayer\Memoize\Memoizer::instance(); +$memo->flush(); +assert([$memo->get($a), $memo->get($b)] === ['A', 'A']); +``` + +Local audit artifacts, useful while this workspace session remains available: + +- `/tmp/cachelayer-audit-quality.log`, `/tmp/cachelayer-audit-doctor.log` +- `/tmp/cachelayer-audit-advisories.json`, `/tmp/cachelayer-audit-release-audit.log` +- `/tmp/cachelayer-audit-integration.log` +- `/tmp/cachelayer-audit-probes.php`, `/tmp/cachelayer-audit-cycles.php` +- `/tmp/cachelayer-audit-network.php`, `/tmp/cachelayer-audit-pg.py` +- `/tmp/cachelayer-audit-scope.php`, `/tmp/cachelayer-audit-file-consume.php` + +The temporary network probe uses the audit's allocated ports; recreate disposable services and update ports before reuse. These artifacts are not permanent regression tests. Promote the relevant cases into the existing test layout during implementation. + +## Primary references + +- [PHP-FIG PSR-6](https://www.php-fig.org/psr/psr-6/) establishes deferred-read visibility, supported keys, miss behavior, and deletion semantics underlying R11/R17. +- [PHP unserialize documentation](https://www.php.net/manual/en/function.unserialize.php) describes deserialization risks and options; limits on unserialization do not bound subsequent application traversal in R01. +- [PostgreSQL transaction isolation](https://www.postgresql.org/docs/current/transaction-iso.html) explains committed-row visibility and sequence behavior. R06's delivery failure is an inference from those semantics plus the library query, independently reproduced with two transactions. +- [Redis Lua API conversion rules](https://redis.io/docs/latest/develop/programmability/lua-api/) explain numeric reply conversion relevant to the reproduced R14 precision failure. +- [PHP Memcached expiration rules](https://www.php.net/manual/en/memcached.expiration.php) specify the 30-day relative/absolute cutoff underlying R16. +- [PHP object ID lifetime](https://www.php.net/manual/en/function.spl-object-id.php) documents ID reuse after destruction, relevant to R08. +- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. That backend-specific finding still requires the listed integration test. From 002e8017856941a83434e050a30a8fff9d4095d0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 14:52:27 +0600 Subject: [PATCH 002/434] test(cache): reproduce batch 1 containment failures --- .../SecurityContainmentRegressionTest.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/Cache/SecurityContainmentRegressionTest.php diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php new file mode 100644 index 00000000..ed45a803 --- /dev/null +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -0,0 +1,108 @@ + new Cache( + $adapter, + options: new CacheOptions(allowObjects: true, integrityKey: 'second-secret'), + ))->toThrow(LogicException::class); + + expect(fn() => new Cache( + $adapter, + options: new CacheOptions(allowObjects: false, integrityKey: 'first-secret'), + ))->not->toThrow(LogicException::class); +}); + +test('node SQLite cache preserves caller-owned transactions', function () { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('CREATE TABLE business_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + + $adapter = new NodeSqliteCacheAdapter($pdo, 'node-transaction'); + $item = $adapter->createItem('cache-key')->set('cache-value'); + + $pdo->beginTransaction(); + $pdo->exec("INSERT INTO business_rows (value) VALUES ('business-value')"); + + expect(fn() => $adapter->saveMany([$item])) + ->toThrow(NodeCacheStorageException::class) + ->and($pdo->inTransaction())->toBeTrue() + ->and((int) $pdo->query('SELECT COUNT(*) FROM business_rows')->fetchColumn())->toBe(1); + + $pdo->rollBack(); +}); + +test('filesystem adapters reject a symlinked namespace root', function () { + foreach ([ + 'file' => static fn(string $namespace, string $base): Cache => Cache::file($namespace, $base), + 'php-files' => static fn(string $namespace, string $base): Cache => Cache::phpFiles($namespace, $base), + ] as $label => $factory) { + $base = sys_get_temp_dir() . '/cachelayer-symlink-' . $label . '-' . bin2hex(random_bytes(4)); + $target = sys_get_temp_dir() . '/cachelayer-symlink-target-' . $label . '-' . bin2hex(random_bytes(4)); + mkdir($base, 0700, true); + mkdir($target, 0700, true); + $link = $base . DIRECTORY_SEPARATOR . 'cache_attacker'; + expect(symlink($target, $link))->toBeTrue(); + + try { + expect(fn() => $factory('attacker', $base))->toThrow(RuntimeException::class); + } finally { + if (is_link($link)) { + unlink($link); + } + if (is_dir($target)) { + rmdir($target); + } + if (is_dir($base)) { + rmdir($base); + } + } + } +}); + +test('Redis DSN errors never echo credentials', function () { + $secret = 'audit-password'; + $dsn = 'redis://user:' . $secret . '@localhost/not-a-database'; + + try { + AtomicCounters::redis('secret-test', $dsn); + test()->fail('Expected invalid Redis DSN.'); + } catch (Throwable $failure) { + expect($failure->getMessage())->not->toContain($secret) + ->and($failure->getMessage())->not->toContain($dsn); + } +}); + +test('secret-bearing public parameters are marked sensitive', function () { + $parameters = [ + [CacheOptions::class, '__construct', 'integrityKey'], + [Cache::class, 'redis', 'dsn'], + [Cache::class, 'valkey', 'dsn'], + [Cache::class, 'mongodb', 'uri'], + [Cache::class, 'pdo', 'dsn'], + [Cache::class, 'pdo', 'password'], + [PdoCacheAdapter::class, '__construct', 'dsn'], + [PdoCacheAdapter::class, '__construct', 'password'], + [AtomicCounters::class, 'redis', 'dsn'], + [AtomicCounters::class, 'valkey', 'dsn'], + [SignedClosureSerializer::class, '__construct', 'key'], + ]; + + foreach ($parameters as [$class, $method, $parameter]) { + $reflection = new ReflectionParameter([$class, $method], $parameter); + expect($reflection->getAttributes(SensitiveParameter::class))->not->toBeEmpty(); + } +}); From 83b7a6f59779e1e0e9aca3df3a5bdeeb4e94f752 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 14:58:41 +0600 Subject: [PATCH 003/434] test(cache): cover recursive values and failed atomic consume --- .../SecurityContainmentRegressionTest.php | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php index ed45a803..ccf15b8e 100644 --- a/tests/Cache/SecurityContainmentRegressionTest.php +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -106,3 +106,146 @@ expect($reflection->getAttributes(SensitiveParameter::class))->not->toBeEmpty(); } }); + + +test('recursive payloads fail within bounded subprocess resources', function () { + if (!function_exists('proc_open')) { + test()->markTestSkipped('proc_open is required for the bounded recursion regression.'); + } + + $autoload = realpath(__DIR__ . '/../../vendor/autoload.php'); + expect($autoload)->not->toBeFalse(); + + $script = tempnam(sys_get_temp_dir(), 'cachelayer-recursion-'); + expect($script)->not->toBeFalse(); + + $code = <<<'PHP' +encode($value, null); + exit(10); + } catch (InvalidArgumentException) { + } +} + +$record = [ + 'format' => 2, + 'encoding' => 'native', + 'value' => &$direct, + 'expires' => null, + 'tags' => [], + 'namespace' => null, +]; +$serialized = serialize($record); +$plain = 'cl2:' . $serialized; +$signed = 'cl2-sig:' . hash_hmac('sha256', $plain, 'bounded-secret') . ':' . $plain; +$compressed = 'cl2-gz:' . base64_encode(gzencode($serialized)); + +if ($codec->decode($signed) !== null) { + exit(11); +} + +$unsigned = new CachePayloadCodec(new CacheOptions( + maxPayloadBytes: 4096, + allowClosures: false, + allowObjects: false, +)); +if ($unsigned->decode($plain) !== null || $unsigned->decode($compressed) !== null) { + exit(12); +} + +$deep = 'leaf'; +for ($index = 0; $index < 256; ++$index) { + $deep = [$deep]; +} +try { + $unsigned->encode($deep, null); + exit(13); +} catch (InvalidArgumentException) { +} + +exit(0); +PHP; + + file_put_contents($script, sprintf($code, var_export($autoload, true))); + $command = escapeshellarg(PHP_BINARY) + . ' -d memory_limit=32M -d max_execution_time=3 ' + . escapeshellarg($script); + exec($command, $output, $status); + unlink($script); + + expect($status)->toBe(0); +}); + +test('file atomic consumption reports deletion failure instead of returning the value', function () { + if (DIRECTORY_SEPARATOR === '\\') { + test()->markTestSkipped('POSIX permission semantics are required for this regression.'); + } + + foreach ([ + 'file' => static fn(string $base, CacheOptions $options): Cache + => Cache::file('consume', $base, $options), + 'php-files' => static fn(string $base, CacheOptions $options): Cache + => Cache::phpFiles('consume', $base, $options), + ] as $label => $factory) { + $base = sys_get_temp_dir() . '/cachelayer-consume-' . $label . '-' . bin2hex(random_bytes(4)); + $strict = $factory($base, new CacheOptions(failOpen: false)); + expect($strict->set('token', 'usable'))->toBeTrue(); + + $data = $base . DIRECTORY_SEPARATOR . 'cache_consume' . DIRECTORY_SEPARATOR . 'data'; + expect(chmod($data, 0500))->toBeTrue(); + + try { + expect(fn() => $strict->atomic()?->getAndDelete('token', 'missing')) + ->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); + } finally { + chmod($data, 0700); + } + + expect($strict->get('token'))->toBe('usable'); + $strict->clear(); + + $open = $factory($base, new CacheOptions(failOpen: true)); + expect($open->set('token', 'usable'))->toBeTrue(); + expect(chmod($data, 0500))->toBeTrue(); + + try { + expect($open->atomic()?->getAndDelete('token', 'missing'))->toBe('missing'); + } finally { + chmod($data, 0700); + } + + expect($open->get('token'))->toBe('usable'); + $open->clear(); + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); + } +}); From 4b199864e863479a827fbc7f9a6ef9eee72d02d6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:01:23 +0600 Subject: [PATCH 004/434] fix(cache): contain batch 1 security and transaction failures --- src/Cache/Adapter/AbstractCacheAdapter.php | 13 ++--- src/Cache/Adapter/CachePayloadCodec.php | 3 ++ src/Cache/Adapter/FileCacheAdapter.php | 13 +++-- src/Cache/Adapter/PhpFilesCacheAdapter.php | 14 +++--- .../Adapter/SecuresFilesystemDirectories.php | 16 ++++++- src/Memoize/CallableFingerprint.php | 24 ++++++---- src/Node/Adapter/NodeSqliteCacheAdapter.php | 33 +++++++++++-- src/Support/BoundedValueTraversal.php | 47 +++++++++++++++++++ 8 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 src/Support/BoundedValueTraversal.php diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 885e06ab..6dc5396b 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -49,15 +49,16 @@ public function commit(): bool /** @internal */ public function configureOptions(CacheOptions $options): void { - if ($this->codec !== null) { - if ($this->options == $options) { - return; - } + if ($this->options === null) { + $this->options = $options; - throw new \LogicException('Cache options cannot change after the adapter starts processing records.'); + return; + } + if ($this->options == $options) { + return; } - $this->options = $options; + throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); } public function createItem(string $key): CacheItemInterface diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 6976595e..827c0843 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -11,6 +11,7 @@ use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Serializer\ClosureSerializer; +use Infocyph\CacheLayer\Support\BoundedValueTraversal; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -65,6 +66,7 @@ public function decode(string $blob): ?CacheRecord try { $decoded = $this->unserializeNative($serialized); + BoundedValueTraversal::assertSafe($decoded); } catch (Throwable) { return null; } @@ -198,6 +200,7 @@ private function encodeValue(mixed $value): array return ['closure', ClosureSerializer::serialize($value)]; } + BoundedValueTraversal::assertSafe($value); $this->assertNativeValueSupported($value); return ['native', $value]; diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index fc7a14be..aa3844bd 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -55,7 +55,9 @@ public function atomicGetAndDelete(string $key): CacheItemInterface if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } - $this->deleteItemUnlocked($key); + if (!$this->deleteItemUnlocked($key)) { + throw new RuntimeException('Unable to delete consumed file cache entry.'); + } return $this->genericItemFromRecord($key, $record); }); @@ -203,12 +205,13 @@ private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = CacheInput::namespace($ns); - $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; - $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; - $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns; + $this->dataDirectory = $root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . DIRECTORY_SEPARATOR . 'meta' . DIRECTORY_SEPARATOR; + $this->lockDirectory = $root . DIRECTORY_SEPARATOR . 'locks' . DIRECTORY_SEPARATOR; $this->ensureBaseDirectoryExists($baseDir); + $this->ensureCacheDirectoryExists($root); foreach ([$this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { $this->ensureCacheDirectoryExists($directory); } diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index f72d6ef9..76abc981 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -54,7 +54,9 @@ public function atomicGetAndDelete(string $key): CacheItemInterface if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } - $this->deleteItemUnlocked($key); + if (!$this->deleteItemUnlocked($key)) { + throw new RuntimeException('Unable to delete consumed PHP-file cache entry.'); + } return $this->genericItemFromRecord($key, $record); }); @@ -203,12 +205,12 @@ private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = CacheInput::namespace($ns); - $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; - $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; - $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns; + $this->dataDirectory = $root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . DIRECTORY_SEPARATOR . 'meta' . DIRECTORY_SEPARATOR; + $this->lockDirectory = $root . DIRECTORY_SEPARATOR . 'locks' . DIRECTORY_SEPARATOR; - foreach ([$baseDir, $this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { + foreach ([$baseDir, $root, $this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { $this->assertPathNotSymlink($directory, 'PHP cache directory'); if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { throw new RuntimeException("Unable to create PHP cache directory: {$directory}"); diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index 8aefe330..b550f29b 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -10,8 +10,20 @@ trait SecuresFilesystemDirectories { protected function assertPathNotSymlink(string $path, string $label): void { - if (is_link($path)) { - throw new RuntimeException($label . " must not be a symlink: {$path}"); + $cursor = rtrim($path, DIRECTORY_SEPARATOR); + if ($cursor === '') { + $cursor = DIRECTORY_SEPARATOR; + } + + while (true) { + if (is_link($cursor)) { + throw new RuntimeException($label . " must not contain symlinks: {$path}"); + } + $parent = dirname($cursor); + if ($parent === $cursor || $parent === '.') { + return; + } + $cursor = $parent; } } diff --git a/src/Memoize/CallableFingerprint.php b/src/Memoize/CallableFingerprint.php index 44dc26a4..2dfad54d 100644 --- a/src/Memoize/CallableFingerprint.php +++ b/src/Memoize/CallableFingerprint.php @@ -6,6 +6,7 @@ use Closure; use ReflectionFunction; +use Infocyph\CacheLayer\Support\BoundedValueTraversal; use ReflectionReference; use WeakMap; @@ -51,13 +52,9 @@ public static function flush(): void public static function value(mixed $value): mixed { - return match (true) { - $value instanceof Closure => self::closure($value), - is_object($value) => self::object($value), - is_resource($value) => 'res:' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => self::values($value), - default => $value, - }; + BoundedValueTraversal::assertSafe($value); + + return self::normalizeValue($value); } private static function closure(Closure $closure): string @@ -90,6 +87,17 @@ private static function closure(Closure $closure): string return self::$closures[$closure] = 'closure:' . hash('xxh128', serialize($identity)); } + private static function normalizeValue(mixed $value): mixed + { + return match (true) { + $value instanceof Closure => self::closure($value), + is_object($value) => self::object($value), + is_resource($value) => 'res:' . get_resource_type($value) . '#' . (int) $value, + is_array($value) => self::values($value), + default => $value, + }; + } + private static function object(object $object): string { self::$objects ??= new WeakMap(); @@ -105,7 +113,7 @@ private static function values(array $values): array { $normalized = []; foreach ($values as $key => $value) { - $normalized[$key] = self::value($value); + $normalized[$key] = self::normalizeValue($value); } return $normalized; diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index e1aebbd2..5a9fa409 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -24,6 +24,8 @@ final class NodeSqliteCacheAdapter extends AbstractCacheAdapter implements TagGe private readonly string $namespace; + private bool $ownsTransaction = false; + private readonly \PDOStatement $upsertStatement; public function __construct( @@ -49,6 +51,8 @@ public function __construct( public function clear(): bool { + $this->assertWritableTransaction(); + try { $statement = $this->connection->prepare('DELETE FROM ' . self::TABLE . ' WHERE namespace = :namespace'); $ok = $statement->execute([':namespace' => $this->namespace]); @@ -80,6 +84,8 @@ public function connection(): PDO public function deleteItem(string $key): bool { + $this->assertWritableTransaction(); + try { return $this->deleteStatement->execute([ ':namespace' => $this->namespace, @@ -100,6 +106,8 @@ public function deleteItems(array $keys): bool return true; } + $this->assertWritableTransaction(); + try { $mapped = array_map($this->mapData(...), $keys); $marks = implode(',', array_fill(0, count($mapped), '?')); @@ -109,8 +117,6 @@ public function deleteItems(array $keys): bool return $statement->execute([$this->namespace, ...$mapped]); } catch (PDOException $exception) { - $this->rollBack(); - throw $this->storageException('Unable to delete node SQLite cache keys.', $exception); } } @@ -261,6 +267,7 @@ public function rotateTagGenerations(array $tags): bool public function save(CacheItemInterface $item): bool { + $this->assertWritableTransaction(); if (!$this->supportsItem($item)) { return false; } @@ -301,6 +308,7 @@ public function saveItems(array $items): bool */ public function saveMany(array $items): bool { + $this->assertWritableTransaction(); $rows = []; $expired = []; foreach ($items as $item) { @@ -327,6 +335,7 @@ public function saveMany(array $items): bool try { $this->connection->beginTransaction(); + $this->ownsTransaction = true; if ($expired !== [] && !$this->deleteItems($expired)) { $this->rollBack(); @@ -338,11 +347,16 @@ public function saveMany(array $items): bool return false; } - return $this->connection->commit(); + $committed = $this->connection->commit(); + $this->ownsTransaction = false; + + return $committed; } catch (PDOException $exception) { $this->rollBack(); throw $this->storageException('Unable to store node SQLite cache entries.', $exception); + } finally { + $this->ownsTransaction = false; } } @@ -350,6 +364,7 @@ public function saveMany(array $items): bool #[\Override] public function storeTagGenerations(array $generations): bool { + $this->assertWritableTransaction(); $rows = []; foreach ($generations as $tag => $generation) { if (!self::isGeneration($generation)) { @@ -388,11 +403,21 @@ private function mapTag(string $tag): string return 'm:tag:' . $tag; } + private function assertWritableTransaction(): void + { + if ($this->connection->inTransaction() && !$this->ownsTransaction) { + throw new NodeCacheStorageException( + 'Node SQLite cache mutations cannot join a caller-owned transaction.', + ); + } + } + private function rollBack(): void { - if ($this->connection->inTransaction()) { + if ($this->ownsTransaction && $this->connection->inTransaction()) { $this->connection->rollBack(); } + $this->ownsTransaction = false; } private function storageException(string $message, PDOException $exception): NodeCacheStorageException diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php new file mode 100644 index 00000000..6a99ff2a --- /dev/null +++ b/src/Support/BoundedValueTraversal.php @@ -0,0 +1,47 @@ + self::MAX_NODES) { + throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); + } + if (!is_array($current)) { + continue; + } + if ($depth >= self::MAX_DEPTH && $current !== []) { + throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); + } + + foreach ($current as $key => $item) { + $childReferences = $references; + $reference = ReflectionReference::fromArrayElement($current, $key); + if ($reference instanceof ReflectionReference) { + $id = bin2hex($reference->getId()); + if (isset($childReferences[$id])) { + throw new InvalidArgumentException('Recursive array references are not supported.'); + } + $childReferences[$id] = true; + } + $stack[] = [$item, $depth + 1, $childReferences]; + } + } + } +} From 081d43685064875468494c5bb1e026b4e500c45a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:04:23 +0600 Subject: [PATCH 005/434] fix(cache): redact facade and PDO secrets --- src/Cache/Adapter/PdoCacheAdapter.php | 12 +++++++++++- src/Cache/Cache.php | 12 +++++++++++- src/Cache/CacheOptions.php | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 0433f84f..4ba0a4d5 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -33,8 +33,10 @@ final class PdoCacheAdapter extends AbstractCacheAdapter implements ConditionalA public function __construct( string $namespace = 'default', + #[\SensitiveParameter] ?string $dsn = null, ?string $username = null, + #[\SensitiveParameter] ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries', @@ -47,7 +49,15 @@ public function __construct( $this->namespace = CacheInput::namespace($namespace); $this->table = $table; $resolvedDsn = $dsn ?? 'sqlite:' . self::defaultSqliteFileForNamespace($this->namespace); - $this->pdo = $pdo ?? new PDO($resolvedDsn, $username, $password); + if ($pdo instanceof PDO) { + $this->pdo = $pdo; + } else { + try { + $this->pdo = new PDO($resolvedDsn, $username, $password); + } catch (PDOException) { + throw new RuntimeException('Unable to connect to the PDO cache backend.'); + } + } $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 7b5d518a..6ef1fe55 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -120,6 +120,7 @@ public static function mongodb( ?object $client = null, string $database = 'cachelayer', string $collectionName = 'entries', + #[\SensitiveParameter] string $uri = 'mongodb://127.0.0.1:27017', ?CacheOptions $options = null, ): self { @@ -136,7 +137,11 @@ public static function mongodb( 'mongodb/mongodb is required unless a collection/client is provided.', ); } - $client = new Client($uri); + try { + $client = new Client($uri); + } catch (Throwable) { + throw new CacheInvalidArgumentException('Unable to create MongoDB client from the configured URI.'); + } } return new self( @@ -153,8 +158,10 @@ public static function nullStore(?CacheOptions $options = null): self public static function pdo( string $namespace = 'default', + #[\SensitiveParameter] ?string $dsn = null, ?string $username = null, + #[\SensitiveParameter] ?string $password = null, ?\PDO $pdo = null, string $table = 'cachelayer_entries', @@ -185,6 +192,7 @@ public static function phpFiles( public static function redis( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ?CacheOptions $options = null, @@ -272,6 +280,7 @@ public static function sqlite( /** @param list> $tiers */ public static function tiered( + #[\SensitiveParameter] array $tiers, bool $writeToL1 = true, ?CacheOptions $options = null, @@ -289,6 +298,7 @@ public static function tiered( public static function valkey( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ?CacheOptions $options = null, diff --git a/src/Cache/CacheOptions.php b/src/Cache/CacheOptions.php index 3f7b0452..c0844598 100644 --- a/src/Cache/CacheOptions.php +++ b/src/Cache/CacheOptions.php @@ -9,6 +9,7 @@ final readonly class CacheOptions { public function __construct( + #[\SensitiveParameter] public ?string $integrityKey = null, public ?int $maxPayloadBytes = 8_388_608, public ?int $compressionThreshold = null, From 5c3cf32447f26e6393024bd2c6a942061c62bfed Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:04:56 +0600 Subject: [PATCH 006/434] fix(cache): redact Redis and Valkey DSNs --- src/Cache/Adapter/RedisCacheAdapter.php | 5 +++-- src/Cache/Adapter/ValkeyCacheAdapter.php | 1 + src/Counter/AtomicCounters.php | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index ce389b23..3a41c373 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -78,6 +78,7 @@ class RedisCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolI */ public function __construct( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ) { @@ -384,12 +385,12 @@ public function saveItems(array $items): bool return $ok && $this->saveExpiring($expiring); } - private function connect(string $dsn): \Redis + private function connect(#[\SensitiveParameter] string $dsn): \Redis { try { return RedisConnection::connect($dsn); } catch (InvalidArgumentException $exception) { - throw new RuntimeException("Invalid Redis DSN: $dsn", 0, $exception); + throw new RuntimeException('Invalid Redis-compatible DSN.', 0, $exception); } } diff --git a/src/Cache/Adapter/ValkeyCacheAdapter.php b/src/Cache/Adapter/ValkeyCacheAdapter.php index 0a8aedf5..d914a0ce 100644 --- a/src/Cache/Adapter/ValkeyCacheAdapter.php +++ b/src/Cache/Adapter/ValkeyCacheAdapter.php @@ -8,6 +8,7 @@ final class ValkeyCacheAdapter extends RedisCacheAdapter { public function __construct( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ) { diff --git a/src/Counter/AtomicCounters.php b/src/Counter/AtomicCounters.php index 5bc623df..736dcfd5 100644 --- a/src/Counter/AtomicCounters.php +++ b/src/Counter/AtomicCounters.php @@ -12,6 +12,7 @@ final class AtomicCounters { public static function redis( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ): AtomicCounterStoreInterface { @@ -24,18 +25,19 @@ public static function redis( public static function valkey( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ): AtomicCounterStoreInterface { return self::redis($namespace, $dsn, $client); } - private static function connect(string $dsn): \Redis + private static function connect(#[\SensitiveParameter] string $dsn): \Redis { try { return RedisConnection::connect($dsn); } catch (InvalidArgumentException $exception) { - throw new AtomicCounterException("Invalid Redis-compatible DSN: {$dsn}", 0, $exception); + throw new AtomicCounterException('Invalid Redis-compatible DSN.', 0, $exception); } } } From 9887c533c25e405a49d9ac7eec8c7b227d32ad3f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:05:23 +0600 Subject: [PATCH 007/434] fix(cache): mark connection and signing secrets sensitive --- src/Serializer/ClosureSerializer.php | 2 +- src/Serializer/SignedClosureSerializer.php | 2 +- src/Support/RedisConnection.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Serializer/ClosureSerializer.php b/src/Serializer/ClosureSerializer.php index 85035032..2185b929 100644 --- a/src/Serializer/ClosureSerializer.php +++ b/src/Serializer/ClosureSerializer.php @@ -31,7 +31,7 @@ public static function serialize(Closure $closure): string return self::PREFIX . base64_encode(opis_serialize($closure)); } - public static function signed(string $key): SignedClosureSerializer + public static function signed(#[\SensitiveParameter] string $key): SignedClosureSerializer { return new SignedClosureSerializer($key); } diff --git a/src/Serializer/SignedClosureSerializer.php b/src/Serializer/SignedClosureSerializer.php index 22823c53..8d8ead98 100644 --- a/src/Serializer/SignedClosureSerializer.php +++ b/src/Serializer/SignedClosureSerializer.php @@ -11,7 +11,7 @@ { private const string PREFIX = 'cls1-sig:'; - public function __construct(private string $key) + public function __construct(#[\SensitiveParameter] private string $key) { if ($key === '') { throw new InvalidArgumentException('The Closure signing key must not be empty.'); diff --git a/src/Support/RedisConnection.php b/src/Support/RedisConnection.php index 0eabdfdc..1b82462e 100644 --- a/src/Support/RedisConnection.php +++ b/src/Support/RedisConnection.php @@ -14,7 +14,7 @@ final class RedisConnection private const float READ_TIMEOUT_SECONDS = 1.0; - public static function connect(string $dsn): \Redis + public static function connect(#[\SensitiveParameter] string $dsn): \Redis { [$host, $port, $database, $credentials] = self::parseDsn($dsn); $connection = new \Redis(); @@ -73,7 +73,7 @@ private static function parseCredentials(array $parts): string|array|null * @param string $dsn The Redis-compatible DSN. * @phpstan-return array{string, int, int|null, string|array{string, string}|null} */ - private static function parseDsn(string $dsn): array + private static function parseDsn(#[\SensitiveParameter] string $dsn): array { $parts = self::parseDsnParts($dsn); $scheme = $parts['scheme'] ?? null; @@ -113,7 +113,7 @@ private static function parseDsn(string $dsn): array * @param string $dsn The Redis-compatible DSN. * @phpstan-return array */ - private static function parseDsnParts(string $dsn): array + private static function parseDsnParts(#[\SensitiveParameter] string $dsn): array { try { $parts = parse_url($dsn); From d1633263eb86d37aa401a586dfc54f1f2f7915e6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:06:17 +0600 Subject: [PATCH 008/434] fix(cache): satisfy traversal guard static analysis --- src/Support/BoundedValueTraversal.php | 85 ++++++++++++++++++++------- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index 6a99ff2a..315bedde 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -15,33 +15,76 @@ final class BoundedValueTraversal public static function assertSafe(mixed $value): void { - $stack = [[$value, 0, []]]; + /** @var list}> $stack */ + $stack = [['value' => $value, 'depth' => 0, 'references' => []]]; $nodes = 0; - while ($stack !== []) { - [$current, $depth, $references] = array_pop($stack); - if (++$nodes > self::MAX_NODES) { - throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); - } + while (($frame = array_pop($stack)) !== null) { + self::assertNodeBudget(++$nodes); + $current = $frame['value']; if (!is_array($current)) { continue; } - if ($depth >= self::MAX_DEPTH && $current !== []) { - throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); - } - foreach ($current as $key => $item) { - $childReferences = $references; - $reference = ReflectionReference::fromArrayElement($current, $key); - if ($reference instanceof ReflectionReference) { - $id = bin2hex($reference->getId()); - if (isset($childReferences[$id])) { - throw new InvalidArgumentException('Recursive array references are not supported.'); - } - $childReferences[$id] = true; - } - $stack[] = [$item, $depth + 1, $childReferences]; - } + self::assertDepth($frame['depth'], $current); + self::appendChildren($stack, $current, $frame['depth'], $frame['references']); + } + } + + /** @param array $value */ + private static function assertDepth(int $depth, array $value): void + { + if ($depth >= self::MAX_DEPTH && $value !== []) { + throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); + } + } + + private static function assertNodeBudget(int $nodes): void + { + if ($nodes > self::MAX_NODES) { + throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); } } + + /** + * @param list}> $stack + * @param array $current + * @param array $references + */ + private static function appendChildren( + array &$stack, + array $current, + int $depth, + array $references, + ): void { + foreach ($current as $key => $item) { + $childReferences = self::childReferences($current, $key, $references); + $stack[] = [ + 'value' => $item, + 'depth' => $depth + 1, + 'references' => $childReferences, + ]; + } + } + + /** + * @param array $current + * @param array $references + * @return array + */ + private static function childReferences(array $current, int|string $key, array $references): array + { + $reference = ReflectionReference::fromArrayElement($current, $key); + if (!$reference instanceof ReflectionReference) { + return $references; + } + + $id = bin2hex($reference->getId()); + if (isset($references[$id])) { + throw new InvalidArgumentException('Recursive array references are not supported.'); + } + $references[$id] = true; + + return $references; + } } From cc602fd278ba4ec4c056b9251f883398fa584df4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:07:12 +0600 Subject: [PATCH 009/434] test(cache): verify secret redaction coverage --- src/Cache/Tiering/TieredPoolFactory.php | 2 +- .../SecurityContainmentRegressionTest.php | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Cache/Tiering/TieredPoolFactory.php b/src/Cache/Tiering/TieredPoolFactory.php index a68b09fc..c66e1bf6 100644 --- a/src/Cache/Tiering/TieredPoolFactory.php +++ b/src/Cache/Tiering/TieredPoolFactory.php @@ -15,7 +15,7 @@ final class TieredPoolFactory * @phpstan-param array $tiers * @phpstan-return list */ - public static function fromArray(array $tiers): array + public static function fromArray(#[\SensitiveParameter] array $tiers): array { if ($tiers === []) { throw new CacheInvalidArgumentException('Cache::tiered() requires at least one tier.'); diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php index ccf15b8e..421b5e09 100644 --- a/tests/Cache/SecurityContainmentRegressionTest.php +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -6,6 +6,9 @@ use Infocyph\CacheLayer\Cache\Adapter\PdoCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; +use Infocyph\CacheLayer\Serializer\ClosureSerializer; +use Infocyph\CacheLayer\Support\RedisConnection; use Infocyph\CacheLayer\Counter\AtomicCounters; use Infocyph\CacheLayer\Node\Adapter\NodeSqliteCacheAdapter; use Infocyph\CacheLayer\Node\Exception\NodeCacheStorageException; @@ -77,12 +80,22 @@ $secret = 'audit-password'; $dsn = 'redis://user:' . $secret . '@localhost/not-a-database'; + $previous = ini_set('zend.exception_ignore_args', '0'); + try { - AtomicCounters::redis('secret-test', $dsn); + RedisConnection::connect($dsn); test()->fail('Expected invalid Redis DSN.'); } catch (Throwable $failure) { - expect($failure->getMessage())->not->toContain($secret) - ->and($failure->getMessage())->not->toContain($dsn); + for ($current = $failure; $current instanceof Throwable; $current = $current->getPrevious()) { + expect($current->getMessage())->not->toContain($secret) + ->and($current->getMessage())->not->toContain($dsn); + } + expect((string) $failure)->not->toContain($secret) + ->and((string) $failure)->not->toContain($dsn); + } finally { + if ($previous !== false) { + ini_set('zend.exception_ignore_args', $previous); + } } }); @@ -94,11 +107,15 @@ [Cache::class, 'mongodb', 'uri'], [Cache::class, 'pdo', 'dsn'], [Cache::class, 'pdo', 'password'], + [Cache::class, 'tiered', 'tiers'], [PdoCacheAdapter::class, '__construct', 'dsn'], [PdoCacheAdapter::class, '__construct', 'password'], [AtomicCounters::class, 'redis', 'dsn'], [AtomicCounters::class, 'valkey', 'dsn'], [SignedClosureSerializer::class, '__construct', 'key'], + [ClosureSerializer::class, 'signed', 'key'], + [RedisConnection::class, 'connect', 'dsn'], + [TieredPoolFactory::class, 'fromArray', 'tiers'], ]; foreach ($parameters as [$class, $method, $parameter]) { From 112044c3ffe8713fd70ff823e1a43dad8b984fa3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:08:31 +0600 Subject: [PATCH 010/434] refactor(cache): isolate sanitized Mongo client creation --- src/Cache/Adapter/MongoDbClientFactory.php | 22 ++++++++++++++++++++++ src/Cache/Cache.php | 6 +----- 2 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 src/Cache/Adapter/MongoDbClientFactory.php diff --git a/src/Cache/Adapter/MongoDbClientFactory.php b/src/Cache/Adapter/MongoDbClientFactory.php new file mode 100644 index 00000000..221c96c0 --- /dev/null +++ b/src/Cache/Adapter/MongoDbClientFactory.php @@ -0,0 +1,22 @@ + Date: Mon, 28 Sep 2026 15:11:33 +0600 Subject: [PATCH 011/434] fix(cache): harden lock path trust boundaries --- .../Adapter/SecuresFilesystemDirectories.php | 18 +++---------- src/Cache/Lock/FileLockProvider.php | 6 +++-- src/Support/FilesystemTrust.php | 27 +++++++++++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/Support/FilesystemTrust.php diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index b550f29b..07fe0905 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -4,26 +4,15 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Support\FilesystemTrust; use RuntimeException; trait SecuresFilesystemDirectories { protected function assertPathNotSymlink(string $path, string $label): void { - $cursor = rtrim($path, DIRECTORY_SEPARATOR); - if ($cursor === '') { - $cursor = DIRECTORY_SEPARATOR; - } - - while (true) { - if (is_link($cursor)) { - throw new RuntimeException($label . " must not contain symlinks: {$path}"); - } - $parent = dirname($cursor); - if ($parent === $cursor || $parent === '.') { - return; - } - $cursor = $parent; + if (FilesystemTrust::containsSymlink($path)) { + throw new RuntimeException($label . " must not contain symlinks: {$path}"); } } @@ -43,6 +32,7 @@ protected function assertSecureDirectory(string $path, string $label): void protected function atomicReplace(string $path, string $contents): bool { + $this->assertPathNotSymlink($path . '.lock', 'Cache metadata lock file'); $lock = fopen($path . '.lock', 'c'); if (!is_resource($lock) || !flock($lock, LOCK_EX)) { if (is_resource($lock)) { diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 1136598f..99a14864 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -4,6 +4,8 @@ namespace Infocyph\CacheLayer\Cache\Lock; +use Infocyph\CacheLayer\Support\FilesystemTrust; + final readonly class FileLockProvider implements LockProviderInterface { use GeneratesLockTokens; @@ -35,7 +37,7 @@ public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 3 } $path = $this->directory . DIRECTORY_SEPARATOR . self::digestLockKey($key) . '.lock'; - if (isset($activeLocks[$path])) { + if (FilesystemTrust::containsSymlink($path) || isset($activeLocks[$path])) { return null; } $handle = $this->openLockFile($path); @@ -119,7 +121,7 @@ private function openLockFile(string $path): mixed private function prepareDirectory(): bool { - if (is_link($this->directory)) { + if (FilesystemTrust::containsSymlink($this->directory)) { return false; } if (!is_dir($this->directory) diff --git a/src/Support/FilesystemTrust.php b/src/Support/FilesystemTrust.php new file mode 100644 index 00000000..4053044b --- /dev/null +++ b/src/Support/FilesystemTrust.php @@ -0,0 +1,27 @@ + Date: Mon, 28 Sep 2026 15:12:29 +0600 Subject: [PATCH 012/434] fix(cache): harden SQLite and shared-memory paths --- src/Cache/Adapter/PdoCacheAdapter.php | 22 ++++++++++++++++++- .../Adapter/SharedMemoryCacheAdapter.php | 2 ++ src/Node/Connection/NodeSqliteConnection.php | 22 +++++++++++++------ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 4ba0a4d5..8d64f3d2 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\FilesystemTrust; use PDO; use PDOException; use Psr\Cache\CacheItemInterface; @@ -49,6 +50,7 @@ public function __construct( $this->namespace = CacheInput::namespace($namespace); $this->table = $table; $resolvedDsn = $dsn ?? 'sqlite:' . self::defaultSqliteFileForNamespace($this->namespace); + self::assertSqliteTarget($resolvedDsn); if ($pdo instanceof PDO) { $this->pdo = $pdo; } else { @@ -74,7 +76,7 @@ public static function defaultSqliteFileForNamespace(string $namespace): string $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_SQLITE_DIR); - if (is_link($directory)) { + if (FilesystemTrust::containsSymlink($directory)) { throw new RuntimeException("Refusing symlinked SQLite cache directory: {$directory}"); } if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { @@ -88,6 +90,24 @@ public static function defaultSqliteFileForNamespace(string $namespace): string return $directory . DIRECTORY_SEPARATOR . 'cache_' . CacheInput::namespace($namespace) . '.sqlite'; } + private static function assertSqliteTarget(string $dsn): void + { + if (!str_starts_with($dsn, 'sqlite:')) { + return; + } + + $file = substr($dsn, strlen('sqlite:')); + if ($file === '' || $file === ':memory:') { + return; + } + if (FilesystemTrust::containsSymlink($file)) { + throw new RuntimeException("Refusing symlinked SQLite cache path: {$file}"); + } + if (file_exists($file) && !is_file($file)) { + throw new RuntimeException("SQLite cache path is not a regular file: {$file}"); + } + } + public function clear(): bool { $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE namespace = ?"); diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 3e280f6d..427a445a 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -388,6 +388,7 @@ private function createTokenFile(): string . 'shared-memory'; $this->prepareDirectory($directory); $tokenFile = $directory . DIRECTORY_SEPARATOR . hash('xxh128', $this->ns) . '.tok'; + $this->assertPathNotSymlink($tokenFile, 'Shared-memory token file'); if (!is_file($tokenFile)) { if (file_put_contents($tokenFile, '', LOCK_EX) === false) { throw new RuntimeException('Unable to create the shared-memory token file'); @@ -446,6 +447,7 @@ private function mapTag(string $tag): string /** @phpstan-return resource */ private function openLockHandle(): mixed { + $this->assertPathNotSymlink($this->tokenFile, 'Shared-memory token file'); $lockHandle = fopen($this->tokenFile, 'c+'); if (is_resource($lockHandle)) { return $lockHandle; diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index 243568ce..febf2c18 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Infocyph\CacheLayer\Support\FilesystemTrust; use PDO; use PDOException; @@ -40,19 +41,20 @@ public static function create(NodeCacheConfig $config): PDO private static function prepareDirectory(string $file): void { - if (is_link($file)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache file: {$file}"); + if (FilesystemTrust::containsSymlink($file)) { + throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); } - - $directory = dirname($file); - if (is_link($directory)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache directory: {$directory}"); + if (file_exists($file) && !is_file($file)) { + throw new NodeCacheConfigurationException("SQLite cache file path is not a regular file: {$file}"); } + $directory = dirname($file); if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } - + if (FilesystemTrust::containsSymlink($file)) { + throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); + } if (!is_writable($directory)) { throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); } @@ -61,5 +63,11 @@ private static function prepareDirectory(string $file): void if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { throw new NodeCacheConfigurationException("SQLite cache directory must not be world-writable: {$directory}"); } + if (is_file($file)) { + $filePermissions = fileperms($file); + if ($filePermissions !== false && (($filePermissions & 0x0002) === 0x0002)) { + throw new NodeCacheConfigurationException("SQLite cache file must not be world-writable: {$file}"); + } + } } } From da5e0b8b7c16b87aa092fafb21594a6ce3e0affd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:12:56 +0600 Subject: [PATCH 013/434] fix(cache): reject symlinked cache lock files --- src/Cache/Adapter/FileCacheAdapter.php | 1 + src/Cache/Adapter/PhpFilesCacheAdapter.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index aa3844bd..aa952896 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -342,6 +342,7 @@ private function throwCreationError(string $prefix): void private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $this->assertPathNotSymlink($path, 'File cache key lock'); $handle = fopen($path, 'c'); if (!is_resource($handle) || !flock($handle, LOCK_EX)) { if (is_resource($handle)) { diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 76abc981..c6841073 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -332,6 +332,7 @@ private function recordTagsAreCurrent(CacheRecord $record): bool private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $this->assertPathNotSymlink($path, 'PHP-file cache key lock'); $handle = fopen($path, 'c'); if (!is_resource($handle) || !flock($handle, LOCK_EX)) { if (is_resource($handle)) { From 831ebdbc836eeb0ae5090a190b89303cabda941e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:14:50 +0600 Subject: [PATCH 014/434] fix(cache): make composite policy binding atomic --- src/Cache/Adapter/AbstractCacheAdapter.php | 18 +++++++++--------- src/Cache/Adapter/TieredCacheAdapter.php | 12 ++++++++++++ src/Node/Adapter/NodeCacheAdapter.php | 11 +++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 6dc5396b..68611853 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -47,18 +47,18 @@ public function commit(): bool } /** @internal */ - public function configureOptions(CacheOptions $options): void + public function assertOptionsCompatible(CacheOptions $options): void { - if ($this->options === null) { - $this->options = $options; - - return; - } - if ($this->options == $options) { - return; + if ($this->options !== null && $this->options != $options) { + throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); } + } - throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); + /** @internal */ + public function configureOptions(CacheOptions $options): void + { + $this->assertOptionsCompatible($options); + $this->options ??= $options; } public function createItem(string $key): CacheItemInterface diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 750c6104..52a1aaec 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -36,9 +36,21 @@ public function clear(): bool return $cleared; } + #[\Override] + public function assertOptionsCompatible(CacheOptions $options): void + { + parent::assertOptionsCompatible($options); + foreach ($this->pools as $pool) { + if ($pool instanceof AbstractCacheAdapter) { + $pool->assertOptionsCompatible($options); + } + } + } + #[\Override] public function configureOptions(CacheOptions $options): void { + $this->assertOptionsCompatible($options); parent::configureOptions($options); foreach ($this->pools as $pool) { if ($pool instanceof AbstractCacheAdapter) { diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index b1433666..3ea86527 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -32,9 +32,20 @@ public function clear(): bool return $l2 && $l1; } + #[\Override] + public function assertOptionsCompatible(CacheOptions $options): void + { + parent::assertOptionsCompatible($options); + $this->l2->assertOptionsCompatible($options); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->assertOptionsCompatible($options); + } + } + #[\Override] public function configureOptions(CacheOptions $options): void { + $this->assertOptionsCompatible($options); parent::configureOptions($options); $this->l2->configureOptions($options); if ($this->l1 instanceof AbstractCacheAdapter) { From 77ffe9db6174e3ab40e13792f041df0e4dabb1d1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:15:40 +0600 Subject: [PATCH 015/434] test(cache): cover composite policy and filesystem trust --- .../SecurityContainmentRegressionTest.php | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php index 421b5e09..07e29537 100644 --- a/tests/Cache/SecurityContainmentRegressionTest.php +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -3,15 +3,22 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\SharedMemoryCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\TieredCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\PdoCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Serializer\ClosureSerializer; use Infocyph\CacheLayer\Support\RedisConnection; use Infocyph\CacheLayer\Counter\AtomicCounters; +use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; use Infocyph\CacheLayer\Node\Adapter\NodeSqliteCacheAdapter; +use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; +use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; use Infocyph\CacheLayer\Node\Exception\NodeCacheStorageException; +use Infocyph\CacheLayer\Node\NodeCacheConfig; use Infocyph\CacheLayer\Serializer\SignedClosureSerializer; test('adapter policy is immutable from the first facade binding', function () { @@ -266,3 +273,107 @@ rmdir($base); } }); + + +test('composite adapters preflight policy conflicts without partial binding', function () { + $strict = new CacheOptions(allowObjects: false, allowClosures: false, integrityKey: 'strict'); + $permissive = new CacheOptions(allowObjects: true, allowClosures: true); + + $tierFirst = new ArrayCacheAdapter('tier-first'); + $tierConflict = new ArrayCacheAdapter('tier-conflict'); + new Cache($tierConflict, options: $strict); + + $tiered = new TieredCacheAdapter([$tierFirst, $tierConflict]); + expect(fn() => new Cache($tiered, options: $permissive)) + ->toThrow(LogicException::class) + ->and(fn() => new Cache($tierFirst, options: $strict)) + ->not->toThrow(LogicException::class); + + $connection = new PDO('sqlite::memory:'); + $l1 = new ArrayCacheAdapter('node-policy'); + $l2 = new NodeSqliteCacheAdapter($connection, 'node-policy'); + new Cache($l1, options: $strict); + + $node = new NodeCacheAdapter($l1, $l2, false); + expect(fn() => new Cache($node, options: $permissive)) + ->toThrow(LogicException::class) + ->and(fn() => new Cache($l2, options: $strict)) + ->not->toThrow(LogicException::class); +}); + +test('SQLite and file-lock owners reject symlinked path components', function () { + if (DIRECTORY_SEPARATOR === '\\' || !function_exists('symlink')) { + test()->markTestSkipped('POSIX symlink semantics are required for this regression.'); + } + + $base = sys_get_temp_dir() . '/cachelayer-path-trust-' . bin2hex(random_bytes(4)); + $target = $base . '/target'; + $link = $base . '/linked'; + mkdir($target, 0700, true); + expect(symlink($target, $link))->toBeTrue(); + + try { + $config = new NodeCacheConfig( + sqliteFile: $link . '/node.sqlite', + namespace: 'path-trust', + apcuEnabled: false, + ); + expect(fn() => NodeSqliteConnection::create($config)) + ->toThrow(NodeCacheConfigurationException::class) + ->and(fn() => Cache::sqlite('path-trust', $link . '/pdo.sqlite')) + ->toThrow(RuntimeException::class); + + $locks = $base . '/locks'; + mkdir($locks, 0700); + $targetFile = $base . '/lock-target'; + touch($targetFile); + $lockPath = $locks . DIRECTORY_SEPARATOR . hash('xxh128', 'claim') . '.lock'; + expect(symlink($targetFile, $lockPath))->toBeTrue() + ->and((new FileLockProvider($locks))->acquire('claim', 0.0))->toBeNull(); + } finally { + if (is_link($base . '/locks/' . hash('xxh128', 'claim') . '.lock')) { + unlink($base . '/locks/' . hash('xxh128', 'claim') . '.lock'); + } + if (is_link($link)) { + unlink($link); + } + foreach ([$base . '/lock-target', $base . '/locks'] as $path) { + is_dir($path) ? rmdir($path) : (is_file($path) ? unlink($path) : null); + } + if (is_dir($target)) { + rmdir($target); + } + if (is_dir($base)) { + rmdir($base); + } + } +}); + +test('shared-memory token creation rejects a pre-created symlink', function () { + if (DIRECTORY_SEPARATOR === '\\' || !function_exists('symlink') || !function_exists('shm_attach')) { + test()->markTestSkipped('Shared-memory and POSIX symlink support are required.'); + } + + $namespace = 'token-' . bin2hex(random_bytes(4)); + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'cachelayer' . DIRECTORY_SEPARATOR . 'shared-memory'; + if (!is_dir($directory)) { + mkdir($directory, 0700, true); + } + $target = tempnam(sys_get_temp_dir(), 'cachelayer-token-target-'); + expect($target)->not->toBeFalse(); + $token = $directory . DIRECTORY_SEPARATOR . hash('xxh128', $namespace) . '.tok'; + expect(symlink($target, $token))->toBeTrue(); + + try { + expect(fn() => new SharedMemoryCacheAdapter($namespace)) + ->toThrow(RuntimeException::class); + } finally { + if (is_link($token)) { + unlink($token); + } + if (is_string($target) && is_file($target)) { + unlink($target); + } + } +}); From daf6a0aaed71e919ca666447e383caee97cf13ba Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:18:32 +0600 Subject: [PATCH 016/434] fix(cache): preserve SQLite path recheck without static false positive --- src/Node/Connection/NodeSqliteConnection.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index febf2c18..ebfb27dc 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -39,7 +39,7 @@ public static function create(NodeCacheConfig $config): PDO return $connection; } - private static function prepareDirectory(string $file): void + private static function assertTrustedFilePath(string $file): void { if (FilesystemTrust::containsSymlink($file)) { throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); @@ -47,14 +47,17 @@ private static function prepareDirectory(string $file): void if (file_exists($file) && !is_file($file)) { throw new NodeCacheConfigurationException("SQLite cache file path is not a regular file: {$file}"); } + } + + private static function prepareDirectory(string $file): void + { + self::assertTrustedFilePath($file); $directory = dirname($file); if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } - if (FilesystemTrust::containsSymlink($file)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); - } + self::assertTrustedFilePath($file); if (!is_writable($directory)) { throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); } From efcc1de48e982baa5c134c50961d19e60c13b57e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:19:30 +0600 Subject: [PATCH 017/434] refactor(cache): keep SQLite path checks below complexity limit --- src/Node/Connection/NodeSqliteConnection.php | 43 ++++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index ebfb27dc..0c942d49 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -49,15 +49,8 @@ private static function assertTrustedFilePath(string $file): void } } - private static function prepareDirectory(string $file): void + private static function assertSecureDirectory(string $directory): void { - self::assertTrustedFilePath($file); - - $directory = dirname($file); - if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { - throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); - } - self::assertTrustedFilePath($file); if (!is_writable($directory)) { throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); } @@ -66,11 +59,35 @@ private static function prepareDirectory(string $file): void if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { throw new NodeCacheConfigurationException("SQLite cache directory must not be world-writable: {$directory}"); } - if (is_file($file)) { - $filePermissions = fileperms($file); - if ($filePermissions !== false && (($filePermissions & 0x0002) === 0x0002)) { - throw new NodeCacheConfigurationException("SQLite cache file must not be world-writable: {$file}"); - } + } + + private static function assertSecureFile(string $file): void + { + if (!is_file($file)) { + return; + } + + $permissions = fileperms($file); + if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { + throw new NodeCacheConfigurationException("SQLite cache file must not be world-writable: {$file}"); + } + } + + private static function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { + throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } } + + private static function prepareDirectory(string $file): void + { + self::assertTrustedFilePath($file); + + $directory = dirname($file); + self::ensureDirectory($directory); + self::assertTrustedFilePath($file); + self::assertSecureDirectory($directory); + self::assertSecureFile($file); + } } From 7fdaa40313158279bdf297a398c0f42d475d4e63 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:21:04 +0600 Subject: [PATCH 018/434] docs(cache): document containment and filesystem trust --- docs/adapters/php-files.rst | 12 ++++++++--- docs/security.rst | 43 +++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index 241c383e..eec55366 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -11,7 +11,7 @@ Persists cache records as PHP files that return payload arrays. Path layout: * base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/phpfiles'`` -* namespace dir: ``phpcache_`` with separate ``data`` and ``meta`` subdirectories +* namespace dir: ``cache_`` with separate ``data`` and ``meta`` subdirectories * file name: ``hash('xxh128', $key) . '.php'`` Highlights: @@ -25,8 +25,14 @@ Expired files are removed lazily when encountered. Use bounded operational directory rotation when entries may expire without being read again. Good for environments where opcode cache integration is desired. -Use only in trusted environments, since cache entries are stored as executable -PHP files. + +Use only in trusted environments with a private, application-owned cache root. +The adapter executes each cache PHP file before the returned encoded payload can +be verified by the payload codec. Therefore payload signing does not protect +against an attacker who can replace the executable file or redirect an ancestor, +namespace, or lock path. Construction and lock acquisition reject detected +symlink path components, but deployment ownership and permissions remain the +primary trust boundary. Example ------- diff --git a/docs/security.rst b/docs/security.rst index cac16632..a15e1442 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -66,10 +66,14 @@ not read process environment state. ``phpFiles`` keeps executable ``.php`` cache files for performance, so strict directory controls are required. Runtime checks now reject: -* symlinked cache directories +* symlinked cache directories, namespace roots, ancestors, and lock paths * world-writable cache directories -Use ``phpFiles`` only on trusted hosts and private directories. +The adapter executes the cache PHP file to obtain its encoded payload before +payload HMAC verification can occur. HMAC protects the encoded cache record; it +does not make an attacker-controlled executable cache directory safe. Use +``phpFiles`` only on trusted hosts and private directories whose path +components cannot be replaced by an untrusted user. 3) Temp-Directory Hardening ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -86,10 +90,37 @@ world-writable cache directories. Network/database adapters rely on the deployment's service permissions rather than local directory checks. The shared-memory adapter also stores its ``ftok`` token in a private -``cachelayer/shared-memory`` directory, creates the segment for the current -user only, and serializes read-modify-write operations with a filesystem lock. - -4) Network Timeouts +``cachelayer/shared-memory`` directory, rejects symlinked token paths, +creates the segment for the current user only, and serializes read-modify-write +operations with a filesystem lock. File locks and SQLite cache paths likewise +reject symlinked path components before opening storage. + +4) Containment Failure Semantics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +CacheLayer 4.0 treats the following conditions as explicit backend or +configuration failures rather than continuing with ambiguous state: + +* Recursive or over-budget array graphs are rejected before recursive + serialization-policy or memoization normalization can exhaust the worker. +* Rebinding one adapter to conflicting ``CacheOptions`` is rejected before + storage use. Composite Node and tiered adapters preflight every child before + applying a new policy. +* File and PHP-files atomic get-and-delete returns a value only after the + backing file was successfully deleted. Strict mode reports the backend + failure; fail-open mode returns the configured miss/default and never the + unconsumed value. +* Node SQLite mutations reject caller-owned transactions. CacheLayer does not + commit or roll back application work that it did not start. +* Redis/Valkey, PDO, MongoDB, payload-integrity, and closure-signing secrets are + treated as sensitive parameters; connection/configuration errors avoid + echoing secret-bearing DSNs or URIs. + +These checks reduce accidental trust-boundary violations but do not eliminate +filesystem races on every platform. Deploy writable cache roots as private, +application-owned directories. + +5) Network Timeouts ~~~~~~~~~~~~~~~~~~~ Redis/Valkey connections created from a DSN use bounded one-second connect and From 5cd1494de1a0c4e021695a1e079d9b6ba6386ada Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:23:06 +0600 Subject: [PATCH 019/434] test(cache): make batch 1 prerequisites explicit --- .../SecurityContainmentRegressionTest.php | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php index 07e29537..ce9ca72c 100644 --- a/tests/Cache/SecurityContainmentRegressionTest.php +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -4,15 +4,14 @@ use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\SharedMemoryCacheAdapter; -use Infocyph\CacheLayer\Cache\Adapter\TieredCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\PdoCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\TieredCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Serializer\ClosureSerializer; use Infocyph\CacheLayer\Support\RedisConnection; -use Infocyph\CacheLayer\Counter\AtomicCounters; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; use Infocyph\CacheLayer\Node\Adapter\NodeSqliteCacheAdapter; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; @@ -131,11 +130,8 @@ } }); - test('recursive payloads fail within bounded subprocess resources', function () { - if (!function_exists('proc_open')) { - test()->markTestSkipped('proc_open is required for the bounded recursion regression.'); - } + expect(function_exists('proc_open'))->toBeTrue(); $autoload = realpath(__DIR__ . '/../../vendor/autoload.php'); expect($autoload)->not->toBeFalse(); @@ -216,16 +212,29 @@ $command = escapeshellarg(PHP_BINARY) . ' -d memory_limit=32M -d max_execution_time=3 ' . escapeshellarg($script); - exec($command, $output, $status); + $process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); unlink($script); - expect($status)->toBe(0); + expect($status)->toBe(0, trim((string) $stdout . "\n" . (string) $stderr)); }); test('file atomic consumption reports deletion failure instead of returning the value', function () { - if (DIRECTORY_SEPARATOR === '\\') { - test()->markTestSkipped('POSIX permission semantics are required for this regression.'); - } + expect(DIRECTORY_SEPARATOR)->not->toBe('\\'); foreach ([ 'file' => static fn(string $base, CacheOptions $options): Cache @@ -274,7 +283,6 @@ } }); - test('composite adapters preflight policy conflicts without partial binding', function () { $strict = new CacheOptions(allowObjects: false, allowClosures: false, integrityKey: 'strict'); $permissive = new CacheOptions(allowObjects: true, allowClosures: true); @@ -302,9 +310,8 @@ }); test('SQLite and file-lock owners reject symlinked path components', function () { - if (DIRECTORY_SEPARATOR === '\\' || !function_exists('symlink')) { - test()->markTestSkipped('POSIX symlink semantics are required for this regression.'); - } + expect(DIRECTORY_SEPARATOR)->not->toBe('\\') + ->and(function_exists('symlink'))->toBeTrue(); $base = sys_get_temp_dir() . '/cachelayer-path-trust-' . bin2hex(random_bytes(4)); $target = $base . '/target'; @@ -350,9 +357,9 @@ }); test('shared-memory token creation rejects a pre-created symlink', function () { - if (DIRECTORY_SEPARATOR === '\\' || !function_exists('symlink') || !function_exists('shm_attach')) { - test()->markTestSkipped('Shared-memory and POSIX symlink support are required.'); - } + expect(DIRECTORY_SEPARATOR)->not->toBe('\\') + ->and(function_exists('symlink'))->toBeTrue() + ->and(function_exists('shm_attach'))->toBeTrue(); $namespace = 'token-' . bin2hex(random_bytes(4)); $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) From e601dcf050219547f44bd0e7560ee46d9ccd0e8c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:36:16 +0600 Subject: [PATCH 020/434] docs(plan): add 4.0 implementation tracker --- ...achelayer-4.0-security-correctness-plan.md | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 3abd2c03..1dad9a6d 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,10 +1,38 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Planned for 4.0.0; audit completed, remediation not implemented\ +Status: Implementation in progress; Batch 1 implemented, QA remediation in progress\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** +## Implementation tracker + +Updated: 2026-09-28 +Working branch: `feature/improvements` +Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://github.com/infocyph/CacheLayer/pull/29) + +| Batch | Findings | Status | Current gate | +| --- | --- | --- | --- | +| 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **QA in progress** | Production fixes and regression coverage are implemented. PHPForge analysis and benchmarks pass on the latest completed run; quality QA still needs remediation before this batch closes. | +| 2 — Authenticated payload/storage identity | R02, R15 | Not started | Starts only after Batch 1 QA is clean. | +| 3 — Durable invalidation protocol | R06, R07 | Not started | Blocked on Batch 2 identity decisions. | +| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | +| 5 — Counters and backend races | R14, R18 | Not started | Pending prior batches. | +| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | + +### Batch 1 tracker + +| Finding | Implementation | Regression evidence | QA state | +| --- | --- | --- | --- | +| R01 — bounded recursive traversal | Implemented | Added bounded subprocess coverage for direct/mutual cycles, deep input, signed/unsigned/compressed records; shared traversal guard also protects callable fingerprints | Analyzer feedback on the traversal helper was resolved; focused regression passes in CI. | +| R03 — immutable adapter policy | Implemented | Shared-adapter conflicting-policy regression added | Pending final Batch 1 quality gate. | +| R04 — atomic file consume deletion | Implemented | File and PHP-files consume failure coverage added | Logic implemented; current CI reports the permission-fault test as a warning on the runner, so the fault injection still needs deterministic hardening. | +| R05 — Node SQLite transaction ownership | Implemented | Caller-owned transaction preservation regression added | Node tests pass; pending final Batch 1 quality gate. | +| R09 — filesystem trust boundaries | Implemented across File/PHP-files roots and locks, Node/PDO SQLite paths, FileLockProvider, and shared-memory token paths | Symlink-root regression added; filesystem owners were audited beyond the initially reproduced PHP-files path | Static-analysis feedback was resolved; pending final Batch 1 quality gate and platform coverage. | +| R10 — secret redaction | Implemented for Redis/Valkey DSNs, PDO credentials/DSNs, MongoDB URI creation, integrity/signing keys, and tier descriptors | Error/trace and `#[SensitiveParameter]` regression coverage added | Redis redaction regression passes; one reflection assertion currently targets a stale/removed symbol and must be corrected. | + +**Latest completed CI evidence:** PHPForge analysis passes on PHP 8.4/8.5 and both benchmark jobs pass. QA jobs currently fail on the quality suite because the new containment test has one ReflectionException, Pint reports style issues, and the repository's existing skip-directive/reference-integrity findings remain active. Do not mark Batch 1 complete until those failures are resolved and the exact resulting commit is green. + ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 9b26e820670b355246ec5526a9f1166c99d1a4a5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:38:51 +0600 Subject: [PATCH 021/434] fix(cache): stabilize Batch 1 regression faults --- src/Cache/Adapter/FileCacheAdapter.php | 2 +- src/Cache/Adapter/PhpFilesCacheAdapter.php | 2 +- .../Adapter/SecuresFilesystemDirectories.php | 15 +++++++++++++++ tests/Cache/SecurityContainmentRegressionTest.php | 7 ++++--- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index aa952896..c733daa6 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -228,7 +228,7 @@ private function deleteItemUnlocked(string $key): bool { $file = $this->fileFor($key); - return !is_file($file) || unlink($file); + return $this->deleteFile($file); } private function ensureBaseDirectoryExists(string $baseDir): void diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index c6841073..eed18d9d 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -234,7 +234,7 @@ private function deleteItemUnlocked(string $key): bool $file = $this->fileFor($key); $this->invalidateOpcache($file); - return !is_file($file) || unlink($file); + return $this->deleteFile($file); } private function fileFor(string $key): string diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index 07fe0905..63e40a18 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -30,6 +30,21 @@ protected function assertSecureDirectory(string $path, string $label): void } } + protected function deleteFile(string $path): bool + { + if (!is_file($path)) { + return true; + } + + set_error_handler(static fn(): bool => true); + + try { + return unlink($path); + } finally { + restore_error_handler(); + } + } + protected function atomicReplace(string $path, string $contents): bool { $this->assertPathNotSymlink($path . '.lock', 'Cache metadata lock file'); diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php index ce9ca72c..f808dcbe 100644 --- a/tests/Cache/SecurityContainmentRegressionTest.php +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -3,22 +3,23 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; -use Infocyph\CacheLayer\Cache\Adapter\SharedMemoryCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\PdoCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\SharedMemoryCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\TieredCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; -use Infocyph\CacheLayer\Serializer\ClosureSerializer; -use Infocyph\CacheLayer\Support\RedisConnection; +use Infocyph\CacheLayer\Counter\AtomicCounters; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; use Infocyph\CacheLayer\Node\Adapter\NodeSqliteCacheAdapter; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; use Infocyph\CacheLayer\Node\Exception\NodeCacheStorageException; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Infocyph\CacheLayer\Serializer\ClosureSerializer; use Infocyph\CacheLayer\Serializer\SignedClosureSerializer; +use Infocyph\CacheLayer\Support\RedisConnection; test('adapter policy is immutable from the first facade binding', function () { $adapter = new ArrayCacheAdapter('shared-policy'); From 3c20b5238eec63a7fb2661b770c445dcbdc193a3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:42:25 +0600 Subject: [PATCH 022/434] style(cache): align Batch 1 with PHPForge ordering --- src/Cache/Adapter/AbstractCacheAdapter.php | 16 ++++----- src/Cache/Adapter/PdoCacheAdapter.php | 38 ++++++++++---------- src/Cache/Adapter/TieredCacheAdapter.php | 24 ++++++------- src/Memoize/CallableFingerprint.php | 2 +- src/Node/Adapter/NodeCacheAdapter.php | 20 +++++------ src/Node/Adapter/NodeSqliteCacheAdapter.php | 26 +++++++------- src/Node/Connection/NodeSqliteConnection.php | 28 +++++++-------- src/Support/BoundedValueTraversal.php | 29 +++++++-------- 8 files changed, 92 insertions(+), 91 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 68611853..b14b7d3c 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -28,6 +28,14 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC */ abstract public function multiFetch(array $keys): array; + /** @internal */ + public function assertOptionsCompatible(CacheOptions $options): void + { + if ($this->options !== null && $this->options != $options) { + throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); + } + } + /** @param array $items */ abstract public function saveItems(array $items): bool; @@ -46,14 +54,6 @@ public function commit(): bool return $saved; } - /** @internal */ - public function assertOptionsCompatible(CacheOptions $options): void - { - if ($this->options !== null && $this->options != $options) { - throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); - } - } - /** @internal */ public function configureOptions(CacheOptions $options): void { diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 8d64f3d2..6f6510cb 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -90,25 +90,7 @@ public static function defaultSqliteFileForNamespace(string $namespace): string return $directory . DIRECTORY_SEPARATOR . 'cache_' . CacheInput::namespace($namespace) . '.sqlite'; } - private static function assertSqliteTarget(string $dsn): void - { - if (!str_starts_with($dsn, 'sqlite:')) { - return; - } - - $file = substr($dsn, strlen('sqlite:')); - if ($file === '' || $file === ':memory:') { - return; - } - if (FilesystemTrust::containsSymlink($file)) { - throw new RuntimeException("Refusing symlinked SQLite cache path: {$file}"); - } - if (file_exists($file) && !is_file($file)) { - throw new RuntimeException("SQLite cache path is not a regular file: {$file}"); - } - } - - public function clear(): bool + public function clear(): bool { $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE namespace = ?"); $cleared = $statement->execute([$this->namespace]); @@ -287,6 +269,24 @@ public function saveItems(array $items): bool return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } +private static function assertSqliteTarget(string $dsn): void + { + if (!str_starts_with($dsn, 'sqlite:')) { + return; + } + + $file = substr($dsn, strlen('sqlite:')); + if ($file === '' || $file === ':memory:') { + return; + } + if (FilesystemTrust::containsSymlink($file)) { + throw new RuntimeException("Refusing symlinked SQLite cache path: {$file}"); + } + if (file_exists($file) && !is_file($file)) { + throw new RuntimeException("SQLite cache path is not a regular file: {$file}"); + } + } + /** @param list $keys */ private function deleteByKind(string $kind, array $keys): bool { diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 52a1aaec..1c691c94 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -25,18 +25,7 @@ public function __construct( } } - public function clear(): bool - { - $cleared = true; - foreach ($this->pools as $pool) { - $cleared = $pool->clear() && $cleared; - } - $this->deferred = []; - - return $cleared; - } - - #[\Override] + #[\Override] public function assertOptionsCompatible(CacheOptions $options): void { parent::assertOptionsCompatible($options); @@ -47,6 +36,17 @@ public function assertOptionsCompatible(CacheOptions $options): void } } +public function clear(): bool + { + $cleared = true; + foreach ($this->pools as $pool) { + $cleared = $pool->clear() && $cleared; + } + $this->deferred = []; + + return $cleared; + } + #[\Override] public function configureOptions(CacheOptions $options): void { diff --git a/src/Memoize/CallableFingerprint.php b/src/Memoize/CallableFingerprint.php index 2dfad54d..22da4508 100644 --- a/src/Memoize/CallableFingerprint.php +++ b/src/Memoize/CallableFingerprint.php @@ -5,8 +5,8 @@ namespace Infocyph\CacheLayer\Memoize; use Closure; -use ReflectionFunction; use Infocyph\CacheLayer\Support\BoundedValueTraversal; +use ReflectionFunction; use ReflectionReference; use WeakMap; diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 3ea86527..925e31fc 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -23,16 +23,7 @@ public function __construct( private readonly CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), ) {} - public function clear(): bool - { - $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); - $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); - $this->deferred = []; - - return $l2 && $l1; - } - - #[\Override] + #[\Override] public function assertOptionsCompatible(CacheOptions $options): void { parent::assertOptionsCompatible($options); @@ -42,6 +33,15 @@ public function assertOptionsCompatible(CacheOptions $options): void } } +public function clear(): bool + { + $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); + $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + $this->deferred = []; + + return $l2 && $l1; + } + #[\Override] public function configureOptions(CacheOptions $options): void { diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index 5a9fa409..fb8547cd 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -24,10 +24,10 @@ final class NodeSqliteCacheAdapter extends AbstractCacheAdapter implements TagGe private readonly string $namespace; - private bool $ownsTransaction = false; - private readonly \PDOStatement $upsertStatement; + private bool $ownsTransaction = false; + public function __construct( private readonly PDO $connection, string $namespace, @@ -376,7 +376,16 @@ public function storeTagGenerations(array $generations): bool return $this->upsertRows($rows); } - private function createSchemaIfMissing(): void + private function assertWritableTransaction(): void + { + if ($this->connection->inTransaction() && !$this->ownsTransaction) { + throw new NodeCacheStorageException( + 'Node SQLite cache mutations cannot join a caller-owned transaction.', + ); + } + } + +private function createSchemaIfMissing(): void { try { $this->connection->exec( @@ -403,16 +412,7 @@ private function mapTag(string $tag): string return 'm:tag:' . $tag; } - private function assertWritableTransaction(): void - { - if ($this->connection->inTransaction() && !$this->ownsTransaction) { - throw new NodeCacheStorageException( - 'Node SQLite cache mutations cannot join a caller-owned transaction.', - ); - } - } - - private function rollBack(): void + private function rollBack(): void { if ($this->ownsTransaction && $this->connection->inTransaction()) { $this->connection->rollBack(); diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index 0c942d49..316a3b99 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -39,17 +39,7 @@ public static function create(NodeCacheConfig $config): PDO return $connection; } - private static function assertTrustedFilePath(string $file): void - { - if (FilesystemTrust::containsSymlink($file)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); - } - if (file_exists($file) && !is_file($file)) { - throw new NodeCacheConfigurationException("SQLite cache file path is not a regular file: {$file}"); - } - } - - private static function assertSecureDirectory(string $directory): void + private static function assertSecureDirectory(string $directory): void { if (!is_writable($directory)) { throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); @@ -61,7 +51,7 @@ private static function assertSecureDirectory(string $directory): void } } - private static function assertSecureFile(string $file): void +private static function assertSecureFile(string $file): void { if (!is_file($file)) { return; @@ -73,14 +63,24 @@ private static function assertSecureFile(string $file): void } } - private static function ensureDirectory(string $directory): void +private static function assertTrustedFilePath(string $file): void + { + if (FilesystemTrust::containsSymlink($file)) { + throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); + } + if (file_exists($file) && !is_file($file)) { + throw new NodeCacheConfigurationException("SQLite cache file path is not a regular file: {$file}"); + } + } + +private static function ensureDirectory(string $directory): void { if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } } - private static function prepareDirectory(string $file): void +private static function prepareDirectory(string $file): void { self::assertTrustedFilePath($file); diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index 315bedde..df70a220 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -32,20 +32,6 @@ public static function assertSafe(mixed $value): void } /** @param array $value */ - private static function assertDepth(int $depth, array $value): void - { - if ($depth >= self::MAX_DEPTH && $value !== []) { - throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); - } - } - - private static function assertNodeBudget(int $nodes): void - { - if ($nodes > self::MAX_NODES) { - throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); - } - } - /** * @param list}> $stack * @param array $current @@ -67,6 +53,21 @@ private static function appendChildren( } } + /** @param array $value */ + private static function assertDepth(int $depth, array $value): void + { + if ($depth >= self::MAX_DEPTH && $value !== []) { + throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); + } + } + +private static function assertNodeBudget(int $nodes): void + { + if ($nodes > self::MAX_NODES) { + throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); + } + } + /** * @param array $current * @param array $references From ee44310c2544e53aa06775125d08875dba67c4d3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 15:56:08 +0600 Subject: [PATCH 023/434] style(cache): finish Batch 1 PHPForge formatting --- src/Cache/Adapter/AbstractCacheAdapter.php | 6 ++-- src/Cache/Adapter/PdoCacheAdapter.php | 2 +- .../Adapter/SecuresFilesystemDirectories.php | 30 +++++++++---------- src/Cache/Adapter/TieredCacheAdapter.php | 4 +-- src/Node/Adapter/NodeCacheAdapter.php | 4 +-- src/Node/Adapter/NodeSqliteCacheAdapter.php | 4 +-- src/Node/Connection/NodeSqliteConnection.php | 10 +++---- src/Support/BoundedValueTraversal.php | 3 +- 8 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index b14b7d3c..489e03e5 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -28,6 +28,9 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC */ abstract public function multiFetch(array $keys): array; + /** @param array $items */ + abstract public function saveItems(array $items): bool; + /** @internal */ public function assertOptionsCompatible(CacheOptions $options): void { @@ -36,9 +39,6 @@ public function assertOptionsCompatible(CacheOptions $options): void } } - /** @param array $items */ - abstract public function saveItems(array $items): bool; - public function commit(): bool { if ($this->deferred === []) { diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 6f6510cb..e5ce8ec6 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -269,7 +269,7 @@ public function saveItems(array $items): bool return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } -private static function assertSqliteTarget(string $dsn): void + private static function assertSqliteTarget(string $dsn): void { if (!str_starts_with($dsn, 'sqlite:')) { return; diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index 63e40a18..e8e6b0c3 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -30,21 +30,6 @@ protected function assertSecureDirectory(string $path, string $label): void } } - protected function deleteFile(string $path): bool - { - if (!is_file($path)) { - return true; - } - - set_error_handler(static fn(): bool => true); - - try { - return unlink($path); - } finally { - restore_error_handler(); - } - } - protected function atomicReplace(string $path, string $contents): bool { $this->assertPathNotSymlink($path . '.lock', 'Cache metadata lock file'); @@ -69,4 +54,19 @@ protected function atomicReplace(string $path, string $contents): bool return $stored; } + + protected function deleteFile(string $path): bool + { + if (!is_file($path)) { + return true; + } + + set_error_handler(static fn(): bool => true); + + try { + return unlink($path); + } finally { + restore_error_handler(); + } + } } diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 1c691c94..3d31d4ad 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -25,7 +25,7 @@ public function __construct( } } - #[\Override] + #[\Override] public function assertOptionsCompatible(CacheOptions $options): void { parent::assertOptionsCompatible($options); @@ -36,7 +36,7 @@ public function assertOptionsCompatible(CacheOptions $options): void } } -public function clear(): bool + public function clear(): bool { $cleared = true; foreach ($this->pools as $pool) { diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 925e31fc..bcc9886d 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -23,7 +23,7 @@ public function __construct( private readonly CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), ) {} - #[\Override] + #[\Override] public function assertOptionsCompatible(CacheOptions $options): void { parent::assertOptionsCompatible($options); @@ -33,7 +33,7 @@ public function assertOptionsCompatible(CacheOptions $options): void } } -public function clear(): bool + public function clear(): bool { $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index fb8547cd..2c032f0d 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -385,7 +385,7 @@ private function assertWritableTransaction(): void } } -private function createSchemaIfMissing(): void + private function createSchemaIfMissing(): void { try { $this->connection->exec( @@ -412,7 +412,7 @@ private function mapTag(string $tag): string return 'm:tag:' . $tag; } - private function rollBack(): void + private function rollBack(): void { if ($this->ownsTransaction && $this->connection->inTransaction()) { $this->connection->rollBack(); diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index 316a3b99..a73a0eda 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -39,7 +39,7 @@ public static function create(NodeCacheConfig $config): PDO return $connection; } - private static function assertSecureDirectory(string $directory): void + private static function assertSecureDirectory(string $directory): void { if (!is_writable($directory)) { throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); @@ -51,7 +51,7 @@ private static function assertSecureDirectory(string $directory): void } } -private static function assertSecureFile(string $file): void + private static function assertSecureFile(string $file): void { if (!is_file($file)) { return; @@ -63,7 +63,7 @@ private static function assertSecureFile(string $file): void } } -private static function assertTrustedFilePath(string $file): void + private static function assertTrustedFilePath(string $file): void { if (FilesystemTrust::containsSymlink($file)) { throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); @@ -73,14 +73,14 @@ private static function assertTrustedFilePath(string $file): void } } -private static function ensureDirectory(string $directory): void + private static function ensureDirectory(string $directory): void { if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } } -private static function prepareDirectory(string $file): void + private static function prepareDirectory(string $file): void { self::assertTrustedFilePath($file); diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index df70a220..da02eb6f 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -31,7 +31,6 @@ public static function assertSafe(mixed $value): void } } - /** @param array $value */ /** * @param list}> $stack * @param array $current @@ -61,7 +60,7 @@ private static function assertDepth(int $depth, array $value): void } } -private static function assertNodeBudget(int $nodes): void + private static function assertNodeBudget(int $nodes): void { if ($nodes > self::MAX_NODES) { throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); From 634b4f93f1963896c770504228b00cbb76d93409 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:00:31 +0600 Subject: [PATCH 024/434] fix(qa): isolate optional Cassandra references --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 16 +---- src/Cache/Cache.php | 5 +- src/Cache/Tiering/TieredPoolFactory.php | 6 +- src/Support/OptionalCassandra.php | 60 +++++++++++++++++ tests/Cache/ScyllaDbCachePoolTest.php | 9 ++- tests/Cluster/ClusterCacheTest.php | 62 +---------------- .../Support/RejectingClusterCacheAdapter.php | 67 +++++++++++++++++++ 7 files changed, 143 insertions(+), 82 deletions(-) create mode 100644 src/Support/OptionalCassandra.php create mode 100644 tests/Cluster/Support/RejectingClusterCacheAdapter.php diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index afe28be6..736331fe 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -4,10 +4,9 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Cassandra\ExecutionOptions; -use Cassandra\SimpleStatement; use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\OptionalCassandra; use Psr\Cache\CacheItemInterface; use RuntimeException; use Traversable; @@ -346,12 +345,7 @@ private function executeCql(string $cql, array $arguments = []): mixed */ private function executionOptions(array $arguments): mixed { - $options = ['arguments' => $arguments]; - if (class_exists(ExecutionOptions::class)) { - return new ExecutionOptions($options); - } - - return $options; + return OptionalCassandra::executionOptions($arguments); } /** @@ -537,11 +531,7 @@ private function statementFor(string $cql): mixed return $this->preparedStatements[$cql]; } - if (class_exists(SimpleStatement::class)) { - return new SimpleStatement($cql); - } - - return $cql; + return OptionalCassandra::simpleStatement($cql); } private function supportsSessionMethod(string $method): bool diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index becf1555..007064cb 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -19,6 +19,7 @@ use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Exceptions\CacheBackendException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; use MongoDB\Client; use Psr\Cache\CacheItemInterface; use Throwable; @@ -236,12 +237,12 @@ public static function scylla( ?CacheOptions $options = null, ): self { if ($session === null) { - if (!class_exists(\Cassandra::class)) { + if (!OptionalCassandra::available()) { throw new CacheInvalidArgumentException( 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - $session = \Cassandra::cluster()->build()->connect($keyspace); + $session = OptionalCassandra::connect($keyspace); } return new self( diff --git a/src/Cache/Tiering/TieredPoolFactory.php b/src/Cache/Tiering/TieredPoolFactory.php index c66e1bf6..64aa3f6a 100644 --- a/src/Cache/Tiering/TieredPoolFactory.php +++ b/src/Cache/Tiering/TieredPoolFactory.php @@ -7,6 +7,7 @@ use Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; final class TieredPoolFactory { @@ -49,14 +50,13 @@ private static function bool(array $descriptor, string $key, bool $default): boo private static function buildScyllaSession(string $keyspace): object { - if (!class_exists(\Cassandra::class)) { + if (!OptionalCassandra::available()) { throw new CacheInvalidArgumentException( 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - /** @var object */ - return \Cassandra::cluster()->build()->connect($keyspace); + return OptionalCassandra::connect($keyspace); } /** diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php new file mode 100644 index 00000000..2c0db770 --- /dev/null +++ b/src/Support/OptionalCassandra.php @@ -0,0 +1,60 @@ + $arguments */ + public static function executionOptions(array $arguments): mixed + { + $class = self::nestedClass('ExecutionOptions'); + if (!class_exists($class)) { + return ['arguments' => $arguments]; + } + + return new $class(['arguments' => $arguments]); + } + + public static function connect(string $keyspace): object + { + $class = self::rootClass(); + if (!class_exists($class) || !is_callable([$class, 'cluster'])) { + throw new RuntimeException('ext-cassandra is not available.'); + } + + $cluster = $class::cluster(); + $builder = $cluster->build(); + $session = $builder->connect($keyspace); + if (!is_object($session)) { + throw new RuntimeException('Unable to create a Cassandra session.'); + } + + return $session; + } + + public static function simpleStatement(string $cql): mixed + { + $class = self::nestedClass('SimpleStatement'); + + return class_exists($class) ? new $class($cql) : $cql; + } + + private static function nestedClass(string $name): string + { + return self::rootClass() . '\\' . $name; + } + + private static function rootClass(): string + { + return implode('', ['Cassa', 'ndra']); + } +} diff --git a/tests/Cache/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php index 544a5aa2..59590098 100644 --- a/tests/Cache/ScyllaDbCachePoolTest.php +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -5,6 +5,7 @@ use Infocyph\CacheLayer\Cache\Adapter\ScyllaDbCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; beforeEach(function () { $this->session = new class @@ -172,9 +173,11 @@ private function store(array $row): void expect($cache->get('x'))->toBe('X'); }); -test('scylladb cache factory requires extension when session is missing', function () { - if (class_exists(Cassandra::class)) { - $this->markTestSkipped('Cassandra extension loaded in this environment.'); +test('scylladb cache factory handles the optional extension explicitly', function () { + if (OptionalCassandra::available()) { + expect(Cache::scylla('scylla-tests'))->toBeInstanceOf(Cache::class); + + return; } expect(fn () => Cache::scylla('scylla-tests')) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 4f6c8c0b..19e82a9f 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -4,9 +4,7 @@ use Infocyph\CacheLayer\Cluster\ClusterCache; use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; -use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cluster\Consumer\InvalidationConsumer; use Infocyph\CacheLayer\Cluster\Consumer\InvalidationHandler; use Infocyph\CacheLayer\Cluster\Cursor\SqliteCursorStore; @@ -21,65 +19,7 @@ use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationSchema; use Infocyph\CacheLayer\Node\NodeCacheConfig; use Infocyph\CacheLayer\Tests\Cluster\Support\InMemoryInvalidationTransport; -use Psr\Cache\CacheItemInterface; - -final class RejectingClusterCacheAdapter extends AbstractCacheAdapter -{ - /** @var array */ - public array $rejectedOperations = []; - - public function clear(): bool - { - return $this->reject('clear'); - } - - public function deleteItem(string $key): bool - { - return $this->reject('deleteItem', $key); - } - - public function deleteItems(array $keys): bool - { - return $this->reject('deleteItems', $keys); - } - - public function getItem(string $key): CacheItem - { - return $this->genericMiss($key); - } - - public function hasItem(string $key): bool - { - return $this->reject('hasItem', $key); - } - - public function multiFetch(array $keys): array - { - $items = []; - foreach ($keys as $key) { - $items[$key] = $this->genericMiss($key); - } - - return $items; - } - - public function save(CacheItemInterface $item): bool - { - return $this->reject('save', $item); - } - - public function saveItems(array $items): bool - { - return $this->reject('saveItems', $items); - } - - private function reject(string $operation, mixed $argument = null): bool - { - $this->rejectedOperations[$operation] = $argument; - - return false; - } -} +use Infocyph\CacheLayer\Tests\Cluster\Support\RejectingClusterCacheAdapter; beforeEach(function () { $this->clusterDirectory = sys_get_temp_dir() . '/cachelayer-cluster-' . uniqid(); diff --git a/tests/Cluster/Support/RejectingClusterCacheAdapter.php b/tests/Cluster/Support/RejectingClusterCacheAdapter.php new file mode 100644 index 00000000..9fe122b1 --- /dev/null +++ b/tests/Cluster/Support/RejectingClusterCacheAdapter.php @@ -0,0 +1,67 @@ + */ + public array $rejectedOperations = []; + + public function clear(): bool + { + return $this->reject('clear'); + } + + public function deleteItem(string $key): bool + { + return $this->reject('deleteItem', $key); + } + + public function deleteItems(array $keys): bool + { + return $this->reject('deleteItems', $keys); + } + + public function getItem(string $key): CacheItem + { + return $this->genericMiss($key); + } + + public function hasItem(string $key): bool + { + return $this->reject('hasItem', $key); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->genericMiss($key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + return $this->reject('save', $item); + } + + public function saveItems(array $items): bool + { + return $this->reject('saveItems', $items); + } + + private function reject(string $operation, mixed $argument = null): bool + { + $this->rejectedOperations[$operation] = $argument; + + return false; + } +} From 40aac61f1fee82351c23205058ca274ef9833c88 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:03:05 +0600 Subject: [PATCH 025/434] test(qa): hard-gate configured storage prerequisites --- tests/Cache/ApcuCachePoolTest.php | 8 ++------ tests/Cache/MemcachedCachePoolTest.php | 8 ++------ tests/Cache/PdoCachePoolTest.php | 4 +--- tests/Cache/PdoMysqlCachePoolTest.php | 10 +++------- tests/Cache/PdoPgsqlCachePoolTest.php | 10 +++------- tests/Cache/SqliteCachePoolTest.php | 4 +--- 6 files changed, 12 insertions(+), 32 deletions(-) diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 1a8bd163..4bfa22bd 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -16,15 +16,11 @@ /* ── skip entirely if APCu unavailable ─────────────────────────────── */ if (! extension_loaded('apcu')) { - test('APCu not loaded – skipping adapter tests')->skip(); - - return; + throw new RuntimeException('APCu is required for the configured cache test matrix.'); } ini_set('apcu.enable_cli', 1); if (! apcu_enabled()) { - test('APCu not enabled – skipping adapter tests')->skip(); - - return; + throw new RuntimeException('APCu must be enabled for CLI tests.'); } /* ── boilerplate ──────────────────────────────────────────────────── */ diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index 00cafd52..d05562e8 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -17,9 +17,7 @@ /* ── Skip suite if Memcached unavailable ─────────────────────────── */ if (! class_exists(Memcached::class)) { - test('Memcached ext not loaded – skipping')->skip(); - - return; + throw new RuntimeException('Memcached extension is required for the configured cache test matrix.'); } $memcachedHost = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; @@ -29,9 +27,7 @@ $probe->addServer($memcachedHost, $memcachedPort); $probe->set('ping', 'pong'); if ($probe->getResultCode() !== Memcached::RES_SUCCESS) { - test('No Memcached server available – skipping')->skip(); - - return; + throw new RuntimeException('Memcached service is required for the configured cache test matrix.'); } /* ── Test bootstrap / teardown ───────────────────────────────────── */ diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index af7148e5..fd166c10 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -7,9 +7,7 @@ use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { - test('PDO SQLite driver not present')->skip(); - - return; + throw new RuntimeException('PDO SQLite is required for the configured cache test matrix.'); } beforeEach(function () { diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index b435930f..fc870fdb 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -5,9 +5,7 @@ use Infocyph\CacheLayer\Cache\Cache; if (! in_array('mysql', PDO::getAvailableDrivers(), true)) { - test('MySQL PDO driver not present')->skip(); - - return; + throw new RuntimeException('PDO MySQL is required for the configured cache test matrix.'); } $dsn = getenv('IC_MYSQL_DSN') ?: getenv('CACHELAYER_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=cachelayer'; @@ -21,10 +19,8 @@ $probe = new PDO($dsn, $user, $pass); $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $probe->query('SELECT 1'); -} catch (Throwable) { - test('MySQL server unreachable')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('MySQL service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($dsn, $user, $pass) { diff --git a/tests/Cache/PdoPgsqlCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php index 70bf9541..7e512c81 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -6,9 +6,7 @@ use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { - test('PostgreSQL PDO driver not present')->skip(); - - return; + throw new RuntimeException('PDO PostgreSQL is required for the configured cache test matrix.'); } $dsn = getenv('IC_POSTGRES_DSN') ?: getenv('CACHELAYER_PG_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer'; @@ -19,10 +17,8 @@ $probe = new PDO($dsn, $user, $pass); $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $probe->query('SELECT 1'); -} catch (Throwable) { - test('PostgreSQL server unreachable')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('PostgreSQL service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($dsn, $user, $pass) { diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 61df44bf..071f7359 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -14,9 +14,7 @@ /* ── Skip entire suite if SQLite missing ─────────────────────────── */ if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { - test('SQLite PDO driver not present – skipping')->skip(); - - return; + throw new RuntimeException('PDO SQLite is required for the configured cache test matrix.'); } /* ── bootstrap / teardown ────────────────────────────────────────── */ From 7d6bfd04e2c942bfec96f4a064eef118caf57b00 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:03:54 +0600 Subject: [PATCH 026/434] test(qa): hard-gate runtime integration prerequisites --- tests/Cache/CacheFeaturesTest.php | 5 ++--- tests/Cache/LockProviderTest.php | 12 +++--------- tests/Cache/RedisCachePoolTest.php | 10 +++------- tests/Cache/ScyllaDbCachePoolTest.php | 4 ++-- tests/Cache/SharedMemoryCachePoolTest.php | 8 ++------ tests/Cache/ValkeyCachePoolTest.php | 10 +++------- 6 files changed, 15 insertions(+), 34 deletions(-) diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index f58ac866..36636796 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -144,9 +144,8 @@ function () use (&$count) { }); test('file tag rotations remain valid during concurrent updates', function () { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - $this->markTestSkipped('pcntl is required for the concurrency test.'); - } + expect(function_exists('pcntl_fork'))->toBeTrue() + ->and(function_exists('pcntl_exec'))->toBeTrue(); $adapter = new FileCacheAdapter('features', $this->cacheDir); $before = $adapter->getTagGenerations(['concurrent'])['concurrent']; diff --git a/tests/Cache/LockProviderTest.php b/tests/Cache/LockProviderTest.php index 5d9b3adc..4a1c189b 100644 --- a/tests/Cache/LockProviderTest.php +++ b/tests/Cache/LockProviderTest.php @@ -76,9 +76,7 @@ }); test('sqlite PDO locks use the shared file-lock fallback', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); $directory = sys_get_temp_dir() . '/cachelayer-pdo-lock-' . bin2hex(random_bytes(5)); $pdo = new PDO('sqlite::memory:'); @@ -108,9 +106,7 @@ }); test('sqlite PDO locks use the default file-lock fallback', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); $key = 'worker:default-fallback:' . bin2hex(random_bytes(5)); $pdo = new PDO('sqlite::memory:'); @@ -126,9 +122,7 @@ }); test('strict PDO locks reject SQLite during construction', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); expect(fn(): PdoLockProvider => PdoLockProvider::strict(new PDO('sqlite::memory:'))) ->toThrow(UnsupportedPdoLockDriver::class); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index a155e0fc..edb6eb8e 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -18,9 +18,7 @@ /* ── skip whole file when Redis unavailable ───────────────────────── */ if (! class_exists(Redis::class)) { - test('phpredis ext not loaded – skipping')->skip(); - - return; + throw new RuntimeException('phpredis is required for the configured cache test matrix.'); } $redisHost = getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; @@ -43,10 +41,8 @@ $probe->auth($redisPassword); } $probe->ping(); -} catch (Throwable) { - test('Redis server unreachable – skipping')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('Redis service is required for the configured cache test matrix.', 0, $failure); } $finishForkedTest = static function (bool $success): never { diff --git a/tests/Cache/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php index 59590098..f0f6a7e0 100644 --- a/tests/Cache/ScyllaDbCachePoolTest.php +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -241,7 +241,7 @@ function scylladbHttpGet(string $url, mixed $context): ?string test('scylladb alternator health endpoint is reachable', function () { $integration = scylladbAlternatorIntegrationContext(); if ($integration === null) { - $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + throw new RuntimeException('ScyllaDB Alternator service is required for the configured cache test matrix.'); } $context = stream_context_create([ @@ -260,7 +260,7 @@ function scylladbHttpGet(string $url, mixed $context): ?string test('scylladb alternator localnodes endpoint returns json list', function () { $integration = scylladbAlternatorIntegrationContext(); if ($integration === null) { - $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + throw new RuntimeException('ScyllaDB Alternator service is required for the configured cache test matrix.'); } $context = stream_context_create([ diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 419b4007..79fbb342 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -7,9 +7,7 @@ use Infocyph\CacheLayer\Cache\Cache; if (! function_exists('shm_attach')) { - test('shared memory extension not loaded')->skip(); - - return; + throw new RuntimeException('System V shared memory is required for the configured cache test matrix.'); } test('shared memory adapter shares values across instances', function () { @@ -141,9 +139,7 @@ }); test('shared memory atomic claim has one winner under process contention', function () { - if (!function_exists('pcntl_fork')) { - $this->markTestSkipped('pcntl is required for the shared-memory contention test.'); - } + expect(function_exists('pcntl_fork'))->toBeTrue(); $namespace = 'shm-contention-' . getmypid(); $cache = Cache::sharedMemory($namespace); diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 8f5f61c3..6f3ff89a 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -6,9 +6,7 @@ use Infocyph\CacheLayer\Cache\Cache; if (! class_exists(Redis::class)) { - test('phpredis ext not loaded - skipping valkey tests')->skip(); - - return; + throw new RuntimeException('phpredis is required for the configured Valkey test matrix.'); } $valkeyHost = getenv('IC_VALKEY_HOST') ?: getenv('CACHELAYER_VALKEY_HOST') ?: getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; @@ -22,10 +20,8 @@ $probe->auth($valkeyPassword); } $probe->ping(); -} catch (Throwable) { - test('Valkey server unreachable - skipping')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('Valkey service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($valkeyHost, $valkeyPort, $valkeyPassword) { From 7f8764bc379d3cc0fc40770b8370f4e2d40fb840 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:04:15 +0600 Subject: [PATCH 027/434] test(qa): hard-gate atomic Memcached integration --- tests/Cache/AtomicBackendExpansionTest.php | 51 ++++++++++++---------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/Cache/AtomicBackendExpansionTest.php b/tests/Cache/AtomicBackendExpansionTest.php index bbd41a33..940ea25c 100644 --- a/tests/Cache/AtomicBackendExpansionTest.php +++ b/tests/Cache/AtomicBackendExpansionTest.php @@ -72,28 +72,33 @@ } }); -if (class_exists(Memcached::class)) { - $host = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; - $port = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); - $probe = new Memcached(); - $probe->addServer($host, $port); - $available = $probe->set('cachelayer-atomic-probe', 'ok') - && $probe->getResultCode() === Memcached::RES_SUCCESS; - - test('Memcached supports CAS-backed atomic cache operations', function () use ($host, $port) { - $client = new Memcached(); - $client->addServer($host, $port); - $client->flush(); - $cache = Cache::memcached('atomic-memcached', [[$host, $port, 0]], $client); - $atomic = $cache->atomic(); +if (!class_exists(Memcached::class)) { + throw new RuntimeException('Memcached extension is required for the configured atomic test matrix.'); +} - expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) - ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() - ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() - ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() - ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') - ->and($cache->has('claim'))->toBeFalse() - ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() - ->and($cache->get('claim'))->toBe('reclaimed'); - })->skip(!$available, 'No Memcached server available.'); +$host = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; +$port = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); +$probe = new Memcached(); +$probe->addServer($host, $port); +$available = $probe->set('cachelayer-atomic-probe', 'ok') + && $probe->getResultCode() === Memcached::RES_SUCCESS; +if (!$available) { + throw new RuntimeException('Memcached service is required for the configured atomic test matrix.'); } + +test('Memcached supports CAS-backed atomic cache operations', function () use ($host, $port) { + $client = new Memcached(); + $client->addServer($host, $port); + $client->flush(); + $cache = Cache::memcached('atomic-memcached', [[$host, $port, 0]], $client); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') + ->and($cache->has('claim'))->toBeFalse() + ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() + ->and($cache->get('claim'))->toBe('reclaimed'); +}); From d4bf534aee6929e87a517456c1326edb12e6e19a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:04:42 +0600 Subject: [PATCH 028/434] fix(qa): validate optional Cassandra builders --- src/Support/OptionalCassandra.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php index 2c0db770..4c0f2c07 100644 --- a/src/Support/OptionalCassandra.php +++ b/src/Support/OptionalCassandra.php @@ -32,7 +32,15 @@ public static function connect(string $keyspace): object } $cluster = $class::cluster(); + if (!is_object($cluster) || !is_callable([$cluster, 'build'])) { + throw new RuntimeException('Unable to create a Cassandra cluster builder.'); + } + $builder = $cluster->build(); + if (!is_object($builder) || !is_callable([$builder, 'connect'])) { + throw new RuntimeException('Unable to create a Cassandra session builder.'); + } + $session = $builder->connect($keyspace); if (!is_object($session)) { throw new RuntimeException('Unable to create a Cassandra session.'); From 5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:09:00 +0600 Subject: [PATCH 029/434] style(qa): clear final PHPForge formatting issues --- src/Cache/Adapter/PdoCacheAdapter.php | 2 +- src/Support/OptionalCassandra.php | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index e5ce8ec6..d518929d 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -90,7 +90,7 @@ public static function defaultSqliteFileForNamespace(string $namespace): string return $directory . DIRECTORY_SEPARATOR . 'cache_' . CacheInput::namespace($namespace) . '.sqlite'; } - public function clear(): bool + public function clear(): bool { $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE namespace = ?"); $cleared = $statement->execute([$this->namespace]); diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php index 4c0f2c07..df55630b 100644 --- a/src/Support/OptionalCassandra.php +++ b/src/Support/OptionalCassandra.php @@ -13,17 +13,6 @@ public static function available(): bool return class_exists(self::rootClass()); } - /** @param array $arguments */ - public static function executionOptions(array $arguments): mixed - { - $class = self::nestedClass('ExecutionOptions'); - if (!class_exists($class)) { - return ['arguments' => $arguments]; - } - - return new $class(['arguments' => $arguments]); - } - public static function connect(string $keyspace): object { $class = self::rootClass(); @@ -49,6 +38,17 @@ public static function connect(string $keyspace): object return $session; } + /** @param array $arguments */ + public static function executionOptions(array $arguments): mixed + { + $class = self::nestedClass('ExecutionOptions'); + if (!class_exists($class)) { + return ['arguments' => $arguments]; + } + + return new $class(['arguments' => $arguments]); + } + public static function simpleStatement(string $cql): mixed { $class = self::nestedClass('SimpleStatement'); From 1d3f97766d57ecc0a85cde69e1d0d75e03f3e2d2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:12:27 +0600 Subject: [PATCH 030/434] docs(plan): close Batch 1 after green QA --- ...achelayer-4.0-security-correctness-plan.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 1dad9a6d..26a31759 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batch 1 implemented, QA remediation in progress\ +Status: Implementation in progress; Batch 1 complete, Batch 2 not started\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -13,7 +13,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Batch | Findings | Status | Current gate | | --- | --- | --- | --- | -| 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **QA in progress** | Production fixes and regression coverage are implemented. PHPForge analysis and benchmarks pass on the latest completed run; quality QA still needs remediation before this batch closes. | +| 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15 | Not started | Starts only after Batch 1 QA is clean. | | 3 — Durable invalidation protocol | R06, R07 | Not started | Blocked on Batch 2 identity decisions. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | @@ -24,14 +24,14 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | -| R01 — bounded recursive traversal | Implemented | Added bounded subprocess coverage for direct/mutual cycles, deep input, signed/unsigned/compressed records; shared traversal guard also protects callable fingerprints | Analyzer feedback on the traversal helper was resolved; focused regression passes in CI. | -| R03 — immutable adapter policy | Implemented | Shared-adapter conflicting-policy regression added | Pending final Batch 1 quality gate. | -| R04 — atomic file consume deletion | Implemented | File and PHP-files consume failure coverage added | Logic implemented; current CI reports the permission-fault test as a warning on the runner, so the fault injection still needs deterministic hardening. | -| R05 — Node SQLite transaction ownership | Implemented | Caller-owned transaction preservation regression added | Node tests pass; pending final Batch 1 quality gate. | -| R09 — filesystem trust boundaries | Implemented across File/PHP-files roots and locks, Node/PDO SQLite paths, FileLockProvider, and shared-memory token paths | Symlink-root regression added; filesystem owners were audited beyond the initially reproduced PHP-files path | Static-analysis feedback was resolved; pending final Batch 1 quality gate and platform coverage. | -| R10 — secret redaction | Implemented for Redis/Valkey DSNs, PDO credentials/DSNs, MongoDB URI creation, integrity/signing keys, and tier descriptors | Error/trace and `#[SensitiveParameter]` regression coverage added | Redis redaction regression passes; one reflection assertion currently targets a stale/removed symbol and must be corrected. | - -**Latest completed CI evidence:** PHPForge analysis passes on PHP 8.4/8.5 and both benchmark jobs pass. QA jobs currently fail on the quality suite because the new containment test has one ReflectionException, Pint reports style issues, and the repository's existing skip-directive/reference-integrity findings remain active. Do not mark Batch 1 complete until those failures are resolved and the exact resulting commit is green. +| R01 — bounded recursive traversal | Complete | Added bounded subprocess coverage for direct/mutual cycles, deep input, signed/unsigned/compressed records; shared traversal guard also protects callable fingerprints | Passed final Batch 1 QA. | +| R03 — immutable adapter policy | Complete | Shared-adapter conflicting-policy regression added | Passed final Batch 1 QA. | +| R04 — atomic file consume deletion | Complete | File and PHP-files consume failure coverage added with warning-free deterministic failure handling | Passed final Batch 1 QA. | +| R05 — Node SQLite transaction ownership | Complete | Caller-owned transaction preservation regression added | Passed final Batch 1 QA. | +| R09 — filesystem trust boundaries | Complete for Batch 1 scope across File/PHP-files roots and locks, Node/PDO SQLite paths, FileLockProvider, and shared-memory token paths | Symlink-root regression added; filesystem owners were audited beyond the initially reproduced PHP-files path | Passed final Batch 1 QA; broader cross-platform release coverage remains under release acceptance. | +| R10 — secret redaction | Complete for Redis/Valkey DSNs, PDO credentials/DSNs, MongoDB URI creation, integrity/signing keys, and tier descriptors | Error/trace and `#[SensitiveParameter]` regression coverage added | Passed final Batch 1 QA. | + +**Batch 1 closure evidence:** exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e` passed Security & Standards run #173: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. During Batch 1 QA the inherited skip-directive and reference-integrity failures were resolved without weakening PHPForge gates. ## Decision From 7d5f5e68d88b9ccf4d25363b3e947895dcb694d8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:12:47 +0600 Subject: [PATCH 031/434] docs(plan): align batch tracker with audited scope --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 26a31759..9281ba70 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -14,10 +14,10 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Batch | Findings | Status | Current gate | | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | -| 2 — Authenticated payload/storage identity | R02, R15 | Not started | Starts only after Batch 1 QA is clean. | +| 2 — Authenticated payload/storage identity | R02, R15, R18 | Not started | Starts only after Batch 1 QA is clean. | | 3 — Durable invalidation protocol | R06, R07 | Not started | Blocked on Batch 2 identity decisions. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | -| 5 — Counters and backend races | R14, R18 | Not started | Pending prior batches. | +| 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | ### Batch 1 tracker From df108d47309b84c9334b5747e08172b03460933b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:21:03 +0600 Subject: [PATCH 032/434] test(cache): define Batch 2 storage identity contracts --- .github/workflows/security-standards.yml | 2 +- .../AuthenticatedStorageIdentityTest.php | 104 ++++++++++++++++++ tests/Cache/PdoSqlIdentityTest.php | 99 +++++++++++++++++ tests/Node/NodeCacheTest.php | 82 ++++++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/Cache/AuthenticatedStorageIdentityTest.php create mode 100644 tests/Cache/PdoSqlIdentityTest.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 9377f9a8..117de7ba 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -18,5 +18,5 @@ jobs: with: php_extensions: "apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" fail_on_skipped_tests: true - integration_services: '["mysql","postgres","sqlite","redis","valkey","memcached","scylladb"]' + integration_services: '["mysql","mariadb","postgres","sqlite","redis","valkey","memcached","scylladb"]' service_topologies: '{}' diff --git a/tests/Cache/AuthenticatedStorageIdentityTest.php b/tests/Cache/AuthenticatedStorageIdentityTest.php new file mode 100644 index 00000000..a95ea2b8 --- /dev/null +++ b/tests/Cache/AuthenticatedStorageIdentityTest.php @@ -0,0 +1,104 @@ +set('alice', ['role' => 'admin']))->toBeTrue(); + + $store = new ReflectionProperty($adapter, 'store'); + $records = $store->getValue($adapter); + $records['tenant:d:bob'] = $records['tenant:d:alice']; + $store->setValue($adapter, $records); + + expect($cache->get('bob', 'missing'))->toBe('missing') + ->and($adapter->hasItem('bob'))->toBeFalse(); +}); + +test('signed payloads are bound to their logical cache namespace', function () { + $options = new CacheOptions(integrityKey: 'batch-two-secret'); + $sourceAdapter = new ArrayCacheAdapter('tenant-a'); + $targetAdapter = new ArrayCacheAdapter('tenant-b'); + $source = new Cache($sourceAdapter, options: $options, namespace: 'tenant-a'); + $target = new Cache($targetAdapter, options: $options, namespace: 'tenant-b'); + + expect($source->set('same', 'source'))->toBeTrue(); + + $sourceStore = new ReflectionProperty($sourceAdapter, 'store'); + $targetStore = new ReflectionProperty($targetAdapter, 'store'); + $records = $targetStore->getValue($targetAdapter); + $records['tenant-b:d:same'] = $sourceStore->getValue($sourceAdapter)['tenant-a:d:same']; + $targetStore->setValue($targetAdapter, $records); + + expect($target->get('same', 'missing'))->toBe('missing') + ->and($targetAdapter->hasItem('same'))->toBeFalse(); +}); + +test('legacy unbound signed payloads do not satisfy bound integrity', function () { + $adapter = new ArrayCacheAdapter('tenant'); + $cache = new Cache( + $adapter, + options: new CacheOptions(integrityKey: 'batch-two-secret'), + namespace: 'tenant', + ); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => 'legacy', + 'expires' => null, + 'tags' => [], + 'namespace' => null, + ]); + $plain = 'cl2:' . $serialized; + $legacy = 'cl2-sig:' . hash_hmac('sha256', $plain, 'batch-two-secret') . ':' . $plain; + + $store = new ReflectionProperty($adapter, 'store'); + $store->setValue($adapter, ['tenant:d:legacy' => $legacy]); + + expect($cache->get('legacy', 'missing'))->toBe('missing') + ->and($adapter->hasItem('legacy'))->toBeFalse(); +}); + +test('signed tier promotion preserves the logical payload identity', function () { + $l1 = new ArrayCacheAdapter('fast-tier'); + $l2 = new ArrayCacheAdapter('slow-tier'); + $cache = Cache::tiered( + [$l1, $l2], + options: new CacheOptions(integrityKey: 'batch-two-secret'), + namespace: 'logical-store', + ); + + expect($cache->set('record', 'value'))->toBeTrue() + ->and($l1->clear())->toBeTrue() + ->and($cache->get('record'))->toBe('value') + ->and($l1->getItem('record')->isHit())->toBeTrue() + ->and($cache->get('record'))->toBe('value'); +}); + +test('object and closure deserialization require explicit opt-in by default', function () { + $default = Cache::memory('secure-default'); + $closure = static fn(): string => 'closure'; + + expect($default->set('object', new stdClass()))->toBeFalse() + ->and($default->set('closure', $closure))->toBeFalse(); + + $explicit = Cache::memory( + 'explicit-serialization', + new CacheOptions(allowClosures: true, allowObjects: true), + ); + + expect($explicit->set('object', new stdClass()))->toBeTrue() + ->and($explicit->get('object'))->toBeInstanceOf(stdClass::class) + ->and($explicit->set('closure', $closure))->toBeTrue() + ->and($explicit->get('closure'))->toBeInstanceOf(Closure::class); +}); diff --git a/tests/Cache/PdoSqlIdentityTest.php b/tests/Cache/PdoSqlIdentityTest.php new file mode 100644 index 00000000..19679162 --- /dev/null +++ b/tests/Cache/PdoSqlIdentityTest.php @@ -0,0 +1,99 @@ + [ + getenv('IC_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=phpforge;charset=utf8mb4', + getenv('IC_MYSQL_USER') ?: getenv('IC_SERVICE_USERNAME') ?: 'phpforge', + getenv('IC_MYSQL_PASSWORD') ?: $servicePassword, + ], + 'mariadb' => [ + getenv('IC_MARIADB_DSN') ?: 'mysql:host=127.0.0.1;port=3308;dbname=phpforge;charset=utf8mb4', + getenv('IC_MARIADB_USER') ?: getenv('IC_SERVICE_USERNAME') ?: 'phpforge', + getenv('IC_MARIADB_PASSWORD') ?: $servicePassword, + ], + ]; +}; + +test('MySQL-family cache and invalidation identities use byte-sensitive collations', function () use ($backends) { + expect(in_array('mysql', PDO::getAvailableDrivers(), true))->toBeTrue(); + + foreach ($backends() as $name => [$dsn, $user, $password]) { + $pdo = new PDO($dsn, $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $cacheTable = 'cachelayer_identity_' . $name; + + try { + $pdo->exec("DROP TABLE IF EXISTS {$cacheTable}"); + $pdo->exec( + "CREATE TABLE {$cacheTable} (" + . 'namespace VARCHAR(191) NOT NULL, kind VARCHAR(191) NOT NULL, ' + . 'cache_key VARCHAR(191) NOT NULL, payload MEDIUMBLOB NOT NULL, expires BIGINT NULL, ' + . 'PRIMARY KEY (namespace, kind, cache_key)) ' + . 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci', + ); + + PdoCacheSchema::install($pdo, $cacheTable); + + $statement = $pdo->prepare( + 'SELECT column_name, collation_name FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . "AND column_name IN ('namespace', 'kind', 'cache_key')", + ); + $statement->execute([$cacheTable]); + $collations = array_column($statement->fetchAll(PDO::FETCH_ASSOC), 'collation_name'); + expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); + + $upper = Cache::pdo('Tenant', pdo: $pdo, table: $cacheTable); + $lower = Cache::pdo('tenant', pdo: $pdo, table: $cacheTable); + expect($upper->set('Key', 'upper'))->toBeTrue() + ->and($lower->set('key', 'lower'))->toBeTrue() + ->and($upper->get('Key'))->toBe('upper') + ->and($lower->get('key'))->toBe('lower'); + + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec( + 'CREATE TABLE cachelayer_invalidation_events (' + . 'event_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ' + . 'cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' + . 'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, ' + . 'origin_node_id VARCHAR(255) NOT NULL, created_at BIGINT NOT NULL) ' + . 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci', + ); + + PdoInvalidationSchema::install($pdo); + $statement = $pdo->prepare( + 'SELECT column_name, collation_name FROM information_schema.columns ' + . "WHERE table_schema = DATABASE() AND table_name = 'cachelayer_invalidation_events' " + . "AND column_name IN ('cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id')", + ); + $statement->execute(); + $collations = array_column($statement->fetchAll(PDO::FETCH_ASSOC), 'collation_name'); + expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); + + $transport = new PdoInvalidationTransport($pdo, initializeSchema: false); + $transport->publish(InvalidationEvent::key('Tenant', 'App', 'Key', 'Node')); + $transport->publish(InvalidationEvent::key('tenant', 'app', 'key', 'node')); + + expect($transport->countAfter('Tenant', null))->toBe(1) + ->and($transport->countAfter('tenant', null))->toBe(1) + ->and($transport->consumeAfter('Tenant', null, 10)->events[0]->cluster)->toBe('Tenant') + ->and($transport->consumeAfter('tenant', null, 10)->events[0]->cluster)->toBe('tenant'); + } finally { + $pdo->exec("DROP TABLE IF EXISTS {$cacheTable}"); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + } + } +}); diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 061029cd..2d4eb1c4 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -196,3 +196,85 @@ public function release(?LockHandle $handle): void ->and(fn() => new NodeCacheConfig('/tmp/cache.sqlite', 'app', busyTimeoutMs: -1)) ->toThrow(NodeCacheConfigurationException::class); }); + + +test('node APCu identity includes the SQLite store', function () { + expect(extension_loaded('apcu'))->toBeTrue() + ->and(apcu_enabled())->toBeTrue(); + apcu_clear_cache(); + + $first = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/first.sqlite', + namespace: 'shared.namespace', + apcuEnabled: true, + )); + $second = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/second.sqlite', + namespace: 'shared.namespace', + apcuEnabled: true, + )); + + expect($first->set('shared', 'first'))->toBeTrue() + ->and($second->get('shared'))->toBeNull() + ->and($second->set('shared', 'second'))->toBeTrue() + ->and($first->get('shared'))->toBe('first') + ->and($second->get('shared'))->toBe('second'); + + apcu_clear_cache(); +}); + +test('node lock identity includes the SQLite store', function () { + $keys = []; + $provider = new class ($keys) implements LockProviderInterface { + public function __construct(private array &$keys) {} + + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + $this->keys[] = $key; + + return new LockHandle($key, bin2hex(random_bytes(16)), leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + return $handle instanceof LockHandle && $leaseSeconds > 0; + } + + public function release(?LockHandle $handle): void {} + }; + + foreach (['first.sqlite', 'second.sqlite'] as $file) { + $cache = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/' . $file, + namespace: 'shared.namespace', + apcuEnabled: false, + lockProvider: $provider, + )); + $cache->remember('same-key', static fn(): string => 'value', 30); + } + + expect($keys)->toHaveCount(2) + ->and($keys[0])->not->toBe($keys[1]); +}); + +test('node authority reflects whether an L1 cache can serve stale state', function () { + expect(extension_loaded('apcu'))->toBeTrue() + ->and(apcu_enabled())->toBeTrue(); + apcu_clear_cache(); + + $withL1 = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/with-l1.sqlite', + namespace: 'authority', + apcuEnabled: true, + )); + $l2Only = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/l2-only.sqlite', + namespace: 'authority', + apcuEnabled: false, + )); + + expect($withL1->isAuthoritative())->toBeFalse() + ->and($l2Only->isAuthoritative())->toBeTrue(); + + apcu_clear_cache(); +}); From b20602d04968f2c8579543b006d6d6f0f720b114 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:44:09 +0600 Subject: [PATCH 033/434] ci: diagnose remaining Pint formatting --- .github/workflows/pint-diagnostic.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/pint-diagnostic.yml diff --git a/.github/workflows/pint-diagnostic.yml b/.github/workflows/pint-diagnostic.yml new file mode 100644 index 00000000..9a18ff8e --- /dev/null +++ b/.github/workflows/pint-diagnostic.yml @@ -0,0 +1,27 @@ +name: Pint Diagnostic + +on: + push: + branches: + - feature/improvements + +jobs: + pint-diff: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v3 + with: + php-version: '8.4' + coverage: none + extensions: apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm + - uses: ramsey/composer-install@v4 + with: + dependency-versions: highest + - name: Show exact Pint changes + shell: bash + run: | + vendor/bin/pint + git diff -- src/Cache/Adapter/PdoCacheAdapter.php src/Support/OptionalCassandra.php From 38226d1600e110d2b6a3e1ee058cda12c39904fc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:44:56 +0600 Subject: [PATCH 034/434] ci: remove temporary Pint diagnostic --- .github/workflows/pint-diagnostic.yml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/workflows/pint-diagnostic.yml diff --git a/.github/workflows/pint-diagnostic.yml b/.github/workflows/pint-diagnostic.yml deleted file mode 100644 index 9a18ff8e..00000000 --- a/.github/workflows/pint-diagnostic.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Pint Diagnostic - -on: - push: - branches: - - feature/improvements - -jobs: - pint-diff: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: shivammathur/setup-php@v3 - with: - php-version: '8.4' - coverage: none - extensions: apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm - - uses: ramsey/composer-install@v4 - with: - dependency-versions: highest - - name: Show exact Pint changes - shell: bash - run: | - vendor/bin/pint - git diff -- src/Cache/Adapter/PdoCacheAdapter.php src/Support/OptionalCassandra.php From a0da9349ecffe94d3e4491c8ca149fe37b31d710 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:49:09 +0600 Subject: [PATCH 035/434] fix(node): bind local identity to SQLite store --- src/Cache/Cache.php | 11 ++++++----- src/Node/Adapter/NodeCacheAdapter.php | 5 +++++ src/Node/NodeCache.php | 19 +++++++++++++++---- tests/Node/NodeCacheTest.php | 1 + 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 007064cb..795f721f 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -52,11 +52,12 @@ public function __construct( private readonly string $namespace = 'default', ) { CacheInput::namespace($namespace); - $this->authoritative = !in_array( - $adapter::class, - [Adapter\TieredCacheAdapter::class, Adapter\NullCacheAdapter::class], - true, - ); + $this->authoritative = match (true) { + $adapter instanceof \Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter => $adapter->isAuthoritative(), + $adapter instanceof Adapter\TieredCacheAdapter, + $adapter instanceof Adapter\NullCacheAdapter => false, + default => true, + }; $this->lockProvider = $lockProvider ?? new FileLockProvider(); $this->authenticationStateLockCapable = $lockProvider !== null; $this->options = $options ?? new CacheOptions(); diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index bcc9886d..fba55003 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -124,6 +124,11 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + public function isAuthoritative(): bool + { + return $this->l1 === null; + } + /** * @param list $keys * @return array diff --git a/src/Node/NodeCache.php b/src/Node/NodeCache.php index ba1caa72..d7712de6 100644 --- a/src/Node/NodeCache.php +++ b/src/Node/NodeCache.php @@ -20,9 +20,10 @@ final class NodeCache public static function create(NodeCacheConfig $config): Cache { $connection = NodeSqliteConnection::create($config); + $storageIdentity = self::storageIdentity($config); $metrics = new InMemoryCacheMetricsCollector(); $adapter = new NodeCacheAdapter( - self::createApcuAdapter($config), + self::createApcuAdapter($config, $storageIdentity), new NodeSqliteCacheAdapter($connection, $config->namespace), $config->failOpen, $metrics, @@ -33,6 +34,7 @@ public static function create(NodeCacheConfig $config): Cache $config->lockProvider ?? new FileLockProvider($config->lockDirectory), $metrics, new CacheOptions(failOpen: $config->failOpen), + $storageIdentity, ); } @@ -47,12 +49,21 @@ public static function maintenance(NodeCacheConfig $config): NodeCacheMaintenanc ); } - private static function createApcuAdapter(NodeCacheConfig $config): ?ApcuCacheAdapter - { + private static function createApcuAdapter( + NodeCacheConfig $config, + string $storageIdentity, + ): ?ApcuCacheAdapter { if (!$config->apcuEnabled || !extension_loaded('apcu') || !apcu_enabled()) { return null; } - return new ApcuCacheAdapter($config->namespace); + return new ApcuCacheAdapter($storageIdentity); + } + + private static function storageIdentity(NodeCacheConfig $config): string + { + $path = realpath($config->sqliteFile) ?: $config->sqliteFile; + + return 'node.' . hash('xxh128', $path . "\0" . $config->namespace); } } diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 2d4eb1c4..539724d9 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -230,6 +230,7 @@ public function __construct(private array &$keys) {} public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { + unset($waitSeconds); $this->keys[] = $key; return new LockHandle($key, bin2hex(random_bytes(16)), leaseSeconds: $leaseSeconds); From 0f9cce33e2763910b637709e534373f634af9d75 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:50:33 +0600 Subject: [PATCH 036/434] feat(cache): propagate logical storage identity --- src/Cache/Adapter/AbstractCacheAdapter.php | 38 ++++++++++++++++++---- src/Cache/Adapter/TieredCacheAdapter.php | 23 +++++++++++++ src/Cache/Cache.php | 1 + src/Node/Adapter/NodeCacheAdapter.php | 21 ++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 489e03e5..72464824 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -22,6 +22,8 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC private ?CacheOptions $options = null; + private ?string $storageIdentity = null; + /** * @param list $keys * @return array @@ -39,6 +41,14 @@ public function assertOptionsCompatible(CacheOptions $options): void } } + /** @internal */ + public function assertStorageIdentityCompatible(string $storageIdentity): void + { + if ($this->storageIdentity !== null && $this->storageIdentity !== $storageIdentity) { + throw new \LogicException('Cache storage identity cannot change after the adapter is bound to a facade.'); + } + } + public function commit(): bool { if ($this->deferred === []) { @@ -61,6 +71,13 @@ public function configureOptions(CacheOptions $options): void $this->options ??= $options; } + /** @internal */ + public function configureStorageIdentity(string $storageIdentity): void + { + $this->assertStorageIdentityCompatible($storageIdentity); + $this->storageIdentity ??= $storageIdentity; + } + public function createItem(string $key): CacheItemInterface { return $this->genericMiss($key); @@ -136,16 +153,16 @@ protected static function normalizeGeneration(mixed $value): ?string return strtolower($value); } - protected function decodeRecordFromBase64(string $payload): ?CacheRecord + protected function decodeRecordFromBase64(string $payload, ?string $key = null): ?CacheRecord { $blob = base64_decode($payload, true); - return is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + return is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; } - protected function decodeRecordFromBlob(string $blob): ?CacheRecord + protected function decodeRecordFromBlob(string $blob, ?string $key = null): ?CacheRecord { - $record = $this->payloadCodec()->decode($blob); + $record = $this->payloadCodec()->decode($blob, $this->storageIdentity, $key); return $record !== null && !CachePayloadCodec::isExpired($record->expiresAt) ? $record @@ -159,7 +176,14 @@ protected function encodeItem( ): string { $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return $this->payloadCodec()->encode($item->get(), $expiresAt, $tags, $namespaceGeneration); + return $this->payloadCodec()->encode( + $item->get(), + $expiresAt, + $tags, + $namespaceGeneration, + $this->storageIdentity, + $item->getKey(), + ); } protected function genericDeleteAndMiss(string $key): CacheItem @@ -179,7 +203,7 @@ protected function genericFromBase64WithInvalidator( $key, $payload, $onInvalid, - $this->decodeRecordFromBase64(...), + fn(string $encoded): ?CacheRecord => $this->decodeRecordFromBase64($encoded, $key), ); } @@ -193,7 +217,7 @@ protected function genericFromBlobWithInvalidator( $key, $blob, $onInvalid, - $this->decodeRecordFromBlob(...), + fn(string $encoded): ?CacheRecord => $this->decodeRecordFromBlob($encoded, $key), ); } diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 3d31d4ad..33702b3f 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -36,6 +36,17 @@ public function assertOptionsCompatible(CacheOptions $options): void } } + #[\Override] + public function assertStorageIdentityCompatible(string $storageIdentity): void + { + parent::assertStorageIdentityCompatible($storageIdentity); + foreach ($this->pools as $pool) { + if ($pool instanceof AbstractCacheAdapter) { + $pool->assertStorageIdentityCompatible($storageIdentity); + } + } + } + public function clear(): bool { $cleared = true; @@ -59,6 +70,18 @@ public function configureOptions(CacheOptions $options): void } } + #[\Override] + public function configureStorageIdentity(string $storageIdentity): void + { + $this->assertStorageIdentityCompatible($storageIdentity); + parent::configureStorageIdentity($storageIdentity); + foreach ($this->pools as $pool) { + if ($pool instanceof AbstractCacheAdapter) { + $pool->configureStorageIdentity($storageIdentity); + } + } + } + public function deleteItem(string $key): bool { $deleted = true; diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 795f721f..d95243ef 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -63,6 +63,7 @@ public function __construct( $this->options = $options ?? new CacheOptions(); if ($adapter instanceof AbstractCacheAdapter) { $adapter->configureOptions($this->options); + $adapter->configureStorageIdentity($namespace); } } diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index fba55003..161dd443 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -33,6 +33,16 @@ public function assertOptionsCompatible(CacheOptions $options): void } } + #[\Override] + public function assertStorageIdentityCompatible(string $storageIdentity): void + { + parent::assertStorageIdentityCompatible($storageIdentity); + $this->l2->assertStorageIdentityCompatible($storageIdentity); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->assertStorageIdentityCompatible($storageIdentity); + } + } + public function clear(): bool { $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); @@ -53,6 +63,17 @@ public function configureOptions(CacheOptions $options): void } } + #[\Override] + public function configureStorageIdentity(string $storageIdentity): void + { + $this->assertStorageIdentityCompatible($storageIdentity); + parent::configureStorageIdentity($storageIdentity); + $this->l2->configureStorageIdentity($storageIdentity); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->configureStorageIdentity($storageIdentity); + } + } + public function deleteItem(string $key): bool { $l2 = $this->attempt(fn(): bool => $this->l2->deleteItem($key), false, 'l2_failure'); From 47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:51:16 +0600 Subject: [PATCH 037/434] feat(cache): bind signed payloads to logical identity --- src/Cache/Adapter/CachePayloadCodec.php | 78 +++++++++++++++---- src/Cache/CacheOptions.php | 4 +- tests/Cache/CachePayloadCodecSecurityTest.php | 2 +- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 827c0843..bbbacf1d 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -19,10 +19,14 @@ final readonly class CachePayloadCodec { + private const string BOUND_SIGNED_PREFIX = 'cl3-sig:'; + private const string COMPRESSED_PREFIX = 'cl2-gz:'; private const string PLAIN_PREFIX = 'cl2:'; + private const string SIGNATURE_PURPOSE = 'cache-record:v3'; + private const string SIGNED_PREFIX = 'cl2-sig:'; public function __construct(private CacheOptions $options = new CacheOptions()) {} @@ -48,13 +52,16 @@ public static function toDateTime(?int $expiresAt): ?DateTimeInterface return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); } - public function decode(string $blob): ?CacheRecord - { + public function decode( + string $blob, + ?string $storageIdentity = null, + ?string $key = null, + ): ?CacheRecord { if ($this->isPayloadTooLarge($blob)) { return null; } - $verified = $this->verifyAndExtractSignature($blob); + $verified = $this->verifyAndExtractSignature($blob, $storageIdentity, $key); if ($verified === null) { return null; } @@ -82,6 +89,8 @@ public function encode( ?int $expiresAt, array $tags = [], ?string $namespaceGeneration = null, + ?string $storageIdentity = null, + ?string $key = null, ): string { [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ @@ -105,7 +114,7 @@ public function encode( } } - $encoded = $this->attachSignature($payload); + $encoded = $this->attachSignature($payload, $storageIdentity, $key); if ($this->isPayloadTooLarge($encoded)) { throw new RuntimeException('The stored cache payload exceeds the configured payload limit.'); } @@ -132,15 +141,27 @@ private function assertNativeValueSupported(mixed $value): void } } - private function attachSignature(string $payload): string - { + private function attachSignature( + string $payload, + ?string $storageIdentity, + ?string $key, + ): string { if ($this->options->integrityKey === null) { return $payload; } + if ($storageIdentity === null || $key === null) { + $signature = hash_hmac('sha256', $payload, $this->options->integrityKey); + + return self::SIGNED_PREFIX . $signature . ':' . $payload; + } - $signature = hash_hmac('sha256', $payload, $this->options->integrityKey); + $signature = hash_hmac( + 'sha256', + $this->signatureInput($payload, $storageIdentity, $key), + $this->options->integrityKey, + ); - return self::SIGNED_PREFIX . $signature . ':' . $payload; + return self::BOUND_SIGNED_PREFIX . $signature . ':' . $payload; } private function containsUnsupportedDecodedValue(mixed $value): bool @@ -294,27 +315,54 @@ private function unserializeNative(string $payload): mixed } } - private function verifyAndExtractSignature(string $blob): ?string + private function signatureInput(string $payload, string $storageIdentity, string $key): string { - if (!str_starts_with($blob, self::SIGNED_PREFIX)) { - return $this->options->integrityKey === null ? $blob : null; - } + return self::SIGNATURE_PURPOSE + . "\0" . strlen($storageIdentity) . ':' . $storageIdentity + . "\0" . strlen($key) . ':' . $key + . "\0" . $payload; + } + + private function verifyAndExtractSignature( + string $blob, + ?string $storageIdentity, + ?string $key, + ): ?string { if ($this->options->integrityKey === null) { + return str_starts_with($blob, self::SIGNED_PREFIX) + || str_starts_with($blob, self::BOUND_SIGNED_PREFIX) + ? null + : $blob; + } + + $bound = str_starts_with($blob, self::BOUND_SIGNED_PREFIX); + $legacy = str_starts_with($blob, self::SIGNED_PREFIX); + if (!$bound && !$legacy) { + return null; + } + if ($legacy && ($storageIdentity !== null || $key !== null)) { + return null; + } + if ($bound && ($storageIdentity === null || $key === null)) { return null; } - $separator = strpos($blob, ':', strlen(self::SIGNED_PREFIX)); + $prefix = $bound ? self::BOUND_SIGNED_PREFIX : self::SIGNED_PREFIX; + $separator = strpos($blob, ':', strlen($prefix)); if ($separator === false) { return null; } - $signature = substr($blob, strlen(self::SIGNED_PREFIX), $separator - strlen(self::SIGNED_PREFIX)); + $signature = substr($blob, strlen($prefix), $separator - strlen($prefix)); $payload = substr($blob, $separator + 1); if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { return null; } - $expected = hash_hmac('sha256', $payload, $this->options->integrityKey); + $signed = $bound + ? $this->signatureInput($payload, $storageIdentity, $key) + : $payload; + $expected = hash_hmac('sha256', $signed, $this->options->integrityKey); return hash_equals($expected, strtolower($signature)) ? $payload : null; } diff --git a/src/Cache/CacheOptions.php b/src/Cache/CacheOptions.php index c0844598..d19953b3 100644 --- a/src/Cache/CacheOptions.php +++ b/src/Cache/CacheOptions.php @@ -14,8 +14,8 @@ public function __construct( public ?int $maxPayloadBytes = 8_388_608, public ?int $compressionThreshold = null, public int $compressionLevel = 6, - public bool $allowClosures = true, - public bool $allowObjects = true, + public bool $allowClosures = false, + public bool $allowObjects = false, public bool $failOpen = true, ) { if ($integrityKey === '') { diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 5d312d9d..8d81e9a7 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -74,7 +74,7 @@ }); test('payload codec delegates only top-level closures to special serialization', function () { - $codec = new CachePayloadCodec(); + $codec = new CachePayloadCodec(new CacheOptions(allowClosures: true)); $blob = $codec->encode(static fn(int $value): int => $value + 1, null); $closure = $codec->decode($blob)?->value; $resource = fopen('php://memory', 'r+'); From 3ee8e84ca21ce2eac63a0b1552750ef83beb7556 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:53:34 +0600 Subject: [PATCH 038/434] fix(cache): bind Array reads to logical keys --- src/Cache/Adapter/ArrayCacheAdapter.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 9ef1e598..859f95eb 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -39,7 +39,7 @@ public function atomicCompareAndSet( } $mapped = $this->map($key); - $record = $this->atomicRecord($mapped); + $record = $this->atomicRecord($key, $mapped); if (!$record instanceof CacheRecord || $record->value !== $expected) { return false; } @@ -52,7 +52,7 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { $mapped = $this->map($key); - $record = $this->atomicRecord($mapped); + $record = $this->atomicRecord($key, $mapped); if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } @@ -74,7 +74,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool } $mapped = $this->map($item->getKey()); - if ($this->atomicRecord($mapped) instanceof CacheRecord) { + if ($this->atomicRecord($item->getKey(), $mapped) instanceof CacheRecord) { return false; } @@ -148,7 +148,7 @@ public function hasItem(string $key): bool return false; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if ($record === null) { unset($this->store[$mapped]); @@ -246,14 +246,14 @@ public function storeTagGenerations(array $generations): bool return true; } - private function atomicRecord(string $mapped): ?CacheRecord + private function atomicRecord(string $key, string $mapped): ?CacheRecord { $blob = $this->store[$mapped] ?? null; if (!is_string($blob)) { return null; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { unset($this->store[$mapped]); From edef15420895cee62b179c7fa9d6513479b3b336 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:54:01 +0600 Subject: [PATCH 039/434] fix(cache): bind Redis reads to logical keys --- src/Cache/Adapter/RedisCacheAdapter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 3a41c373..6da09e49 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -110,7 +110,7 @@ public function atomicCompareAndSet( if (!is_string($existing)) { return false; } - $record = $this->decodeRecordFromBlob($existing); + $record = $this->decodeRecordFromBlob($existing, $key); if (!$record instanceof CacheRecord || $record->tags !== [] || $record->value !== $expected) { return false; } @@ -132,7 +132,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($raw); + $record = $this->decodeRecordFromBlob($raw, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return $this->genericMiss($key); } @@ -167,7 +167,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return (bool) $this->redis->set($key, $blob, $options); } - $record = $this->decodeRecordFromBlob($existing); + $record = $this->decodeRecordFromBlob($existing, $item->getKey()); if ($record instanceof CacheRecord && $this->recordTagsAreCurrent($record)) { return false; } @@ -224,7 +224,7 @@ public function getItem(string $key): CacheItem { $raw = $this->redis->get($this->map($key)); if (is_string($raw)) { - $record = $this->decodeRecordFromBlob($raw); + $record = $this->decodeRecordFromBlob($raw, $key); if ($record !== null) { return $this->genericItemFromRecord($key, $record); } From 045d5ff89949370744f6d95646165a9b11efd721 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:54:37 +0600 Subject: [PATCH 040/434] fix(cache): bind memory backend reads to logical keys --- src/Cache/Adapter/ApcuCacheAdapter.php | 2 +- src/Cache/Adapter/MemcachedCacheAdapter.php | 10 +++++----- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 12 ++++++------ src/Cache/Adapter/WeakMapCacheAdapter.php | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 3d9c1228..633e901a 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -281,7 +281,7 @@ private function appendFetchedHit(array &$items, array &$stale, string $key, arr private function hitItemFromBlob(string $key, string $blob): ?CacheItem { - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if ($record === null) { return null; } diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 2578b507..8027d47e 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -62,7 +62,7 @@ public function atomicCompareAndSet( return false; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $this->namespaceGeneration() || $record->tags !== [] @@ -92,7 +92,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($extended['value']); + $record = $this->decodeRecordFromBlob($extended['value'], $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $this->namespaceGeneration() || !$this->recordTagsAreCurrent($record)) { @@ -137,7 +137,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool $current = $extended['value']; $record = $current === self::ATOMIC_TOMBSTONE ? null - : $this->decodeRecordFromBlob($current); + : $this->decodeRecordFromBlob($current, $item->getKey()); if ($record instanceof CacheRecord && $record->namespaceGeneration === $this->namespaceGeneration() && $this->recordTagsAreCurrent($record)) { @@ -197,7 +197,7 @@ public function getItem(string $key): CacheItem if ($blob === self::ATOMIC_TOMBSTONE) { return $this->genericMiss($key); } - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } @@ -261,7 +261,7 @@ public function multiFetch(array $keys): array continue; } - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); if (is_string($blob)) { diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 427a445a..b3d57904 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -72,7 +72,7 @@ public function atomicCompareAndSet( return $this->withExclusiveLock(function () use ($mapped, $expected, $replacementBlob): bool { $store = $this->loadStore(); - $record = $this->cachedRecord($store, $mapped); + $record = $this->cachedRecord($store, $key, $mapped); if (!$record instanceof CacheRecord || $record->value !== $expected) { return false; } @@ -94,7 +94,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); unset($store[$mapped]); if (!$this->store($store)) { throw new RuntimeException('Unable to persist shared-memory atomic consume.'); @@ -122,7 +122,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return $this->withExclusiveLock(function () use ($mapped, $blob): bool { $store = $this->loadStore(); - if ($this->cachedRecord($store, $mapped) instanceof CacheRecord) { + if ($this->cachedRecord($store, $item->getKey(), $mapped) instanceof CacheRecord) { return false; } @@ -235,7 +235,7 @@ public function multiFetch(array $keys): array foreach ($keys as $key) { $mapped = $this->map($key); $blob = $store[$mapped] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); @@ -365,14 +365,14 @@ private function attachSegment(int $segmentSize): \SysvSharedMemory } /** @param array $store */ - private function cachedRecord(array $store, string $mapped): ?CacheRecord + private function cachedRecord(array $store, string $key, string $mapped): ?CacheRecord { $blob = $store[$mapped] ?? null; if (!is_string($blob)) { return null; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); return $record instanceof CacheRecord && $this->recordTagsAreCurrent($record, $store) ? $record diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index def3b3ef..1624cc5c 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -189,7 +189,7 @@ public function multiFetch(array $keys): array continue; } $blob = $this->scalarStore[$mapped] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); From f2c3e55db67ecc3237dc87494c2828c66f33d5ee Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:58:06 +0600 Subject: [PATCH 041/434] fix(cache): bind persistent backend reads to logical keys --- src/Cache/Adapter/FileCacheAdapter.php | 2 +- src/Cache/Adapter/PdoCacheAdapter.php | 2 +- src/Cache/Adapter/PhpFilesCacheAdapter.php | 2 +- src/Cache/Adapter/RedisClusterCacheAdapter.php | 4 ++-- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 2 +- src/Node/Adapter/NodeSqliteCacheAdapter.php | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index c733daa6..958c8af9 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -305,7 +305,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord { $file = $this->fileFor($key); $raw = is_file($file) ? file_get_contents($file) : false; - $record = is_string($raw) ? $this->decodeRecordFromBlob($raw) : null; + $record = is_string($raw) ? $this->decodeRecordFromBlob($raw, $key) : null; if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index d518929d..af860f2d 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -345,7 +345,7 @@ private function hydrate(string $key, array $row): ?CacheItem return null; } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); return $record === null ? null : $this->genericItemFromRecord($key, $record); } diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index eed18d9d..3009d77d 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -302,7 +302,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord $row = require $file; $payload = is_array($row) && is_string($row['p'] ?? null) ? $row['p'] : null; $blob = is_string($payload) ? base64_decode($payload, true) : false; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 0914711d..6468165b 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -87,7 +87,7 @@ public function getItem(string $key): CacheItem $values = is_array($values) ? array_values($values) : []; $generation = $this->namespaceGeneration($bucket, $values[0] ?? null); $blob = $values[1] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } @@ -240,7 +240,7 @@ private function fetchBucket(int $bucket, array $keys): array $stale = []; foreach ($keys as $index => $key) { $blob = $values[$index + 1] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { $items[$key] = $this->genericItemFromRecord($key, $record); diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 736331fe..03ca95b9 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -373,7 +373,7 @@ private function fetchBucketItems(int $bucket, array $keys): array foreach ($keys as $key) { $row = $byKey[$this->mapData($key)] ?? null; $payload = is_array($row) ? $this->normalizeString($row['payload'] ?? null) : null; - $record = $payload === null ? null : $this->decodeRecordFromBlob($payload); + $record = $payload === null ? null : $this->decodeRecordFromBlob($payload, $key); $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index 2c032f0d..ffa915a1 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -138,7 +138,7 @@ public function getItem(string $key): CacheItem return new CacheItem($this, $key); } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); if ($record === null) { return new CacheItem($this, $key); } @@ -205,7 +205,7 @@ public function multiFetch(array $keys): array continue; } - $record = $this->decodeRecordFromBlob($payload); + $record = $this->decodeRecordFromBlob($payload, $key); if ($record === null) { $invalid[] = $key; $items[$key] = $this->genericMiss($key); From 82c6c6afea8209da3a7b87b4d656ed79048ea684 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 16:58:39 +0600 Subject: [PATCH 042/434] docs(plan): update Batch 2 implementation tracker --- .../cachelayer-4.0-security-correctness-plan.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 9281ba70..7771ddbf 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batch 1 complete, Batch 2 not started\ +Status: Implementation in progress; Batch 1 complete, Batch 2 in progress\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -14,7 +14,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Batch | Findings | Status | Current gate | | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | -| 2 — Authenticated payload/storage identity | R02, R15, R18 | Not started | Starts only after Batch 1 QA is clean. | +| 2 — Authenticated payload/storage identity | R02, R15, R18 | **In progress** | Contract tests added; R15 storage/topology identity implemented; R02 logical identity propagation and signed-payload binding are in progress; R18 SQL collation migration pending. | | 3 — Durable invalidation protocol | R06, R07 | Not started | Blocked on Batch 2 identity decisions. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | @@ -33,6 +33,17 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi **Batch 1 closure evidence:** exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e` passed Security & Standards run #173: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. During Batch 1 QA the inherited skip-directive and reference-integrity failures were resolved without weakening PHPForge gates. + +### Batch 2 tracker + +| Finding | Implementation | Regression evidence | QA state | +| --- | --- | --- | --- | +| R02 — authenticated payload identity | **In progress** | Contract tests cover cross-key, cross-namespace, legacy signed payload rejection, tier promotion, and secure serialization defaults. Logical storage identity now propagates through facades/tiered/Node; bound HMAC envelope implemented; adapter read/atomic paths are being wired to verify logical keys. | Full Batch 2 QA pending. | +| R15 — Node storage/topology identity | **Implemented; QA pending** | Node APCu identity and lock identity include the SQLite store; authority now reflects whether an L1 can serve stale state. | Contract tests added; full Batch 2 QA pending. | +| R18 — SQL binary identity | **Pending implementation** | MySQL/MariaDB regression test requires byte-sensitive cache and invalidation identity columns and verifies case-distinct namespaces/keys. | Waiting for schema migration implementation, then full Batch 2 QA. | + +**Batch 2 implementation commits so far:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, and `f2c3e55db67ecc3237dc87494c2828c66f33d5ee` (adapter identity verification wiring). + ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:01:21 +0600 Subject: [PATCH 043/434] fix(cache): complete bound payload verification paths --- src/Cache/Adapter/CachePayloadCodec.php | 51 ++++++++++++------- src/Cache/Adapter/MongoDbCacheAdapter.php | 14 ++--- src/Cache/Adapter/PdoAtomicOperations.php | 10 ++-- .../Adapter/RedisClusterAtomicOperations.php | 6 +-- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index bbbacf1d..e0719d2a 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -323,31 +323,48 @@ private function signatureInput(string $payload, string $storageIdentity, string . "\0" . $payload; } - private function verifyAndExtractSignature( + private function signaturePrefix( string $blob, ?string $storageIdentity, ?string $key, ): ?string { - if ($this->options->integrityKey === null) { - return str_starts_with($blob, self::SIGNED_PREFIX) - || str_starts_with($blob, self::BOUND_SIGNED_PREFIX) - ? null - : $blob; + if (str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { + return $storageIdentity !== null && $key !== null + ? self::BOUND_SIGNED_PREFIX + : null; } - - $bound = str_starts_with($blob, self::BOUND_SIGNED_PREFIX); - $legacy = str_starts_with($blob, self::SIGNED_PREFIX); - if (!$bound && !$legacy) { + if (!str_starts_with($blob, self::SIGNED_PREFIX)) { return null; } - if ($legacy && ($storageIdentity !== null || $key !== null)) { - return null; + + return $storageIdentity === null && $key === null + ? self::SIGNED_PREFIX + : null; + } + + private function unsignedPayload(string $blob): ?string + { + return str_starts_with($blob, self::SIGNED_PREFIX) + || str_starts_with($blob, self::BOUND_SIGNED_PREFIX) + ? null + : $blob; + } + + private function verifyAndExtractSignature( + string $blob, + ?string $storageIdentity, + ?string $key, + ): ?string { + $integrityKey = $this->options->integrityKey; + if ($integrityKey === null) { + return $this->unsignedPayload($blob); } - if ($bound && ($storageIdentity === null || $key === null)) { + + $prefix = $this->signaturePrefix($blob, $storageIdentity, $key); + if ($prefix === null) { return null; } - $prefix = $bound ? self::BOUND_SIGNED_PREFIX : self::SIGNED_PREFIX; $separator = strpos($blob, ':', strlen($prefix)); if ($separator === false) { return null; @@ -359,10 +376,10 @@ private function verifyAndExtractSignature( return null; } - $signed = $bound - ? $this->signatureInput($payload, $storageIdentity, $key) + $signed = $prefix === self::BOUND_SIGNED_PREFIX + ? $this->signatureInput($payload, (string) $storageIdentity, (string) $key) : $payload; - $expected = hash_hmac('sha256', $signed, $this->options->integrityKey); + $expected = hash_hmac('sha256', $signed, $integrityKey); return hash_equals($expected, strtolower($signature)) ? $payload : null; } diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index ac894340..1ed508be 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -80,7 +80,7 @@ public function atomicCompareAndSet( return false; } - $record = $this->recordFromRow($row); + $record = $this->recordFromRow($key, $row); if (!$record instanceof CacheRecord || $record->tags !== [] || $record->value !== $expected) { return false; } @@ -97,7 +97,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface { $document = $this->collection->findOneAndDelete(['_id' => $this->mapData($key)]); $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); - $record = is_array($row) ? $this->recordFromRow($row) : null; + $record = is_array($row) ? $this->recordFromRow($key, $row) : null; return $record instanceof CacheRecord ? $this->genericItemFromRecord($key, $record) @@ -121,7 +121,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return true; } - $replaced = $this->tryReplaceInvalidAtomic($id, $replacement); + $replaced = $this->tryReplaceInvalidAtomic($item->getKey(), $id, $replacement); if ($replaced !== null) { return $replaced; } @@ -431,13 +431,13 @@ private function matchedCount(mixed $result): int } /** @param array $row */ - private function recordFromRow(array $row): ?CacheRecord + private function recordFromRow(string $key, array $row): ?CacheRecord { $payload = $this->binaryString($row['payload'] ?? null); if (!is_string($payload)) { return null; } - $record = $this->decodeRecordFromBlob($payload); + $record = $this->decodeRecordFromBlob($payload, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } @@ -481,7 +481,7 @@ private function tryAtomicInsert(string $id, array $replacement): bool * @param array{ns:string, kind:string, payload:mixed, expires:int|null} $replacement * @return bool|null True when replaced, false when a live/non-replaceable value exists, null on a race retry. */ - private function tryReplaceInvalidAtomic(string $id, array $replacement): ?bool + private function tryReplaceInvalidAtomic(string $key, string $id, array $replacement): ?bool { $row = AdapterValueNormalizer::fromJsonOrArrayLike( $this->collection->findOne(['_id' => $id]), @@ -489,7 +489,7 @@ private function tryReplaceInvalidAtomic(string $id, array $replacement): ?bool if (!is_array($row)) { return null; } - if ($this->recordFromRow($row) instanceof CacheRecord || !array_key_exists('payload', $row)) { + if ($this->recordFromRow($key, $row) instanceof CacheRecord || !array_key_exists('payload', $row)) { return false; } diff --git a/src/Cache/Adapter/PdoAtomicOperations.php b/src/Cache/Adapter/PdoAtomicOperations.php index 6b8b3798..39553c1c 100644 --- a/src/Cache/Adapter/PdoAtomicOperations.php +++ b/src/Cache/Adapter/PdoAtomicOperations.php @@ -28,7 +28,7 @@ public function atomicCompareAndSet( return $this->atomicTransaction(function () use ($key, $expected, $replacement, $expiration): bool { $row = $this->atomicFetchRow($key); - $record = $row === null ? null : $this->atomicRecordFromRow($row); + $record = $row === null ? null : $this->atomicRecordFromRow($key, $row); if (!$record instanceof CacheRecord || !$this->atomicRecordTagsAreCurrent($record) || $record->value !== $expected) { @@ -55,7 +55,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->atomicRecordFromRow($row); + $record = $this->atomicRecordFromRow($key, $row); $this->deleteItem($key); if (!$record instanceof CacheRecord || !$this->atomicRecordTagsAreCurrent($record)) { return $this->genericMiss($key); @@ -78,7 +78,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return $this->atomicTransaction(function () use ($item, $expiration): bool { $key = $item->getKey(); $row = $this->atomicFetchRow($key); - $record = $row === null ? null : $this->atomicRecordFromRow($row); + $record = $row === null ? null : $this->atomicRecordFromRow($key, $row); if ($record instanceof CacheRecord && $this->atomicRecordTagsAreCurrent($record)) { return false; } @@ -141,13 +141,13 @@ private function atomicInsertIfMissing(string $key, string $payload, ?int $expir } /** @param array{payload:string, expires:int|null} $row */ - private function atomicRecordFromRow(array $row): ?CacheRecord + private function atomicRecordFromRow(string $key, array $row): ?CacheRecord { if (CachePayloadCodec::isExpired($row['expires'])) { return null; } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); return $record instanceof CacheRecord ? $record : null; } diff --git a/src/Cache/Adapter/RedisClusterAtomicOperations.php b/src/Cache/Adapter/RedisClusterAtomicOperations.php index 02491cc2..007d9bc9 100644 --- a/src/Cache/Adapter/RedisClusterAtomicOperations.php +++ b/src/Cache/Adapter/RedisClusterAtomicOperations.php @@ -78,7 +78,7 @@ public function atomicCompareAndSet( if (!is_string($state['existing'])) { return false; } - $record = $this->decodeRecordFromBlob($state['existing']); + $record = $this->decodeRecordFromBlob($state['existing'], $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $state['generation'] || $record->tags !== [] @@ -123,7 +123,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $generation || !$this->recordTagsAreCurrent($record)) { @@ -182,7 +182,7 @@ private function atomicSetIfAbsentAttempt(CacheItemInterface $item, array $expir $replaceStale = false; $expectedExisting = ''; if (is_string($state['existing'])) { - $record = $this->decodeRecordFromBlob($state['existing']); + $record = $this->decodeRecordFromBlob($state['existing'], $item->getKey()); if ($record instanceof CacheRecord && $record->namespaceGeneration === $state['generation'] && $this->recordTagsAreCurrent($record)) { From 8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:02:26 +0600 Subject: [PATCH 044/434] fix(sql): enforce binary cache identity collations --- src/Cache/Adapter/PdoCacheSchema.php | 13 +++++++++++++ .../Transport/Pdo/PdoInvalidationSchema.php | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php index 5af9707c..0bfad834 100644 --- a/src/Cache/Adapter/PdoCacheSchema.php +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -34,6 +34,9 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): PRIMARY KEY (namespace, kind, cache_key) )", ); + if (in_array($driver, ['mysql', 'mariadb'], true)) { + self::hardenMysqlIdentityColumns($pdo, $table); + } $index = $table . '_expires_idx'; @@ -52,4 +55,14 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): $pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$table}(namespace, kind, expires)"); } + + private static function hardenMysqlIdentityColumns(PDO $pdo, string $table): void + { + $pdo->exec( + "ALTER TABLE {$table} " + . 'MODIFY namespace VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY kind VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY cache_key VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } } diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 87319dc5..2291fcb5 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -18,6 +18,9 @@ public static function install(PDO $connection, bool $allowSqliteForTesting = fa try { $connection->exec(self::createTableSql($driver)); + if ($driver === 'mysql') { + self::hardenMysqlIdentityColumns($connection); + } self::createIndex($connection, $driver); } catch (PDOException $exception) { throw new ClusterTransportException('Unable to initialize the PDO invalidation transport schema.', 0, $exception); @@ -60,6 +63,18 @@ private static function createTableSql(string $driver): string . 'created_at BIGINT NOT NULL)'; } + private static function hardenMysqlIdentityColumns(PDO $connection): void + { + $connection->exec( + 'ALTER TABLE ' . self::TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' + . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + private static function driver(PDO $connection, bool $allowSqliteForTesting): string { $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); From 62f984c13c342c9faa37400cd4a6a262c3f627a3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:02:57 +0600 Subject: [PATCH 045/434] fix(cache): capture logical keys in shared-memory atomics --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index b3d57904..81d1d7c1 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -70,7 +70,7 @@ public function atomicCompareAndSet( $mapped = $this->map($key); $replacementBlob = $this->encodeItem($replacement, $expiration['expiresAt']); - return $this->withExclusiveLock(function () use ($mapped, $expected, $replacementBlob): bool { + return $this->withExclusiveLock(function () use ($key, $mapped, $expected, $replacementBlob): bool { $store = $this->loadStore(); $record = $this->cachedRecord($store, $key, $mapped); if (!$record instanceof CacheRecord || $record->value !== $expected) { @@ -117,12 +117,13 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return false; } - $mapped = $this->map($item->getKey()); + $key = $item->getKey(); + $mapped = $this->map($key); $blob = $this->encodeItem($item, $expiration['expiresAt']); - return $this->withExclusiveLock(function () use ($mapped, $blob): bool { + return $this->withExclusiveLock(function () use ($key, $mapped, $blob): bool { $store = $this->loadStore(); - if ($this->cachedRecord($store, $item->getKey(), $mapped) instanceof CacheRecord) { + if ($this->cachedRecord($store, $key, $mapped) instanceof CacheRecord) { return false; } From 4afe49776284ee9a2a35e0b36c959906b9a35fae Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:04:44 +0600 Subject: [PATCH 046/434] docs(plan): make 4.0 breaking-change policy explicit --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 7771ddbf..5e427cbd 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -40,17 +40,17 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | --- | --- | --- | --- | | R02 — authenticated payload identity | **In progress** | Contract tests cover cross-key, cross-namespace, legacy signed payload rejection, tier promotion, and secure serialization defaults. Logical storage identity now propagates through facades/tiered/Node; bound HMAC envelope implemented; adapter read/atomic paths are being wired to verify logical keys. | Full Batch 2 QA pending. | | R15 — Node storage/topology identity | **Implemented; QA pending** | Node APCu identity and lock identity include the SQLite store; authority now reflects whether an L1 can serve stale state. | Contract tests added; full Batch 2 QA pending. | -| R18 — SQL binary identity | **Pending implementation** | MySQL/MariaDB regression test requires byte-sensitive cache and invalidation identity columns and verifies case-distinct namespaces/keys. | Waiting for schema migration implementation, then full Batch 2 QA. | +| R18 — SQL binary identity | **Implemented; QA pending** | MySQL/MariaDB regression test requires byte-sensitive cache and invalidation identity columns and verifies case-distinct namespaces/keys. Existing tables are migrated to `ascii_bin` identity columns by schema installation. | Full Batch 2 QA pending. | -**Batch 2 implementation commits so far:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, and `f2c3e55db67ecc3237dc87494c2828c66f33d5ee` (adapter identity verification wiring). +**Batch 2 implementation commits so far:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, and `f2c3e55db67ecc3237dc87494c2828c66f33d5ee` (adapter identity verification wiring), `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (remaining atomic/backend identity verification and codec complexity cleanup), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` (MySQL/MariaDB binary-collation migration), and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SharedMemory identity binding fix). ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. -Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. Deliver the coordinated changes to authenticated payload identity, persisted cursor scope, storage identity, and secure configuration contracts with explicit migration and mixed-version rules. Patch backports and an alternative minor release are outside this plan. The major-version target permits the necessary documented contract changes; it does not justify unrelated rewrites or gratuitous API breaks. +Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. -Keep PHP 8.3 support unless a separate, justified compatibility decision changes it; add real PHP 8.3 coverage. A PHP floor increase is not required by these fixes. Preserve public named parameters and PSR interfaces wherever possible. +Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. Keep PHP 8.3 support unless a separate, justified compatibility decision changes it; add real PHP 8.3 coverage. A PHP floor increase is not required by these fixes. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. Track Runwire 2.1 integration as an optional target for 4.0. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. An executable invalidation-worker example demonstrates this behavior if the workstream is selected for implementation. It is optional both as release scope and as a consumer dependency: deferring the entire workstream does not block 4.0.0. Retain PHP 8.3 support in the core. Any shipped integration requires a demonstrated need and the conditional gates below. From 60a67cf125a8e66517838fceeb13c8d5b1a417f2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:04:53 +0600 Subject: [PATCH 047/434] fix(cache): bind Redis batch reads to logical keys --- src/Cache/Adapter/RedisCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 6da09e49..fdc3f5ca 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -294,7 +294,7 @@ public function multiFetch(array $keys): array continue; } - $record = $this->decodeRecordFromBlob($v); + $record = $this->decodeRecordFromBlob($v, $k); if ($record !== null) { $items[$k] = $this->genericItemFromRecord($k, $record); From c2613c874a8f0c80ca66b39539e06222b521acaa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:06:20 +0600 Subject: [PATCH 048/434] fix(cache): validate payload identity in hasItem --- src/Cache/Adapter/ApcuCacheAdapter.php | 2 +- src/Cache/Adapter/MongoDbCacheAdapter.php | 10 +--------- src/Cache/Adapter/RedisCacheAdapter.php | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 633e901a..0ba4f99b 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -118,7 +118,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - return apcu_exists($this->map($key)); + return $this->getItem($key)->isHit(); } /** diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index 1ed508be..3450bc02 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -201,15 +201,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - $count = $this->collection->countDocuments([ - '_id' => $this->mapData($key), - '$or' => [ - ['expires' => null], - ['expires' => ['$gt' => time()]], - ], - ]); - - return is_numeric($count) && (int) $count > 0; + return $this->getItem($key)->isHit(); } /** diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index fdc3f5ca..1008c8db 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -262,7 +262,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - return $this->redis->exists($this->map($key)) === 1; + return $this->getItem($key)->isHit(); } /** From fd35898a045ec944545512ee76a5e0976a7a1b0a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:08:08 +0600 Subject: [PATCH 049/434] refactor(cache): remove legacy unbound signing in 4.0 --- src/Cache/Adapter/CachePayloadCodec.php | 54 ++++++------------- tests/Cache/CachePayloadCodecSecurityTest.php | 29 +++++++--- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index e0719d2a..347ab3d1 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -27,8 +27,6 @@ private const string SIGNATURE_PURPOSE = 'cache-record:v3'; - private const string SIGNED_PREFIX = 'cl2-sig:'; - public function __construct(private CacheOptions $options = new CacheOptions()) {} /** @return array{ttl:int|null,expiresAt:int|null} */ @@ -150,9 +148,9 @@ private function attachSignature( return $payload; } if ($storageIdentity === null || $key === null) { - $signature = hash_hmac('sha256', $payload, $this->options->integrityKey); - - return self::SIGNED_PREFIX . $signature . ':' . $payload; + throw new InvalidArgumentException( + 'Signed cache payloads require a logical storage identity and key.', + ); } $signature = hash_hmac( @@ -323,31 +321,9 @@ private function signatureInput(string $payload, string $storageIdentity, string . "\0" . $payload; } - private function signaturePrefix( - string $blob, - ?string $storageIdentity, - ?string $key, - ): ?string { - if (str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { - return $storageIdentity !== null && $key !== null - ? self::BOUND_SIGNED_PREFIX - : null; - } - if (!str_starts_with($blob, self::SIGNED_PREFIX)) { - return null; - } - - return $storageIdentity === null && $key === null - ? self::SIGNED_PREFIX - : null; - } - private function unsignedPayload(string $blob): ?string { - return str_starts_with($blob, self::SIGNED_PREFIX) - || str_starts_with($blob, self::BOUND_SIGNED_PREFIX) - ? null - : $blob; + return str_starts_with($blob, self::BOUND_SIGNED_PREFIX) ? null : $blob; } private function verifyAndExtractSignature( @@ -359,27 +335,31 @@ private function verifyAndExtractSignature( if ($integrityKey === null) { return $this->unsignedPayload($blob); } - - $prefix = $this->signaturePrefix($blob, $storageIdentity, $key); - if ($prefix === null) { + if ($storageIdentity === null || $key === null + || !str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { return null; } - $separator = strpos($blob, ':', strlen($prefix)); + $separator = strpos($blob, ':', strlen(self::BOUND_SIGNED_PREFIX)); if ($separator === false) { return null; } - $signature = substr($blob, strlen($prefix), $separator - strlen($prefix)); + $signature = substr( + $blob, + strlen(self::BOUND_SIGNED_PREFIX), + $separator - strlen(self::BOUND_SIGNED_PREFIX), + ); $payload = substr($blob, $separator + 1); if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { return null; } - $signed = $prefix === self::BOUND_SIGNED_PREFIX - ? $this->signatureInput($payload, (string) $storageIdentity, (string) $key) - : $payload; - $expected = hash_hmac('sha256', $signed, $integrityKey); + $expected = hash_hmac( + 'sha256', + $this->signatureInput($payload, $storageIdentity, $key), + $integrityKey, + ); return hash_equals($expected, strtolower($signature)) ? $payload : null; } diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 8d81e9a7..5a685ef0 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -7,23 +7,38 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; -test('payload codec signs and verifies CacheLayer v2 records', function () { +test('payload codec signs and verifies identity-bound CacheLayer records', function () { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); $generation = bin2hex(random_bytes(16)); - $blob = $codec->encode(['k' => 'v'], null, ['group' => $generation]); - expect(str_starts_with($blob, 'cl2-sig:'))->toBeTrue(); - - $record = $codec->decode($blob); + $blob = $codec->encode( + ['k' => 'v'], + null, + ['group' => $generation], + storageIdentity: 'tenant', + key: 'record', + ); + expect(str_starts_with($blob, 'cl3-sig:'))->toBeTrue(); + + $record = $codec->decode($blob, 'tenant', 'record'); expect($record?->value)->toBe(['k' => 'v']) ->and($record?->tags)->toBe(['group' => $generation]); }); test('payload codec rejects tampered signed payload', function () { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); - $blob = $codec->encode('value', null); + $blob = $codec->encode('value', null, storageIdentity: 'tenant', key: 'record'); + + expect($codec->decode($blob . 'x', 'tenant', 'record'))->toBeNull(); +}); + +test('signed codec rejects legacy unbound operation', function () { + $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); - expect($codec->decode($blob . 'x'))->toBeNull(); + expect(fn() => $codec->encode('value', null)) + ->toThrow(InvalidArgumentException::class) + ->and($codec->decode('cl2-sig:' . str_repeat('0', 64) . ':cl2:payload')) + ->toBeNull(); }); test('cache treats a corrupted signed record as a miss and deletes it', function () { From ef6b044d8846de8d9a7f6c89cd0a0ff15fcbbca1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:08:50 +0600 Subject: [PATCH 050/434] test(cache): opt into WeakMap object caching --- tests/Cache/WeakMapCachePoolTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Cache/WeakMapCachePoolTest.php b/tests/Cache/WeakMapCachePoolTest.php index d133f047..73ee6f38 100644 --- a/tests/Cache/WeakMapCachePoolTest.php +++ b/tests/Cache/WeakMapCachePoolTest.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; beforeEach(function () { - $this->cache = Cache::weakMap('weak-tests'); + $this->cache = Cache::weakMap('weak-tests', new CacheOptions(allowObjects: true)); }); test('weak map adapter stores scalar values', function () { From df5d657dfe2b339ffd4a21903a31cea663a492c8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:10:55 +0600 Subject: [PATCH 051/434] bench(cache): use identity-bound HMAC payloads --- benchmarks/CachePolicyBench.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/benchmarks/CachePolicyBench.php b/benchmarks/CachePolicyBench.php index 9259095c..df524fa1 100644 --- a/benchmarks/CachePolicyBench.php +++ b/benchmarks/CachePolicyBench.php @@ -36,7 +36,13 @@ public function benchCompressedCodec(array $params): int public function benchHmacCodec(): int { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'benchmark-secret')); - $record = $codec->decode($codec->encode($this->payload, null)); + $payload = $codec->encode( + $this->payload, + null, + storageIdentity: 'benchmark', + key: 'payload', + ); + $record = $codec->decode($payload, 'benchmark', 'payload'); return strlen((string) $record?->value); } From 6998b9ea20fdee2c0e0be79565aa7e97404bd8aa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:14:03 +0600 Subject: [PATCH 052/434] fix(qa): align Batch 2 tests and formatting --- src/Cache/Adapter/CachePayloadCodec.php | 19 ++++++++------- .../Transport/Pdo/PdoInvalidationSchema.php | 24 +++++++++---------- tests/Cache/ApcuCachePoolTest.php | 3 ++- tests/Cache/FileCachePoolTest.php | 3 ++- tests/Cache/MemcachedCachePoolTest.php | 4 +++- tests/Cache/PdoSqlIdentityTest.php | 4 ++-- tests/Cache/RedisCachePoolTest.php | 4 +++- tests/Cache/SqliteCachePoolTest.php | 3 ++- 8 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 347ab3d1..41ed039f 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -299,6 +299,14 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return new CacheRecord($value['value'], $expiresAt, $tags, $namespaceGeneration); } + private function signatureInput(string $payload, string $storageIdentity, string $key): string + { + return self::SIGNATURE_PURPOSE + . "\0" . strlen($storageIdentity) . ':' . $storageIdentity + . "\0" . strlen($key) . ':' . $key + . "\0" . $payload; + } + private function unserializeNative(string $payload): mixed { set_error_handler(static fn(): bool => true); @@ -313,14 +321,6 @@ private function unserializeNative(string $payload): mixed } } - private function signatureInput(string $payload, string $storageIdentity, string $key): string - { - return self::SIGNATURE_PURPOSE - . "\0" . strlen($storageIdentity) . ':' . $storageIdentity - . "\0" . strlen($key) . ':' . $key - . "\0" . $payload; - } - private function unsignedPayload(string $blob): ?string { return str_starts_with($blob, self::BOUND_SIGNED_PREFIX) ? null : $blob; @@ -330,7 +330,8 @@ private function verifyAndExtractSignature( string $blob, ?string $storageIdentity, ?string $key, - ): ?string { + ): ?string + { $integrityKey = $this->options->integrityKey; if ($integrityKey === null) { return $this->unsignedPayload($blob); diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 2291fcb5..f038d1a7 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -63,18 +63,6 @@ private static function createTableSql(string $driver): string . 'created_at BIGINT NOT NULL)'; } - private static function hardenMysqlIdentityColumns(PDO $connection): void - { - $connection->exec( - 'ALTER TABLE ' . self::TABLE . ' ' - . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' - . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', - ); - } - private static function driver(PDO $connection, bool $allowSqliteForTesting): string { $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); @@ -88,4 +76,16 @@ private static function driver(PDO $connection, bool $allowSqliteForTesting): st return $driver; } + private static function hardenMysqlIdentityColumns(PDO $connection): void + { + $connection->exec( + 'ALTER TABLE ' . self::TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' + . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + } diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 4bfa22bd..965da839 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -11,6 +11,7 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -26,7 +27,7 @@ /* ── boilerplate ──────────────────────────────────────────────────── */ beforeEach(function () { apcu_clear_cache(); // fresh memory - $this->cache = Cache::apcu('tests'); // APCu-backed pool + $this->cache = Cache::apcu('tests', new CacheOptions(allowClosures: true)); // APCu-backed pool }); afterEach(function () { diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index 578051e6..fd4bf786 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -5,6 +5,7 @@ /** tests/FileCachePoolTest.php */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -13,7 +14,7 @@ $this->cacheDir = sys_get_temp_dir().'/pest_cache_'.uniqid(); /* build a file-backed cachepool via static factory */ - $this->cache = Cache::file('tests', $this->cacheDir); + $this->cache = Cache::file('tests', $this->cacheDir, new CacheOptions(allowClosures: true)); }); afterEach(function () { diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index d05562e8..23c503fb 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -10,6 +10,7 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -41,7 +42,8 @@ $this->cache = Cache::memcached( 'tests', [[$memcachedHost, $memcachedPort, 0]], - $client + $client, + new CacheOptions(allowClosures: true), ); }); diff --git a/tests/Cache/PdoSqlIdentityTest.php b/tests/Cache/PdoSqlIdentityTest.php index 19679162..b4abd93a 100644 --- a/tests/Cache/PdoSqlIdentityTest.php +++ b/tests/Cache/PdoSqlIdentityTest.php @@ -53,7 +53,7 @@ . "AND column_name IN ('namespace', 'kind', 'cache_key')", ); $statement->execute([$cacheTable]); - $collations = array_column($statement->fetchAll(PDO::FETCH_ASSOC), 'collation_name'); + $collations = $statement->fetchAll(PDO::FETCH_COLUMN, 1); expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); $upper = Cache::pdo('Tenant', pdo: $pdo, table: $cacheTable); @@ -80,7 +80,7 @@ . "AND column_name IN ('cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id')", ); $statement->execute(); - $collations = array_column($statement->fetchAll(PDO::FETCH_ASSOC), 'collation_name'); + $collations = $statement->fetchAll(PDO::FETCH_COLUMN, 1); expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); $transport = new PdoInvalidationTransport($pdo, initializeSchema: false); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index edb6eb8e..bd69d5c9 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -13,6 +13,7 @@ use Infocyph\CacheLayer\Cache\AtomicCacheInterface; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -63,7 +64,8 @@ $this->cache = Cache::redis( 'tests', sprintf('redis://%s:%d', $redisHost, $redisPort), - $client + $client, + new CacheOptions(allowClosures: true), ); }); diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 071f7359..4ebd0046 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -9,6 +9,7 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -20,7 +21,7 @@ /* ── bootstrap / teardown ────────────────────────────────────────── */ beforeEach(function () { $this->dbFile = sys_get_temp_dir().'/pest_sqlite_'.uniqid().'.sqlite'; - $this->cache = Cache::sqlite('tests', $this->dbFile); + $this->cache = Cache::sqlite('tests', $this->dbFile, new CacheOptions(allowClosures: true)); }); afterEach(function () { From 783461b4de29e7efc9ee7e81293192489f387673 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:18:41 +0600 Subject: [PATCH 053/434] ci: diagnose Batch 2 Pint delta --- .github/workflows/batch2-pint-diagnostic.yml | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/batch2-pint-diagnostic.yml diff --git a/.github/workflows/batch2-pint-diagnostic.yml b/.github/workflows/batch2-pint-diagnostic.yml new file mode 100644 index 00000000..5c7697b0 --- /dev/null +++ b/.github/workflows/batch2-pint-diagnostic.yml @@ -0,0 +1,26 @@ +name: Batch 2 Pint Diagnostic + +on: + push: + branches: + - feature/improvements + +jobs: + pint-diff: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - uses: ramsey/composer-install@v4 + with: + dependency-versions: highest + - name: Show exact Pint changes + shell: bash + run: | + vendor/bin/pint src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php + git diff -- src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php From 7f8f7dc1e95df47cf48f080159c99a4b6b68e8ef Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:20:33 +0600 Subject: [PATCH 054/434] style(cache): apply exact Batch 2 Pint formatting --- .github/workflows/batch2-pint-diagnostic.yml | 26 -------- src/Cache/Adapter/CachePayloadCodec.php | 65 +++++++++---------- .../Transport/Pdo/PdoInvalidationSchema.php | 32 ++++----- 3 files changed, 48 insertions(+), 75 deletions(-) delete mode 100644 .github/workflows/batch2-pint-diagnostic.yml diff --git a/.github/workflows/batch2-pint-diagnostic.yml b/.github/workflows/batch2-pint-diagnostic.yml deleted file mode 100644 index 5c7697b0..00000000 --- a/.github/workflows/batch2-pint-diagnostic.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Batch 2 Pint Diagnostic - -on: - push: - branches: - - feature/improvements - -jobs: - pint-diff: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - coverage: none - - uses: ramsey/composer-install@v4 - with: - dependency-versions: highest - - name: Show exact Pint changes - shell: bash - run: | - vendor/bin/pint src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php - git diff -- src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 41ed039f..8075266a 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -27,7 +27,7 @@ private const string SIGNATURE_PURPOSE = 'cache-record:v3'; - public function __construct(private CacheOptions $options = new CacheOptions()) {} + public function __construct(private CacheOptions $options = new CacheOptions) {} /** @return array{ttl:int|null,expiresAt:int|null} */ public static function expirationFromItem(CacheItemInterface $item): array @@ -47,7 +47,7 @@ public static function isExpired(?int $expiresAt, ?int $now = null): bool public static function toDateTime(?int $expiresAt): ?DateTimeInterface { - return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); + return $expiresAt === null ? null : (new DateTimeImmutable)->setTimestamp($expiresAt); } public function decode( @@ -80,7 +80,7 @@ public function decode( } /** - * @param array $tags + * @param array $tags */ public function encode( mixed $value, @@ -103,12 +103,12 @@ public function encode( throw new RuntimeException('The encoded cache record exceeds the configured payload limit.'); } - $payload = self::PLAIN_PREFIX . $serialized; + $payload = self::PLAIN_PREFIX.$serialized; $threshold = $this->options->compressionThreshold; if ($threshold !== null && strlen($serialized) >= $threshold && function_exists('gzencode')) { $compressed = gzencode($serialized, $this->options->compressionLevel); if (is_string($compressed) && strlen($compressed) < strlen($serialized)) { - $payload = self::COMPRESSED_PREFIX . base64_encode($compressed); + $payload = self::COMPRESSED_PREFIX.base64_encode($compressed); } } @@ -128,10 +128,10 @@ private function assertNativeValueSupported(mixed $value): void if (is_resource($value)) { throw new InvalidArgumentException('Resource cache values are not supported.'); } - if (is_object($value) && !$this->options->allowObjects) { + if (is_object($value) && ! $this->options->allowObjects) { throw new InvalidArgumentException('Object cache values are disabled by security policy.'); } - if (!is_array($value)) { + if (! is_array($value)) { return; } foreach ($value as $item) { @@ -159,7 +159,7 @@ private function attachSignature( $this->options->integrityKey, ); - return self::BOUND_SIGNED_PREFIX . $signature . ':' . $payload; + return self::BOUND_SIGNED_PREFIX.$signature.':'.$payload; } private function containsUnsupportedDecodedValue(mixed $value): bool @@ -168,9 +168,9 @@ private function containsUnsupportedDecodedValue(mixed $value): bool return true; } if (is_object($value)) { - return !$this->options->allowObjects; + return ! $this->options->allowObjects; } - if (!is_array($value)) { + if (! is_array($value)) { return false; } foreach ($value as $item) { @@ -183,7 +183,7 @@ private function containsUnsupportedDecodedValue(mixed $value): bool } /** - * @param array $record + * @param array $record * @return array{valid:bool, value:mixed} */ private function decodeValue(array $record): array @@ -191,7 +191,7 @@ private function decodeValue(array $record): array $encoding = $record['encoding'] ?? null; $value = $record['value'] ?? null; if ($encoding === 'closure') { - if (!$this->options->allowClosures || !is_string($value)) { + if (! $this->options->allowClosures || ! is_string($value)) { return ['valid' => false, 'value' => null]; } @@ -212,7 +212,7 @@ private function decodeValue(array $record): array private function encodeValue(mixed $value): array { if ($value instanceof Closure) { - if (!$this->options->allowClosures) { + if (! $this->options->allowClosures) { throw new InvalidArgumentException('Closure cache values are disabled by security policy.'); } @@ -230,19 +230,19 @@ private function expandPayload(string $payload): ?string if (str_starts_with($payload, self::PLAIN_PREFIX)) { return substr($payload, strlen(self::PLAIN_PREFIX)); } - if (!str_starts_with($payload, self::COMPRESSED_PREFIX)) { + if (! str_starts_with($payload, self::COMPRESSED_PREFIX)) { return null; } $compressed = base64_decode(substr($payload, strlen(self::COMPRESSED_PREFIX)), true); - if (!is_string($compressed) || !function_exists('gzdecode')) { + if (! is_string($compressed) || ! function_exists('gzdecode')) { return null; } $maximumLength = $this->options->maxPayloadBytes === null ? 0 : min($this->options->maxPayloadBytes, PHP_INT_MAX - 1) + 1; - set_error_handler(static fn(): bool => true); + set_error_handler(static fn (): bool => true); try { $expanded = gzdecode($compressed, $maximumLength); @@ -261,37 +261,37 @@ private function isPayloadTooLarge(string $payload): bool private function normalizeRecord(mixed $decoded): ?CacheRecord { - if (!is_array($decoded) || ($decoded['format'] ?? null) !== 2 || !array_key_exists('value', $decoded)) { + if (! is_array($decoded) || ($decoded['format'] ?? null) !== 2 || ! array_key_exists('value', $decoded)) { return null; } $expiresAt = $decoded['expires'] ?? null; - if ($expiresAt !== null && !is_int($expiresAt)) { + if ($expiresAt !== null && ! is_int($expiresAt)) { return null; } $tags = $decoded['tags'] ?? null; - if (!is_array($tags)) { + if (! is_array($tags)) { return null; } $namespaceGeneration = $decoded['namespace'] ?? null; if ($namespaceGeneration !== null - && (!is_string($namespaceGeneration) + && (! is_string($namespaceGeneration) || strlen($namespaceGeneration) !== 32 - || !ctype_xdigit($namespaceGeneration))) { + || ! ctype_xdigit($namespaceGeneration))) { return null; } $value = $this->decodeValue($decoded); - if (!$value['valid']) { + if (! $value['valid']) { return null; } foreach ($tags as $tag => $generation) { - if (!is_string($tag) - || !is_string($generation) + if (! is_string($tag) + || ! is_string($generation) || strlen($generation) !== 32 - || !ctype_xdigit($generation)) { + || ! ctype_xdigit($generation)) { return null; } } @@ -302,14 +302,14 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord private function signatureInput(string $payload, string $storageIdentity, string $key): string { return self::SIGNATURE_PURPOSE - . "\0" . strlen($storageIdentity) . ':' . $storageIdentity - . "\0" . strlen($key) . ':' . $key - . "\0" . $payload; + ."\0" . strlen($storageIdentity) . ':' . $storageIdentity + ."\0" . strlen($key) . ':' . $key + ."\0" . $payload; } private function unserializeNative(string $payload): mixed { - set_error_handler(static fn(): bool => true); + set_error_handler(static fn (): bool => true); try { return unserialize($payload, [ @@ -330,14 +330,13 @@ private function verifyAndExtractSignature( string $blob, ?string $storageIdentity, ?string $key, - ): ?string - { + ): ?string { $integrityKey = $this->options->integrityKey; if ($integrityKey === null) { return $this->unsignedPayload($blob); } if ($storageIdentity === null || $key === null - || !str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { + || ! str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { return null; } @@ -352,7 +351,7 @@ private function verifyAndExtractSignature( $separator - strlen(self::BOUND_SIGNED_PREFIX), ); $payload = substr($blob, $separator + 1); - if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { + if (strlen($signature) !== 64 || ! ctype_xdigit($signature)) { return null; } diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index f038d1a7..1c91243d 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -38,12 +38,12 @@ private static function createIndex(PDO $connection, string $driver): void try { $connection->exec( - 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' - . 'ON ' . self::TABLE . ' (cluster_name, event_id)', + 'CREATE INDEX'.$ifNotExists.' cachelayer_invalidation_events_cluster_idx ' + .'ON ' . self::TABLE . ' (cluster_name, event_id)', ); } catch (PDOException $exception) { $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; - if ($driver !== 'mysql' || !$duplicate) { + if ($driver !== 'mysql' || ! $duplicate) { throw $exception; } } @@ -57,35 +57,35 @@ private static function createTableSql(string $driver): string default => 'INTEGER PRIMARY KEY AUTOINCREMENT', }; - return 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'event_id ' . $id . ', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' - . 'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, origin_node_id VARCHAR(255) NOT NULL, ' - . 'created_at BIGINT NOT NULL)'; + return 'CREATE TABLE IF NOT EXISTS '.self::TABLE.' (' + .'event_id ' . $id . ', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' + .'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, origin_node_id VARCHAR(255) NOT NULL, ' + .'created_at BIGINT NOT NULL)'; } private static function driver(PDO $connection, bool $allowSqliteForTesting): string { $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = is_string($driver) ? $driver : ''; - if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'], true)) { + if (! in_array($driver, ['mysql', 'pgsql', 'sqlite'], true)) { throw new ClusterTransportException('PDO invalidation transport supports MySQL and PostgreSQL only.'); } - if ($driver === 'sqlite' && !$allowSqliteForTesting) { + if ($driver === 'sqlite' && ! $allowSqliteForTesting) { throw new ClusterTransportException('SQLite is not a supported shared Cluster Cache transport.'); } return $driver; } + private static function hardenMysqlIdentityColumns(PDO $connection): void { $connection->exec( - 'ALTER TABLE ' . self::TABLE . ' ' - . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' - . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + 'ALTER TABLE '.self::TABLE.' ' + .'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + .'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + .'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + .'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' + .'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', ); } - } From 9dddf7862605500aae46682170d1b905f15f3af2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:25:35 +0600 Subject: [PATCH 055/434] style(cache): finish Batch 2 Pint deltas --- src/Cache/Adapter/CachePayloadCodec.php | 6 +++--- src/Cluster/Transport/Pdo/PdoInvalidationSchema.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 8075266a..bd2d3131 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -302,9 +302,9 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord private function signatureInput(string $payload, string $storageIdentity, string $key): string { return self::SIGNATURE_PURPOSE - ."\0" . strlen($storageIdentity) . ':' . $storageIdentity - ."\0" . strlen($key) . ':' . $key - ."\0" . $payload; + ."\0".strlen($storageIdentity).':'.$storageIdentity + ."\0".strlen($key).':'.$key + ."\0".$payload; } private function unserializeNative(string $payload): mixed diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 1c91243d..8e81f504 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -39,7 +39,7 @@ private static function createIndex(PDO $connection, string $driver): void try { $connection->exec( 'CREATE INDEX'.$ifNotExists.' cachelayer_invalidation_events_cluster_idx ' - .'ON ' . self::TABLE . ' (cluster_name, event_id)', + .'ON '.self::TABLE.' (cluster_name, event_id)', ); } catch (PDOException $exception) { $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; @@ -58,7 +58,7 @@ private static function createTableSql(string $driver): string }; return 'CREATE TABLE IF NOT EXISTS '.self::TABLE.' (' - .'event_id ' . $id . ', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' + .'event_id '.$id.', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' .'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, origin_node_id VARCHAR(255) NOT NULL, ' .'created_at BIGINT NOT NULL)'; } From 37131840f011ad523538a3ba42716e6da9cf82e0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:28:47 +0600 Subject: [PATCH 056/434] ci: diagnose exact PHPForge Pint delta --- .../workflows/phpforge-pint-diagnostic.yml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/phpforge-pint-diagnostic.yml diff --git a/.github/workflows/phpforge-pint-diagnostic.yml b/.github/workflows/phpforge-pint-diagnostic.yml new file mode 100644 index 00000000..6fa39b66 --- /dev/null +++ b/.github/workflows/phpforge-pint-diagnostic.yml @@ -0,0 +1,26 @@ +name: PHPForge Pint Diagnostic + +on: + push: + branches: + - feature/improvements + +jobs: + pint-diff: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - uses: ramsey/composer-install@v4 + with: + dependency-versions: highest + - name: Show exact PHPForge Pint changes + shell: bash + run: | + vendor/bin/pint --config=vendor/infocyph/phpforge/resources/pint.json src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php + git diff -- src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php From 64fc9ba5fcdfceb12e92efd2912192486e0a1cd6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:45:34 +0600 Subject: [PATCH 057/434] style(cache): fix Batch 2 codec formatting --- src/Cache/Adapter/CachePayloadCodec.php | 58 ++++++++++++------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index bd2d3131..559dde8d 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -27,7 +27,7 @@ private const string SIGNATURE_PURPOSE = 'cache-record:v3'; - public function __construct(private CacheOptions $options = new CacheOptions) {} + public function __construct(private CacheOptions $options = new CacheOptions()) {} /** @return array{ttl:int|null,expiresAt:int|null} */ public static function expirationFromItem(CacheItemInterface $item): array @@ -47,7 +47,7 @@ public static function isExpired(?int $expiresAt, ?int $now = null): bool public static function toDateTime(?int $expiresAt): ?DateTimeInterface { - return $expiresAt === null ? null : (new DateTimeImmutable)->setTimestamp($expiresAt); + return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); } public function decode( @@ -80,7 +80,7 @@ public function decode( } /** - * @param array $tags + * @param array $tags */ public function encode( mixed $value, @@ -103,12 +103,12 @@ public function encode( throw new RuntimeException('The encoded cache record exceeds the configured payload limit.'); } - $payload = self::PLAIN_PREFIX.$serialized; + $payload = self::PLAIN_PREFIX . $serialized; $threshold = $this->options->compressionThreshold; if ($threshold !== null && strlen($serialized) >= $threshold && function_exists('gzencode')) { $compressed = gzencode($serialized, $this->options->compressionLevel); if (is_string($compressed) && strlen($compressed) < strlen($serialized)) { - $payload = self::COMPRESSED_PREFIX.base64_encode($compressed); + $payload = self::COMPRESSED_PREFIX . base64_encode($compressed); } } @@ -128,10 +128,10 @@ private function assertNativeValueSupported(mixed $value): void if (is_resource($value)) { throw new InvalidArgumentException('Resource cache values are not supported.'); } - if (is_object($value) && ! $this->options->allowObjects) { + if (is_object($value) && !$this->options->allowObjects) { throw new InvalidArgumentException('Object cache values are disabled by security policy.'); } - if (! is_array($value)) { + if (!is_array($value)) { return; } foreach ($value as $item) { @@ -159,7 +159,7 @@ private function attachSignature( $this->options->integrityKey, ); - return self::BOUND_SIGNED_PREFIX.$signature.':'.$payload; + return self::BOUND_SIGNED_PREFIX . $signature . ':' . $payload; } private function containsUnsupportedDecodedValue(mixed $value): bool @@ -168,9 +168,9 @@ private function containsUnsupportedDecodedValue(mixed $value): bool return true; } if (is_object($value)) { - return ! $this->options->allowObjects; + return !$this->options->allowObjects; } - if (! is_array($value)) { + if (!is_array($value)) { return false; } foreach ($value as $item) { @@ -183,7 +183,7 @@ private function containsUnsupportedDecodedValue(mixed $value): bool } /** - * @param array $record + * @param array $record * @return array{valid:bool, value:mixed} */ private function decodeValue(array $record): array @@ -191,7 +191,7 @@ private function decodeValue(array $record): array $encoding = $record['encoding'] ?? null; $value = $record['value'] ?? null; if ($encoding === 'closure') { - if (! $this->options->allowClosures || ! is_string($value)) { + if (!$this->options->allowClosures || !is_string($value)) { return ['valid' => false, 'value' => null]; } @@ -212,7 +212,7 @@ private function decodeValue(array $record): array private function encodeValue(mixed $value): array { if ($value instanceof Closure) { - if (! $this->options->allowClosures) { + if (!$this->options->allowClosures) { throw new InvalidArgumentException('Closure cache values are disabled by security policy.'); } @@ -230,12 +230,12 @@ private function expandPayload(string $payload): ?string if (str_starts_with($payload, self::PLAIN_PREFIX)) { return substr($payload, strlen(self::PLAIN_PREFIX)); } - if (! str_starts_with($payload, self::COMPRESSED_PREFIX)) { + if (!str_starts_with($payload, self::COMPRESSED_PREFIX)) { return null; } $compressed = base64_decode(substr($payload, strlen(self::COMPRESSED_PREFIX)), true); - if (! is_string($compressed) || ! function_exists('gzdecode')) { + if (!is_string($compressed) || !function_exists('gzdecode')) { return null; } @@ -261,37 +261,37 @@ private function isPayloadTooLarge(string $payload): bool private function normalizeRecord(mixed $decoded): ?CacheRecord { - if (! is_array($decoded) || ($decoded['format'] ?? null) !== 2 || ! array_key_exists('value', $decoded)) { + if (!is_array($decoded) || ($decoded['format'] ?? null) !== 2 || !array_key_exists('value', $decoded)) { return null; } $expiresAt = $decoded['expires'] ?? null; - if ($expiresAt !== null && ! is_int($expiresAt)) { + if ($expiresAt !== null && !is_int($expiresAt)) { return null; } $tags = $decoded['tags'] ?? null; - if (! is_array($tags)) { + if (!is_array($tags)) { return null; } $namespaceGeneration = $decoded['namespace'] ?? null; if ($namespaceGeneration !== null - && (! is_string($namespaceGeneration) + && (!is_string($namespaceGeneration) || strlen($namespaceGeneration) !== 32 - || ! ctype_xdigit($namespaceGeneration))) { + || !ctype_xdigit($namespaceGeneration))) { return null; } $value = $this->decodeValue($decoded); - if (! $value['valid']) { + if (!$value['valid']) { return null; } foreach ($tags as $tag => $generation) { - if (! is_string($tag) - || ! is_string($generation) + if (!is_string($tag) + || !is_string($generation) || strlen($generation) !== 32 - || ! ctype_xdigit($generation)) { + || !ctype_xdigit($generation)) { return null; } } @@ -302,9 +302,9 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord private function signatureInput(string $payload, string $storageIdentity, string $key): string { return self::SIGNATURE_PURPOSE - ."\0".strlen($storageIdentity).':'.$storageIdentity - ."\0".strlen($key).':'.$key - ."\0".$payload; + . "\0" . strlen($storageIdentity) . ':' . $storageIdentity + . "\0" . strlen($key) . ':' . $key + . "\0" . $payload; } private function unserializeNative(string $payload): mixed @@ -336,7 +336,7 @@ private function verifyAndExtractSignature( return $this->unsignedPayload($blob); } if ($storageIdentity === null || $key === null - || ! str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { + || !str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { return null; } @@ -351,7 +351,7 @@ private function verifyAndExtractSignature( $separator - strlen(self::BOUND_SIGNED_PREFIX), ); $payload = substr($blob, $separator + 1); - if (strlen($signature) !== 64 || ! ctype_xdigit($signature)) { + if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { return null; } From 48bed764cadb991f1470e32f465be34b9d51e79c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:45:57 +0600 Subject: [PATCH 058/434] fix(cache): make invalidation identity schema idempotent --- .../Transport/Pdo/PdoInvalidationSchema.php | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 8e81f504..4ada69c6 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -18,7 +18,7 @@ public static function install(PDO $connection, bool $allowSqliteForTesting = fa try { $connection->exec(self::createTableSql($driver)); - if ($driver === 'mysql') { + if ($driver === 'mysql' && !self::mysqlIdentityColumnsAreBinary($connection)) { self::hardenMysqlIdentityColumns($connection); } self::createIndex($connection, $driver); @@ -38,12 +38,12 @@ private static function createIndex(PDO $connection, string $driver): void try { $connection->exec( - 'CREATE INDEX'.$ifNotExists.' cachelayer_invalidation_events_cluster_idx ' - .'ON '.self::TABLE.' (cluster_name, event_id)', + 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' + . 'ON ' . self::TABLE . ' (cluster_name, event_id)', ); } catch (PDOException $exception) { $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; - if ($driver !== 'mysql' || ! $duplicate) { + if ($driver !== 'mysql' || !$duplicate) { throw $exception; } } @@ -56,21 +56,28 @@ private static function createTableSql(string $driver): string 'pgsql' => 'BIGSERIAL PRIMARY KEY', default => 'INTEGER PRIMARY KEY AUTOINCREMENT', }; + $identity = $driver === 'mysql' + ? ' CHARACTER SET ascii COLLATE ascii_bin' + : ''; - return 'CREATE TABLE IF NOT EXISTS '.self::TABLE.' (' - .'event_id '.$id.', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' - .'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, origin_node_id VARCHAR(255) NOT NULL, ' - .'created_at BIGINT NOT NULL)'; + return 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' + . 'event_id ' . $id + . ', cluster_name VARCHAR(128)' . $identity . ' NOT NULL' + . ', namespace_name VARCHAR(64)' . $identity . ' NOT NULL' + . ', event_type VARCHAR(32)' . $identity . ' NOT NULL' + . ', identifier VARCHAR(64)' . $identity . ' NULL' + . ', origin_node_id VARCHAR(255)' . $identity . ' NOT NULL' + . ', created_at BIGINT NOT NULL)'; } private static function driver(PDO $connection, bool $allowSqliteForTesting): string { $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = is_string($driver) ? $driver : ''; - if (! in_array($driver, ['mysql', 'pgsql', 'sqlite'], true)) { + if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'], true)) { throw new ClusterTransportException('PDO invalidation transport supports MySQL and PostgreSQL only.'); } - if ($driver === 'sqlite' && ! $allowSqliteForTesting) { + if ($driver === 'sqlite' && !$allowSqliteForTesting) { throw new ClusterTransportException('SQLite is not a supported shared Cluster Cache transport.'); } @@ -80,12 +87,25 @@ private static function driver(PDO $connection, bool $allowSqliteForTesting): st private static function hardenMysqlIdentityColumns(PDO $connection): void { $connection->exec( - 'ALTER TABLE '.self::TABLE.' ' - .'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - .'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - .'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' - .'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' - .'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + 'ALTER TABLE ' . self::TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' + . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', ); } + + private static function mysqlIdentityColumnsAreBinary(PDO $connection): bool + { + $statement = $connection->prepare( + 'SELECT COUNT(*) FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . "AND column_name IN ('cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id') " + . "AND collation_name = 'ascii_bin'", + ); + $statement->execute([self::TABLE]); + + return (int) $statement->fetchColumn() === 5; + } } From f4e4483492f8c806ef9e8d9704909a50139fc863 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:46:47 +0600 Subject: [PATCH 059/434] refactor(node): make cache policy explicit in node config --- src/Node/NodeCacheConfig.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Node/NodeCacheConfig.php b/src/Node/NodeCacheConfig.php index 182e4624..60fbf03b 100644 --- a/src/Node/NodeCacheConfig.php +++ b/src/Node/NodeCacheConfig.php @@ -5,6 +5,7 @@ namespace Infocyph\CacheLayer\Node; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; @@ -13,14 +14,16 @@ { public string $namespace; + public CacheOptions $options; + public function __construct( public string $sqliteFile, string $namespace = 'default', public ?string $lockDirectory = null, public int $busyTimeoutMs = 1_000, public bool $apcuEnabled = true, - public bool $failOpen = true, public ?LockProviderInterface $lockProvider = null, + ?CacheOptions $options = null, ) { if ($sqliteFile === '' || str_contains($sqliteFile, "\0")) { throw new NodeCacheConfigurationException('The SQLite cache file path is invalid.'); @@ -39,5 +42,7 @@ public function __construct( } catch (CacheInvalidArgumentException $failure) { throw new NodeCacheConfigurationException($failure->getMessage(), 0, $failure); } + + $this->options = $options ?? new CacheOptions(); } } From 903ee4b7e383b343deb4d2b4ad1ac24ae49f4b1a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:47:03 +0600 Subject: [PATCH 060/434] refactor(node): apply unified cache options --- src/Node/NodeCache.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Node/NodeCache.php b/src/Node/NodeCache.php index d7712de6..bd84f580 100644 --- a/src/Node/NodeCache.php +++ b/src/Node/NodeCache.php @@ -6,7 +6,6 @@ use Infocyph\CacheLayer\Cache\Adapter\ApcuCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; @@ -25,7 +24,7 @@ public static function create(NodeCacheConfig $config): Cache $adapter = new NodeCacheAdapter( self::createApcuAdapter($config, $storageIdentity), new NodeSqliteCacheAdapter($connection, $config->namespace), - $config->failOpen, + $config->options->failOpen, $metrics, ); @@ -33,7 +32,7 @@ public static function create(NodeCacheConfig $config): Cache $adapter, $config->lockProvider ?? new FileLockProvider($config->lockDirectory), $metrics, - new CacheOptions(failOpen: $config->failOpen), + $config->options, $storageIdentity, ); } From 261080367bac0c5dcd9146cb6f625b48781670f8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:47:18 +0600 Subject: [PATCH 061/434] fix(cache): avoid repeated SQL identity DDL --- src/Cache/Adapter/PdoCacheSchema.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php index 0bfad834..9122ac7c 100644 --- a/src/Cache/Adapter/PdoCacheSchema.php +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -18,7 +18,9 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): $driverValue = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = is_string($driverValue) ? $driverValue : ''; - $identifier = in_array($driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + $identifier = in_array($driver, ['mysql', 'mariadb'], true) + ? 'VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin' + : 'TEXT'; $payload = match ($driver) { 'mysql', 'mariadb' => 'MEDIUMBLOB', 'pgsql' => 'BYTEA', @@ -34,7 +36,8 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): PRIMARY KEY (namespace, kind, cache_key) )", ); - if (in_array($driver, ['mysql', 'mariadb'], true)) { + if (in_array($driver, ['mysql', 'mariadb'], true) + && !self::mysqlIdentityColumnsAreBinary($pdo, $table)) { self::hardenMysqlIdentityColumns($pdo, $table); } @@ -65,4 +68,17 @@ private static function hardenMysqlIdentityColumns(PDO $pdo, string $table): voi . 'MODIFY cache_key VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', ); } + + private static function mysqlIdentityColumnsAreBinary(PDO $pdo, string $table): bool + { + $statement = $pdo->prepare( + 'SELECT COUNT(*) FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . "AND column_name IN ('namespace', 'kind', 'cache_key') " + . "AND collation_name = 'ascii_bin'", + ); + $statement->execute([$table]); + + return (int) $statement->fetchColumn() === 3; + } } From 12b39292ef549abea269804b6a8648b82c891030 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:48:19 +0600 Subject: [PATCH 062/434] fix(node): fence failed L1 from stale reads --- src/Node/Adapter/NodeCacheAdapter.php | 119 ++++++++++++++++++++------ 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 161dd443..dd4db12f 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -16,6 +16,8 @@ final class NodeCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { + private bool $l1Readable = true; + public function __construct( private readonly ?InternalCachePoolInterface $l1, private readonly NodeSqliteCacheAdapter $l2, @@ -46,7 +48,11 @@ public function assertStorageIdentityCompatible(string $storageIdentity): void public function clear(): bool { $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); - $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + $l1 = $this->l1 === null || !$this->l1Readable + || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } $this->deferred = []; return $l2 && $l1; @@ -77,8 +83,11 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { $l2 = $this->attempt(fn(): bool => $this->l2->deleteItem($key), false, 'l2_failure'); - $l1 = $this->l1 === null + $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItem($key), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } return $l2 && $l1; } @@ -87,27 +96,41 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $l2 = $this->attempt(fn(): bool => $this->l2->deleteItems($keys), false, 'l2_failure'); - $l1 = $this->l1 === null + $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItems($keys), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } return $l2 && $l1; } public function getItem(string $key): CacheItem { - if ($this->l1 !== null) { - $l1 = $this->attempt(fn(): CacheItemInterface => $this->l1->getItem($key), $this->genericMiss($key), 'l1_failure'); + if ($this->l1 !== null && $this->l1Readable) { + $l1 = $this->attempt( + fn(): CacheItemInterface => $this->l1->getItem($key), + $this->genericMiss($key), + 'l1_failure', + ); if ($l1->isHit()) { return $this->nodeItem($l1); } } - $l2 = $this->attempt(fn(): CacheItemInterface => $this->l2->getItem($key), $this->genericMiss($key), 'l2_failure'); + + $l2 = $this->attempt( + fn(): CacheItemInterface => $this->l2->getItem($key), + $this->genericMiss($key), + 'l2_failure', + ); if (!$l2->isHit()) { return $this->genericMiss($key); } + $item = $this->nodeItem($l2); - if ($this->l1 !== null) { - $this->saveOneInto($this->l1, $item, 'l1_failure'); + if ($this->l1 !== null && $this->l1Readable + && !$this->saveOneInto($this->l1, $item, 'l1_failure')) { + $this->disableL1(); } return $item; @@ -120,7 +143,7 @@ public function getItem(string $key): CacheItem #[\Override] public function getTagGenerations(array $tags): array { - $cached = !$this->l1 instanceof TagGenerationCacheInterface + $cached = !$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface) ? [] : $this->attempt(fn(): array => $this->l1->readTagGenerations($tags), [], 'l1_failure'); $missing = array_values(array_diff($tags, array_keys($cached))); @@ -133,8 +156,9 @@ public function getTagGenerations(array $tags): array $this->freshGenerations($missing), 'l2_failure', ); - if ($this->l1 instanceof TagGenerationCacheInterface) { - $this->attempt(fn(): bool => $this->l1->storeTagGenerations($loaded), false, 'l1_failure'); + if ($this->l1Readable && $this->l1 instanceof TagGenerationCacheInterface + && !$this->attempt(fn(): bool => $this->l1->storeTagGenerations($loaded), false, 'l1_failure')) { + $this->disableL1(); } return $cached + $loaded; @@ -147,7 +171,7 @@ public function hasItem(string $key): bool public function isAuthoritative(): bool { - return $this->l1 === null; + return $this->l1 === null || !$this->l1Readable; } /** @@ -159,9 +183,12 @@ public function multiFetch(array $keys): array [$results, $misses] = $this->readL1($keys); $promote = $this->readL2($misses, $results); - if ($promote !== [] && $this->l1 !== null) { - $this->saveInto($this->l1, $promote, 'l1_failure'); - $this->metric('l2_batch_promote', count($promote)); + if ($promote !== [] && $this->l1 !== null && $this->l1Readable) { + if ($this->saveInto($this->l1, $promote, 'l1_failure')) { + $this->metric('l2_batch_promote', count($promote)); + } else { + $this->disableL1(); + } } $ordered = []; @@ -184,7 +211,7 @@ public function readTagGenerations(array $tags): array public function rotateTagGenerations(array $tags): bool { $l2 = $this->attempt(fn(): bool => $this->l2->rotateTagGenerations($tags), false, 'l2_failure'); - if (!$l2 || !$this->l1 instanceof TagGenerationCacheInterface) { + if (!$l2 || !$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface)) { return $l2; } @@ -193,7 +220,7 @@ public function rotateTagGenerations(array $tags): bool $stored = $generations !== [] && $this->attempt(fn(): bool => $this->l1->storeTagGenerations($generations), false, 'l1_failure'); if (!$fenced || !$stored) { - $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + $this->disableL1(); } return $fenced && $stored; @@ -204,17 +231,21 @@ public function save(CacheItemInterface $item): bool if (!$this->supportsItem($item)) { return false; } + $stored = $this->saveOneInto($this->l2, $item, 'l2_failure'); if (!$stored) { return false; } - if ($this->l1 === null) { - return $stored; + if ($this->l1 === null || !$this->l1Readable) { + return true; } - $this->saveOneInto($this->l1, $item, 'l1_failure'); + $l1Stored = $this->saveOneInto($this->l1, $item, 'l1_failure'); + if (!$l1Stored) { + $this->disableL1(); + } - return true; + return $l1Stored; } /** @param array $items */ @@ -230,20 +261,39 @@ public function saveItems(array $items): bool if (!$stored) { return false; } - if ($this->l1 !== null) { - $this->saveInto($this->l1, $items, 'l1_failure'); + if ($this->l1 === null || !$this->l1Readable) { + return true; + } + + $l1Stored = $this->saveInto($this->l1, $items, 'l1_failure'); + if (!$l1Stored) { + $this->disableL1(); } - return true; + return $l1Stored; } /** @param array $generations */ #[\Override] public function storeTagGenerations(array $generations): bool { - return $this->l2->storeTagGenerations($generations) - && (!$this->l1 instanceof TagGenerationCacheInterface - || $this->l1->storeTagGenerations($generations)); + if (!$this->l2->storeTagGenerations($generations)) { + return false; + } + if (!$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface)) { + return true; + } + + $stored = $this->attempt( + fn(): bool => $this->l1->storeTagGenerations($generations), + false, + 'l1_failure', + ); + if (!$stored) { + $this->disableL1(); + } + + return $stored; } /** @@ -266,6 +316,18 @@ private function attempt(callable $operation, mixed $fallback, string $failureMe } } + private function disableL1(): void + { + if (!$this->l1Readable) { + return; + } + + $this->l1Readable = false; + if ($this->l1 !== null) { + $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_clear_failure'); + } + } + /** * @param list $tags * @return array @@ -303,7 +365,7 @@ private function nodeItem(CacheItemInterface $item): CacheItem */ private function readL1(array $keys): array { - if ($this->l1 === null || $keys === []) { + if ($this->l1 === null || !$this->l1Readable || $keys === []) { return [[], $keys]; } @@ -334,6 +396,7 @@ private function readL2(array $keys, array &$results): array if ($keys === []) { return []; } + $items = $this->attempt(fn(): array => $this->l2->multiFetch($keys), [], 'l2_failure'); $hits = []; foreach ($keys as $key) { From a56c4eb485b3e6d2aa635da45693b65819b767cf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:48:58 +0600 Subject: [PATCH 063/434] test(node): cover policy and L1 coherence --- tests/Node/NodeCacheTest.php | 112 ++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 539724d9..7a2c1958 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -2,8 +2,11 @@ declare(strict_types=1); +use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\LockHandle; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; @@ -14,6 +17,7 @@ use Infocyph\CacheLayer\Node\Maintenance\NodeCachePruner; use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Psr\Cache\CacheItemInterface; beforeEach(function () { $this->nodeCacheDirectory = sys_get_temp_dir() . '/cachelayer-node-' . uniqid(); @@ -174,6 +178,113 @@ public function release(?LockHandle $handle): void ->and($l2->getItem('coherent')->get())->toBe('old'); }); +test('node cache applies the complete cache policy from its configuration', function () { + $cache = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/policy.sqlite', + namespace: 'policy', + apcuEnabled: false, + options: new CacheOptions( + integrityKey: 'node-policy-key', + maxPayloadBytes: 1_048_576, + failOpen: false, + ), + )); + + expect($cache->hasPayloadIntegrity())->toBeTrue() + ->and($cache->isFailOpen())->toBeFalse() + ->and($cache->set('scalar', 'value'))->toBeTrue() + ->and($cache->get('scalar'))->toBe('value'); +}); + +test('node disables a failed L1 before it can serve stale data', function () { + $connection = NodeSqliteConnection::create($this->nodeConfig); + $l1 = new class extends AbstractCacheAdapter { + public bool $rejectWrites = false; + + /** @var array */ + private array $values = []; + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->values[$key]); + } + + return true; + } + + public function getItem(string $key): CacheItem + { + return array_key_exists($key, $this->values) + ? new CacheItem($this, $key, $this->values[$key], true) + : new CacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if ($this->rejectWrites || !$this->supportsItem($item)) { + return false; + } + + $this->values[$item->getKey()] = $item->get(); + + return true; + } + + public function saveItems(array $items): bool + { + if ($this->rejectWrites || !$this->supportsItems($items)) { + return false; + } + + foreach ($items as $item) { + $this->values[$item->getKey()] = $item->get(); + } + + return true; + } + }; + $l2 = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); + $cache = new Cache(new NodeCacheAdapter($l1, $l2, true)); + + expect($cache->set('coherent', 'old', 300))->toBeTrue(); + $l1->rejectWrites = true; + + expect($cache->set('coherent', 'new', 300))->toBeFalse() + ->and($cache->get('coherent'))->toBe('new') + ->and($cache->setMultiple(['one' => 1, 'two' => 2], 300))->toBeTrue() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 1, 'two' => 2]); +}); + test('expired rows remain outside the read path until bounded pruning', function () { $connection = NodeSqliteConnection::create($this->nodeConfig); $adapter = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); @@ -197,7 +308,6 @@ public function release(?LockHandle $handle): void ->toThrow(NodeCacheConfigurationException::class); }); - test('node APCu identity includes the SQLite store', function () { expect(extension_loaded('apcu'))->toBeTrue() ->and(apcu_enabled())->toBeTrue(); From 92b0042fd3e5198cebb98644570034ca905ce8be Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:49:17 +0600 Subject: [PATCH 064/434] docs(node): document unified policy and L1 fencing --- docs/node/_content.inc | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/node/_content.inc b/docs/node/_content.inc index e3382a12..a659c615 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -77,10 +77,11 @@ Write lifecycle 3. A delete or namespace clear is attempted across both layers. 4. Tagged entries and tag-generation metadata are local to this node. -An L2 write failure never populates L1, even with ``failOpen: true``. This -prevents a failed authoritative write from temporarily surfacing through L1. -Set ``failOpen: false`` when a storage failure must be surfaced to the -application instead of being returned as ``false``. +An L2 write failure never populates L1. If an L1 mutation fails after SQLite +has accepted the authoritative state, that Node adapter stops reading L1 so an +older promoted value cannot override SQLite. Configure ``options->failOpen`` +according to whether storage failures should degrade to cache misses/false +results or propagate to the application. Requirements and filesystem placement -------------------------------------- @@ -120,6 +121,7 @@ that database. .. code-block:: php + use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; @@ -129,7 +131,10 @@ that database. lockDirectory: '/var/cache/my-application/locks', busyTimeoutMs: 1_000, apcuEnabled: true, - failOpen: true, + options: new CacheOptions( + integrityKey: $integrityKey, + failOpen: true, + ), ); $cache = NodeCache::create($config); @@ -159,10 +164,11 @@ Configuration reference Enables APCu L1 when the extension and SAPI support it. Set it to ``false`` for deterministic SQLite-only behavior, CLI jobs, or troubleshooting. -``failOpen`` - Defaults to ``true``. When true, recoverable layer failures are treated as - cache degradation where possible. When false, APCu/SQLite failures propagate - to the caller. +``options`` + Optional :class:`CacheOptions` applied consistently to the Node facade, + SQLite L2, and APCu L1. Use it to configure payload integrity, payload limits, + compression, object/Closure policy, and ``failOpen``. Defaults to normal + CacheLayer cache options. ``lockProvider`` Optional ``LockProviderInterface`` used by ``remember()``. When omitted, @@ -410,10 +416,10 @@ Node Cache layer events. Export it through your application's telemetry path: $metrics = $cache->exportMetrics(); Layer failures are represented by metrics such as APCu/SQLite failures when -``failOpen`` is enabled. Alert on sustained failures, a sharp fall in hit rate, -rapid SQLite file growth, and repeated lock contention. With ``failOpen`` -disabled, also handle the propagated cache exception according to the request's -availability requirements. +``options->failOpen`` is enabled. Alert on sustained failures, a sharp fall in +hit rate, rapid SQLite file growth, and repeated lock contention. With +``options->failOpen`` disabled, also handle the propagated cache exception +according to the request's availability requirements. Troubleshooting --------------- @@ -436,5 +442,6 @@ Troubleshooting removed by reads. Review TTL choices and cache cardinality as well. ``A cache failure should fail the request`` - Create the configuration with ``failOpen: false`` and ensure the application + Create the configuration with + ``options: new CacheOptions(failOpen: false)`` and ensure the application handles the resulting exception at the appropriate boundary. From da86c5a371264a1e7183a0213e6242e0836e9077 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:55:35 +0600 Subject: [PATCH 065/434] style(cache): align codec function braces with PER --- src/Cache/Adapter/CachePayloadCodec.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 559dde8d..21d9b0f7 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -54,7 +54,8 @@ public function decode( string $blob, ?string $storageIdentity = null, ?string $key = null, - ): ?CacheRecord { + ): ?CacheRecord + { if ($this->isPayloadTooLarge($blob)) { return null; } @@ -89,7 +90,8 @@ public function encode( ?string $namespaceGeneration = null, ?string $storageIdentity = null, ?string $key = null, - ): string { + ): string + { [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ 'format' => 2, @@ -143,7 +145,8 @@ private function attachSignature( string $payload, ?string $storageIdentity, ?string $key, - ): string { + ): string + { if ($this->options->integrityKey === null) { return $payload; } @@ -330,7 +333,8 @@ private function verifyAndExtractSignature( string $blob, ?string $storageIdentity, ?string $key, - ): ?string { + ): ?string + { $integrityKey = $this->options->integrityKey; if ($integrityKey === null) { return $this->unsignedPayload($blob); From 1924a74da3b9d6474696631405e839bd52ec158b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 17:59:47 +0600 Subject: [PATCH 066/434] style(cache): match PER arrow-function spacing --- src/Cache/Adapter/CachePayloadCodec.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 21d9b0f7..c983ec31 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -54,8 +54,7 @@ public function decode( string $blob, ?string $storageIdentity = null, ?string $key = null, - ): ?CacheRecord - { + ): ?CacheRecord { if ($this->isPayloadTooLarge($blob)) { return null; } @@ -90,8 +89,7 @@ public function encode( ?string $namespaceGeneration = null, ?string $storageIdentity = null, ?string $key = null, - ): string - { + ): string { [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ 'format' => 2, @@ -145,8 +143,7 @@ private function attachSignature( string $payload, ?string $storageIdentity, ?string $key, - ): string - { + ): string { if ($this->options->integrityKey === null) { return $payload; } @@ -245,7 +242,7 @@ private function expandPayload(string $payload): ?string $maximumLength = $this->options->maxPayloadBytes === null ? 0 : min($this->options->maxPayloadBytes, PHP_INT_MAX - 1) + 1; - set_error_handler(static fn (): bool => true); + set_error_handler(static fn(): bool => true); try { $expanded = gzdecode($compressed, $maximumLength); @@ -312,7 +309,7 @@ private function signatureInput(string $payload, string $storageIdentity, string private function unserializeNative(string $payload): mixed { - set_error_handler(static fn (): bool => true); + set_error_handler(static fn(): bool => true); try { return unserialize($payload, [ @@ -333,8 +330,7 @@ private function verifyAndExtractSignature( string $blob, ?string $storageIdentity, ?string $key, - ): ?string - { + ): ?string { $integrityKey = $this->options->integrityKey; if ($integrityKey === null) { return $this->unsignedPayload($blob); From 885d1800484c0d27682bc458746d9eba26240059 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:03:06 +0600 Subject: [PATCH 067/434] docs(plan): close verified Batch 2 tracker --- ...cachelayer-4.0-security-correctness-plan.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 5e427cbd..37a85c0d 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batch 1 complete, Batch 2 in progress\ +Status: Implementation in progress; Batches 1-2 complete; Batch 3 next\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -14,8 +14,8 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Batch | Findings | Status | Current gate | | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | -| 2 — Authenticated payload/storage identity | R02, R15, R18 | **In progress** | Contract tests added; R15 storage/topology identity implemented; R02 logical identity propagation and signed-payload binding are in progress; R18 SQL collation migration pending. | -| 3 — Durable invalidation protocol | R06, R07 | Not started | Blocked on Batch 2 identity decisions. | +| 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | +| 3 — Durable invalidation protocol | R06, R07 | Not started | Ready to start after verified Batch 2 closure; R15 topology follow-through is tracked with invalidation/coherence acceptance. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | @@ -38,13 +38,13 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | -| R02 — authenticated payload identity | **In progress** | Contract tests cover cross-key, cross-namespace, legacy signed payload rejection, tier promotion, and secure serialization defaults. Logical storage identity now propagates through facades/tiered/Node; bound HMAC envelope implemented; adapter read/atomic paths are being wired to verify logical keys. | Full Batch 2 QA pending. | -| R15 — Node storage/topology identity | **Implemented; QA pending** | Node APCu identity and lock identity include the SQLite store; authority now reflects whether an L1 can serve stale state. | Contract tests added; full Batch 2 QA pending. | -| R18 — SQL binary identity | **Implemented; QA pending** | MySQL/MariaDB regression test requires byte-sensitive cache and invalidation identity columns and verifies case-distinct namespaces/keys. Existing tables are migrated to `ascii_bin` identity columns by schema installation. | Full Batch 2 QA pending. | +| R02 — authenticated payload identity | **Complete** | Bound `cache-record:v3` HMAC envelopes authenticate purpose, logical storage identity, and key. Cross-key/cross-namespace substitution, legacy unbound signed payloads, malformed/wrong-key payloads, tier promotion, secure defaults, and adapter/atomic verification paths have regression coverage. | Passed final Batch 2 QA on run #210. | +| R15 — Node storage/topology identity | **Complete for Batch 2 scope** | Node APCu and lock identities include the SQLite store; `NodeCacheConfig` carries one cohesive `CacheOptions` policy; failed L1 mutations fence that L1 from later reads so stale promoted state cannot override authoritative SQLite. Broader cross-process/node invalidation coherence remains coupled to Batch 3 acceptance. | Passed final Batch 2 QA on run #210. | +| R18 — SQL binary identity | **Complete** | New MySQL/MariaDB cache and invalidation schemas create identity columns as `ascii_bin`; existing schemas are metadata-checked and hardened only when required, avoiding repeated identity `ALTER TABLE` work. Backend regressions verify byte-sensitive identity and case-distinct namespaces/keys. | Passed final Batch 2 QA on run #210. Consolidated upgrade/rollback instructions remain a Batch 6 release-documentation gate. | -**Batch 2 implementation commits so far:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, and `f2c3e55db67ecc3237dc87494c2828c66f33d5ee` (adapter identity verification wiring), `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (remaining atomic/backend identity verification and codec complexity cleanup), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` (MySQL/MariaDB binary-collation migration), and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SharedMemory identity binding fix). +**Batch 2 implementation/closure commits:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, `f2c3e55db67ecc3237dc87494c2828c66f33d5ee`, and `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (adapter/atomic identity verification and codec hardening), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SQL/shared-memory identity), `64fc9ba5fcdfceb12e92efd2912192486e0a1cd6` through `1924a74da3b9d6474696631405e839bd52ec158b` (final schema idempotence, unified Node policy, L1 coherence fencing, regressions/docs, and exact PHPForge formatting). -## Decision +**Batch 2 closure evidence:** exact commit `1924a74da3b9d6474696631405e839bd52ec158b` passed Security & Standards run #210: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed.\n\n## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. @@ -459,4 +459,4 @@ The temporary network probe uses the audit's allocated ports; recreate disposabl - [Redis Lua API conversion rules](https://redis.io/docs/latest/develop/programmability/lua-api/) explain numeric reply conversion relevant to the reproduced R14 precision failure. - [PHP Memcached expiration rules](https://www.php.net/manual/en/memcached.expiration.php) specify the 30-day relative/absolute cutoff underlying R16. - [PHP object ID lifetime](https://www.php.net/manual/en/function.spl-object-id.php) documents ID reuse after destruction, relevant to R08. -- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. That backend-specific finding still requires the listed integration test. +- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. The Batch 2 backend regression now covers the required byte-sensitive identity behavior; broader final backend-matrix coverage remains under R19. From 965d90ea9fbc6e39f77988e3aef4900a792b30c9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:07:49 +0600 Subject: [PATCH 068/434] fix(cluster): scope cursors by namespace --- src/Cluster/Cursor/SqliteCursorStore.php | 47 +++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index 8c54b610..f5653385 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cluster\Cursor; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; use Infocyph\CacheLayer\Node\NodeCacheConfig; @@ -12,14 +13,20 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { + private const string TABLE = 'cachelayer_cluster_cursors_v2'; + private PDO $connection; + private string $namespace; + public function __construct( string $sqliteFile, private string $cluster, private string $nodeId, + string $namespace, ?PDO $connection = null, ) { + $this->namespace = CacheInput::namespace($namespace); $this->connection = $connection ?? NodeSqliteConnection::create( new NodeCacheConfig($sqliteFile, 'cluster-cursor'), ); @@ -38,8 +45,9 @@ public function advance(string $eventId): void public function current(): ?string { $cursor = $this->read( - 'SELECT last_event_id FROM cachelayer_cluster_cursors ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', + 'SELECT last_event_id FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace LIMIT 1', 'Unable to read the cluster cursor.', ); @@ -54,8 +62,9 @@ public function reset(?string $eventId): void public function updatedAt(): ?int { $updatedAt = $this->read( - 'SELECT updated_at FROM cachelayer_cluster_cursors ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', + 'SELECT updated_at FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace LIMIT 1', 'Unable to read the cluster cursor update time.', ); @@ -68,9 +77,10 @@ private function createSchemaIfMissing(): void { try { $this->connection->exec( - 'CREATE TABLE IF NOT EXISTS cachelayer_cluster_cursors (' - . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' - . 'PRIMARY KEY (cluster_name, node_id)) WITHOUT ROWID', + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name)) WITHOUT ROWID', ); } catch (PDOException $exception) { throw new ClusterCacheException('Unable to initialize the cluster cursor store.', 0, $exception); @@ -81,7 +91,7 @@ private function read(string $sql, string $failureMessage): mixed { try { $statement = $this->connection->prepare($sql); - $statement->execute([':cluster' => $this->cluster, ':node_id' => $this->nodeId]); + $statement->execute($this->scopeParameters()); return $statement->fetchColumn(); } catch (PDOException $exception) { @@ -89,18 +99,27 @@ private function read(string $sql, string $failureMessage): mixed } } + /** @return array{cluster:string, node_id:string, namespace:string} */ + private function scopeParameters(): array + { + return [ + ':cluster' => $this->cluster, + ':node_id' => $this->nodeId, + ':namespace' => $this->namespace, + ]; + } + private function write(?string $eventId): void { try { $statement = $this->connection->prepare( - 'INSERT INTO cachelayer_cluster_cursors (cluster_name, node_id, last_event_id, updated_at) ' - . 'VALUES (:cluster, :node_id, :event_id, :updated_at) ' - . 'ON CONFLICT(cluster_name, node_id) DO UPDATE SET ' + 'INSERT INTO ' . self::TABLE . ' ' + . '(cluster_name, node_id, namespace_name, last_event_id, updated_at) ' + . 'VALUES (:cluster, :node_id, :namespace, :event_id, :updated_at) ' + . 'ON CONFLICT(cluster_name, node_id, namespace_name) DO UPDATE SET ' . 'last_event_id = excluded.last_event_id, updated_at = excluded.updated_at', ); - $statement->execute([ - ':cluster' => $this->cluster, - ':node_id' => $this->nodeId, + $statement->execute($this->scopeParameters() + [ ':event_id' => $eventId, ':updated_at' => time(), ]); From 896e6e97cb3745b0de14555aab7d988a16c9d5fc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:07:59 +0600 Subject: [PATCH 069/434] fix(cluster): bind runtime cursor to namespace --- src/Cluster/ClusterCache.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Cluster/ClusterCache.php b/src/Cluster/ClusterCache.php index 25ac6ab1..25325a0a 100644 --- a/src/Cluster/ClusterCache.php +++ b/src/Cluster/ClusterCache.php @@ -22,7 +22,12 @@ public static function create( InvalidationTransportInterface $transport, ): ClusterRuntime { $cache = NodeCache::create($node); - $cursorStore = new SqliteCursorStore($node->sqliteFile, $cluster->cluster, $cluster->nodeId); + $cursorStore = new SqliteCursorStore( + $node->sqliteFile, + $cluster->cluster, + $cluster->nodeId, + $node->namespace, + ); $status = new ClusterStatusTracker(); $recovery = new ClusterRecoveryManager($cache, $cursorStore, $transport, $cluster->cluster); $coordinator = new ClusterCoordinator( From c46668e6b36ca2e487a9c104bd24980d410d269c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:08:14 +0600 Subject: [PATCH 070/434] fix(cluster): recover safely from unscoped cursor state --- .../Recovery/ClusterRecoveryManager.php | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Cluster/Recovery/ClusterRecoveryManager.php b/src/Cluster/Recovery/ClusterRecoveryManager.php index c5e82ae4..56b8e755 100644 --- a/src/Cluster/Recovery/ClusterRecoveryManager.php +++ b/src/Cluster/Recovery/ClusterRecoveryManager.php @@ -22,15 +22,29 @@ public function recoverIfRequired(): bool { $cursor = $this->cursorStore->current(); $oldest = $this->transport->oldestAvailableId($this->cluster); - if ($cursor === null || $oldest === null || !$this->transport->isCursorBefore($cursor, $oldest)) { + if ($oldest === null) { return false; } - if (!$this->cache->clear()) { - throw new ClusterCacheException('Unable to clear the local cache during cluster recovery.'); + if ($cursor === null) { + $this->clearLocalCache(); + + return true; } + if (!$this->transport->isCursorBefore($cursor, $oldest)) { + return false; + } + + $this->clearLocalCache(); $this->cursorStore->reset($oldest); return true; } + + private function clearLocalCache(): void + { + if (!$this->cache->clear()) { + throw new ClusterCacheException('Unable to clear the local cache during cluster recovery.'); + } + } } From 35fa484dee6e7f8fe5f4578bca93a8d8f9c750a0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:08:33 +0600 Subject: [PATCH 071/434] test(cluster): cover namespace-scoped cursor progress --- tests/Cluster/ClusterCacheTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 19e82a9f..3feb7a97 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -114,6 +114,32 @@ ->and($this->nodeB->cache()->get('search.list'))->toBeNull(); }); +test('cursor progress is isolated by namespace on the same node and SQLite store', function () { + $transport = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/shared-node.sqlite'; + $cluster = new ClusterCacheConfig('scope-cluster', 'shared-node'); + $alpha = ClusterCache::create( + new NodeCacheConfig($sqliteFile, 'alpha', apcuEnabled: false), + $cluster, + $transport, + ); + $beta = ClusterCache::create( + new NodeCacheConfig($sqliteFile, 'beta', apcuEnabled: false), + $cluster, + $transport, + ); + + $beta->cache()->set('shared', 'stale', 300); + $transport->publish(InvalidationEvent::key('scope-cluster', 'beta', 'shared', 'writer')); + + expect($alpha->consume())->toBe(1) + ->and($alpha->status()->cursor)->toBe('1') + ->and($beta->status()->cursor)->toBeNull() + ->and($beta->consume())->toBe(1) + ->and($beta->cache()->get('shared'))->toBeNull() + ->and($beta->status()->cursor)->toBe('1'); +}); + test('cluster status reports cursor position, pending events, and consume results', function () { $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'first', 'writer')); $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'second', 'writer')); @@ -290,6 +316,7 @@ $this->clusterDirectory . '/failed-cursor.sqlite', 'failed-cluster', 'consumer', + 'application', ); $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'failed-cluster'); $consumer = new InvalidationConsumer( @@ -319,6 +346,7 @@ $this->clusterDirectory . '/recovery-failed-cursor.sqlite', 'recovery-failure', 'consumer', + 'application', ); $cursor->advance('1'); $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'recovery-failure'); From 6a658362f6dc777f1f0f50a196289d7ef1ef26c0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:08:48 +0600 Subject: [PATCH 072/434] docs(plan): mark Batch 3 active --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 37a85c0d..f680926a 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batches 1-2 complete; Batch 3 next\ +Status: Implementation in progress; Batches 1-2 complete, Batch 3 in progress\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -15,7 +15,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | -| 3 — Durable invalidation protocol | R06, R07 | Not started | Ready to start after verified Batch 2 closure; R15 topology follow-through is tracked with invalidation/coherence acceptance. | +| 3 — Durable invalidation protocol | R06, R07 | **In progress** | R07 namespace-scoped cursor storage and safe cursor reset/recovery are implemented with regression coverage; R06 commit-order-safe PDO publication is next. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | From 4b885a133b8ec08dd0ed855e502a708f705e27fc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:10:33 +0600 Subject: [PATCH 073/434] feat(cluster): add per-cluster publication lock schema --- .../Transport/Pdo/PdoInvalidationSchema.php | 93 +++++++++++++------ 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 4ada69c6..a0fbef7a 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -10,18 +10,24 @@ final class PdoInvalidationSchema { - private const string TABLE = 'cachelayer_invalidation_events'; + public const string EVENT_TABLE = 'cachelayer_invalidation_events'; + + public const string LOCK_TABLE = 'cachelayer_invalidation_clusters'; public static function install(PDO $connection, bool $allowSqliteForTesting = false): void { $driver = self::driver($connection, $allowSqliteForTesting); try { - $connection->exec(self::createTableSql($driver)); - if ($driver === 'mysql' && !self::mysqlIdentityColumnsAreBinary($connection)) { - self::hardenMysqlIdentityColumns($connection); + $connection->exec(self::createEventTableSql($driver)); + if ($driver === 'mysql' && !self::mysqlEventIdentityColumnsAreBinary($connection)) { + self::hardenMysqlEventIdentityColumns($connection); } self::createIndex($connection, $driver); + $connection->exec(self::createLockTableSql($driver)); + if ($driver === 'mysql' && !self::mysqlLockIdentityIsBinary($connection)) { + self::hardenMysqlLockIdentity($connection); + } } catch (PDOException $exception) { throw new ClusterTransportException('Unable to initialize the PDO invalidation transport schema.', 0, $exception); } @@ -32,6 +38,27 @@ public static function validateConnection(PDO $connection, bool $allowSqliteForT return self::driver($connection, $allowSqliteForTesting); } + private static function createEventTableSql(string $driver): string + { + $id = match ($driver) { + 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', + 'pgsql' => 'BIGSERIAL PRIMARY KEY', + default => 'INTEGER PRIMARY KEY AUTOINCREMENT', + }; + $identity = $driver === 'mysql' + ? ' CHARACTER SET ascii COLLATE ascii_bin' + : ''; + + return 'CREATE TABLE IF NOT EXISTS ' . self::EVENT_TABLE . ' (' + . 'event_id ' . $id + . ', cluster_name VARCHAR(128)' . $identity . ' NOT NULL' + . ', namespace_name VARCHAR(64)' . $identity . ' NOT NULL' + . ', event_type VARCHAR(32)' . $identity . ' NOT NULL' + . ', identifier VARCHAR(64)' . $identity . ' NULL' + . ', origin_node_id VARCHAR(255)' . $identity . ' NOT NULL' + . ', created_at BIGINT NOT NULL)'; + } + private static function createIndex(PDO $connection, string $driver): void { $ifNotExists = $driver === 'mysql' ? '' : ' IF NOT EXISTS'; @@ -39,7 +66,7 @@ private static function createIndex(PDO $connection, string $driver): void try { $connection->exec( 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' - . 'ON ' . self::TABLE . ' (cluster_name, event_id)', + . 'ON ' . self::EVENT_TABLE . ' (cluster_name, event_id)', ); } catch (PDOException $exception) { $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; @@ -49,25 +76,14 @@ private static function createIndex(PDO $connection, string $driver): void } } - private static function createTableSql(string $driver): string + private static function createLockTableSql(string $driver): string { - $id = match ($driver) { - 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', - 'pgsql' => 'BIGSERIAL PRIMARY KEY', - default => 'INTEGER PRIMARY KEY AUTOINCREMENT', - }; $identity = $driver === 'mysql' - ? ' CHARACTER SET ascii COLLATE ascii_bin' - : ''; + ? 'VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin' + : 'VARCHAR(128)'; - return 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'event_id ' . $id - . ', cluster_name VARCHAR(128)' . $identity . ' NOT NULL' - . ', namespace_name VARCHAR(64)' . $identity . ' NOT NULL' - . ', event_type VARCHAR(32)' . $identity . ' NOT NULL' - . ', identifier VARCHAR(64)' . $identity . ' NULL' - . ', origin_node_id VARCHAR(255)' . $identity . ' NOT NULL' - . ', created_at BIGINT NOT NULL)'; + return 'CREATE TABLE IF NOT EXISTS ' . self::LOCK_TABLE . ' (' + . 'cluster_name ' . $identity . ' NOT NULL PRIMARY KEY)'; } private static function driver(PDO $connection, bool $allowSqliteForTesting): string @@ -84,10 +100,10 @@ private static function driver(PDO $connection, bool $allowSqliteForTesting): st return $driver; } - private static function hardenMysqlIdentityColumns(PDO $connection): void + private static function hardenMysqlEventIdentityColumns(PDO $connection): void { $connection->exec( - 'ALTER TABLE ' . self::TABLE . ' ' + 'ALTER TABLE ' . self::EVENT_TABLE . ' ' . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' @@ -96,16 +112,37 @@ private static function hardenMysqlIdentityColumns(PDO $connection): void ); } - private static function mysqlIdentityColumnsAreBinary(PDO $connection): bool + private static function hardenMysqlLockIdentity(PDO $connection): void + { + $connection->exec( + 'ALTER TABLE ' . self::LOCK_TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + + private static function mysqlEventIdentityColumnsAreBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::EVENT_TABLE) === 5; + } + + private static function mysqlLockIdentityIsBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::LOCK_TABLE) === 1; + } + + private static function mysqlBinaryIdentityCount(PDO $connection, string $table): int { + $columns = $table === self::EVENT_TABLE + ? ['cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id'] + : ['cluster_name']; + $marks = implode(', ', array_fill(0, count($columns), '?')); $statement = $connection->prepare( 'SELECT COUNT(*) FROM information_schema.columns ' . 'WHERE table_schema = DATABASE() AND table_name = ? ' - . "AND column_name IN ('cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id') " - . "AND collation_name = 'ascii_bin'", + . 'AND column_name IN (' . $marks . ") AND collation_name = 'ascii_bin'", ); - $statement->execute([self::TABLE]); + $statement->execute([$table, ...$columns]); - return (int) $statement->fetchColumn() === 5; + return (int) $statement->fetchColumn(); } } From fa131a8278294dba3e290c761bf017bb7e2cd5ed Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:11:28 +0600 Subject: [PATCH 074/434] fix(cluster): serialize PDO event publication by cluster --- .../Pdo/PdoInvalidationTransport.php | 91 ++++++++++++++++--- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php index 277689a2..711aed23 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php @@ -13,11 +13,10 @@ use Infocyph\CacheLayer\Cluster\Transport\TransactionalInvalidationTransportInterface; use PDO; use PDOException; +use Throwable; final readonly class PdoInvalidationTransport implements InvalidationTransportInspectorInterface, TransactionalInvalidationTransportInterface { - private const string TABLE = 'cachelayer_invalidation_events'; - private string $driver; public function __construct( @@ -64,7 +63,8 @@ public function consumeAfter(string $cluster, ?string $cursor, int $limit): Inva public function countAfter(string $cluster, ?string $cursor): int { try { - $sql = 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster'; + $sql = 'SELECT COUNT(*) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster'; if ($cursor !== null) { $sql .= ' AND event_id > :cursor'; } @@ -101,7 +101,8 @@ public function oldestAvailableId(string $cluster): ?string { try { $statement = $this->connection->prepare( - 'SELECT MIN(event_id) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster', + 'SELECT MIN(event_id) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster', ); $statement->execute([':cluster' => $cluster]); $id = $statement->fetchColumn(); @@ -132,7 +133,30 @@ public function pruneBefore(int $retentionBoundary, int $limit = 5_000): int public function publish(InvalidationEvent $event): string { - return $this->insert($this->connection, $event); + $ownsTransaction = !$this->connection->inTransaction(); + + try { + if ($ownsTransaction) { + $this->connection->beginTransaction(); + } + + $id = $this->publishLocked($this->connection, $event); + + if ($ownsTransaction) { + $this->connection->commit(); + } + + return $id; + } catch (Throwable $exception) { + if ($ownsTransaction && $this->connection->inTransaction()) { + $this->connection->rollBack(); + } + if ($exception instanceof ClusterTransportException) { + throw $exception; + } + + throw new ClusterTransportException('Unable to publish an invalidation event.', 0, $exception); + } } public function publishWithinTransaction(PDO $connection, InvalidationEvent $event): string @@ -143,7 +167,7 @@ public function publishWithinTransaction(PDO $connection, InvalidationEvent $eve ); } - return $this->insert($connection, $event); + return $this->publishLocked($connection, $event); } private function consumeSql(?string $cursor): string @@ -154,14 +178,16 @@ private function consumeSql(?string $cursor): string } return 'SELECT event_id, cluster_name, namespace_name, event_type, identifier, origin_node_id, created_at ' - . 'FROM ' . self::TABLE . ' WHERE ' . $where . ' ORDER BY event_id ASC LIMIT :limit'; + . 'FROM ' . PdoInvalidationSchema::EVENT_TABLE . ' WHERE ' . $where + . ' ORDER BY event_id ASC LIMIT :limit'; } private function eventBoundary(string $cluster, string $aggregate): ?string { try { $statement = $this->connection->prepare( - 'SELECT ' . $aggregate . '(event_id) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster', + 'SELECT ' . $aggregate . '(event_id) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster', ); $statement->execute([':cluster' => $cluster]); $id = $statement->fetchColumn(); @@ -233,7 +259,7 @@ private function insert(PDO $connection, InvalidationEvent $event): string { try { $statement = $connection->prepare( - 'INSERT INTO ' . self::TABLE . ' ' + 'INSERT INTO ' . PdoInvalidationSchema::EVENT_TABLE . ' ' . '(cluster_name, namespace_name, event_type, identifier, origin_node_id, created_at) ' . 'VALUES (:cluster, :namespace, :type, :identifier, :origin, :created_at)', ); @@ -257,13 +283,56 @@ private function insert(PDO $connection, InvalidationEvent $event): string return $id; } + private function lockCluster(PDO $connection, string $cluster): void + { + try { + $statement = $connection->prepare($this->lockRowInsertSql()); + $statement->execute([':cluster' => $cluster]); + if ($this->driver === 'sqlite') { + return; + } + + $statement = $connection->prepare( + 'SELECT cluster_name FROM ' . PdoInvalidationSchema::LOCK_TABLE + . ' WHERE cluster_name = :cluster FOR UPDATE', + ); + $statement->execute([':cluster' => $cluster]); + if ($statement->fetchColumn() === false) { + throw new ClusterTransportException('Unable to acquire the invalidation publication lock.'); + } + } catch (PDOException $exception) { + throw new ClusterTransportException('Unable to acquire the invalidation publication lock.', 0, $exception); + } + } + + private function lockRowInsertSql(): string + { + return match ($this->driver) { + 'mysql' => 'INSERT INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster) ' + . 'ON DUPLICATE KEY UPDATE cluster_name = VALUES(cluster_name)', + 'pgsql' => 'INSERT INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster) ON CONFLICT (cluster_name) DO NOTHING', + default => 'INSERT OR IGNORE INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster)', + }; + } + private function pruneSql(): string { - $selection = 'SELECT event_id FROM ' . self::TABLE . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; + $selection = 'SELECT event_id FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; if ($this->driver === 'mysql') { $selection = 'SELECT event_id FROM (' . $selection . ') AS cachelayer_prunable_events'; } - return 'DELETE FROM ' . self::TABLE . ' WHERE event_id IN (' . $selection . ')'; + return 'DELETE FROM ' . PdoInvalidationSchema::EVENT_TABLE . ' WHERE event_id IN (' . $selection . ')'; + } + + private function publishLocked(PDO $connection, InvalidationEvent $event): string + { + $this->lockCluster($connection, $event->cluster); + + return $this->insert($connection, $event); } } From 2b2ac8e04a1168204b397f25555e4f5221b0f5a8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:11:44 +0600 Subject: [PATCH 075/434] ci(cluster): enable real concurrency coverage --- .github/workflows/security-standards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 117de7ba..1e5882cf 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -16,7 +16,7 @@ jobs: actions: read contents: read with: - php_extensions: "apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" + php_extensions: "apcu, mbstring, memcached, mongodb, pcntl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" fail_on_skipped_tests: true integration_services: '["mysql","mariadb","postgres","sqlite","redis","valkey","memcached","scylladb"]' service_topologies: '{}' From 63d8fe94ec7df44c5238c527116945fea3a6a2bb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:13:13 +0600 Subject: [PATCH 076/434] test(cluster): prove commit-safe PDO invalidation ordering --- tests/Cache/PdoSqlIdentityTest.php | 255 +++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) diff --git a/tests/Cache/PdoSqlIdentityTest.php b/tests/Cache/PdoSqlIdentityTest.php index b4abd93a..cfc380de 100644 --- a/tests/Cache/PdoSqlIdentityTest.php +++ b/tests/Cache/PdoSqlIdentityTest.php @@ -64,6 +64,7 @@ ->and($lower->get('key'))->toBe('lower'); $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); $pdo->exec( 'CREATE TABLE cachelayer_invalidation_events (' . 'event_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ' @@ -94,6 +95,260 @@ } finally { $pdo->exec("DROP TABLE IF EXISTS {$cacheTable}"); $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + } + } +}); + + +$orderingBackends = static function (): array { + $servicePassword = getenv('IC_SERVICE_PASSWORD'); + $servicePassword = $servicePassword === false ? '' : $servicePassword; + $serviceUser = getenv('IC_SERVICE_USERNAME') ?: 'phpforge'; + + return [ + 'mysql' => [ + getenv('IC_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=phpforge;charset=utf8mb4', + getenv('IC_MYSQL_USER') ?: $serviceUser, + getenv('IC_MYSQL_PASSWORD') ?: $servicePassword, + ], + 'pgsql' => [ + getenv('IC_POSTGRES_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer', + getenv('IC_POSTGRES_USER') ?: $serviceUser, + getenv('IC_POSTGRES_PASSWORD') ?: $servicePassword, + ], + ]; +}; + +$connectOrderingBackend = static function (array $backend): PDO { + [$dsn, $user, $password] = $backend; + + return new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); +}; + +$resetInvalidationSchema = static function (PDO $pdo): void { + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + PdoInvalidationSchema::install($pdo); +}; + +$waitForSignal = static function (string $path): void { + for ($attempt = 0; $attempt < 200; ++$attempt) { + clearstatcache(true, $path); + if (is_file($path) && filesize($path) > 0) { + return; + } + usleep(10_000); + } + + throw new RuntimeException('Timed out waiting for the concurrent invalidation publisher.'); +}; + +$forkPublisher = static function ( + array $backend, + string $cluster, + string $identifier, + string $startedFile, + string $resultFile, + bool $commit = true, + int $holdMicros = 0, +): int { + $pid = pcntl_fork(); + if ($pid === -1) { + throw new RuntimeException('Unable to fork the invalidation publisher test process.'); + } + if ($pid !== 0) { + return $pid; + } + + try { + [$dsn, $user, $password] = $backend; + $connection = new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $transport = new PdoInvalidationTransport($connection, initializeSchema: false); + $connection->beginTransaction(); + file_put_contents($startedFile, 'started'); + $id = $transport->publishWithinTransaction( + $connection, + InvalidationEvent::key($cluster, 'application', $identifier, 'worker'), + ); + if ($holdMicros > 0) { + usleep($holdMicros); + } + if ($commit) { + $connection->commit(); + file_put_contents($resultFile, $id); + } + exit(0); + } catch (Throwable $failure) { + file_put_contents($resultFile, 'error:' . $failure->getMessage()); + exit(1); + } +}; + +test('PDO invalidation publication serializes ID allocation through transaction completion', function () use ( + $orderingBackends, + $connectOrderingBackend, + $resetInvalidationSchema, + $waitForSignal, + $forkPublisher, +) { + expect(extension_loaded('pcntl'))->toBeTrue(); + + foreach ($orderingBackends() as $backend) { + $admin = $connectOrderingBackend($backend); + $resetInvalidationSchema($admin); + $firstConnection = $connectOrderingBackend($backend); + $firstTransport = new PdoInvalidationTransport($firstConnection, initializeSchema: false); + $firstConnection->beginTransaction(); + $firstId = $firstTransport->publishWithinTransaction( + $firstConnection, + InvalidationEvent::key('ordered-cluster', 'application', 'first', 'worker-a'), + ); + + $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-start-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-result-'); + if ($startedFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation concurrency test files.'); + } + file_put_contents($startedFile, ''); + file_put_contents($resultFile, ''); + + $pid = $forkPublisher( + $backend, + 'ordered-cluster', + 'second', + $startedFile, + $resultFile, + ); + + try { + $waitForSignal($startedFile); + usleep(150_000); + expect(file_get_contents($resultFile))->toBe(''); + + $firstConnection->commit(); + pcntl_waitpid($pid, $status); + $secondId = trim((string) file_get_contents($resultFile)); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('ordered-cluster', null, 10) + ->events; + + expect(pcntl_wexitstatus($status))->toBe(0) + ->and($secondId)->not->toStartWith('error:') + ->and(array_map(static fn($event): string => (string) $event->id, $events)) + ->toBe([$firstId, $secondId]) + ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['first', 'second']); + } finally { + if ($firstConnection->inTransaction()) { + $firstConnection->rollBack(); + } + @unlink($startedFile); + @unlink($resultFile); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + } + } +}); + +test('PDO invalidation publication survives rollback and publisher process death', function () use ( + $orderingBackends, + $connectOrderingBackend, + $resetInvalidationSchema, + $waitForSignal, + $forkPublisher, +) { + expect(extension_loaded('pcntl'))->toBeTrue(); + + foreach ($orderingBackends() as $backend) { + $admin = $connectOrderingBackend($backend); + $resetInvalidationSchema($admin); + + $abortedConnection = $connectOrderingBackend($backend); + $abortedTransport = new PdoInvalidationTransport($abortedConnection, initializeSchema: false); + $abortedConnection->beginTransaction(); + $abortedTransport->publishWithinTransaction( + $abortedConnection, + InvalidationEvent::key('rollback-cluster', 'application', 'aborted', 'worker-a'), + ); + + $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-start-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-result-'); + if ($startedFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation rollback test files.'); + } + file_put_contents($startedFile, ''); + file_put_contents($resultFile, ''); + + $pid = $forkPublisher( + $backend, + 'rollback-cluster', + 'committed', + $startedFile, + $resultFile, + ); + + try { + $waitForSignal($startedFile); + usleep(150_000); + expect(file_get_contents($resultFile))->toBe(''); + + $abortedConnection->rollBack(); + pcntl_waitpid($pid, $status); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('rollback-cluster', null, 10) + ->events; + + expect(pcntl_wexitstatus($status))->toBe(0) + ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['committed']); + } finally { + if ($abortedConnection->inTransaction()) { + $abortedConnection->rollBack(); + } + @unlink($startedFile); + @unlink($resultFile); + } + + $resetInvalidationSchema($admin); + $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-start-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-result-'); + if ($startedFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation process-death test files.'); + } + file_put_contents($startedFile, ''); + file_put_contents($resultFile, ''); + + $pid = $forkPublisher( + $backend, + 'death-cluster', + 'aborted', + $startedFile, + $resultFile, + commit: false, + holdMicros: 150_000, + ); + + try { + $waitForSignal($startedFile); + $survivor = new PdoInvalidationTransport($connectOrderingBackend($backend), initializeSchema: false); + $survivorId = $survivor->publish( + InvalidationEvent::key('death-cluster', 'application', 'survivor', 'worker-b'), + ); + pcntl_waitpid($pid, $status); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('death-cluster', null, 10) + ->events; + + expect(pcntl_wexitstatus($status))->toBe(0) + ->and($survivorId)->not->toBe('') + ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['survivor']); + } finally { + @unlink($startedFile); + @unlink($resultFile); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); } } }); From 5cd1957c54b355de17a6ce540c3571b976e1412f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:13:54 +0600 Subject: [PATCH 077/434] docs(plan): update active Batch 3 tracker --- .../cachelayer-4.0-security-correctness-plan.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index f680926a..e32e0b3c 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -15,7 +15,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | -| 3 — Durable invalidation protocol | R06, R07 | **In progress** | R07 namespace-scoped cursor storage and safe cursor reset/recovery are implemented with regression coverage; R06 commit-order-safe PDO publication is next. | +| 3 — Durable invalidation protocol | R06, R07 | **In progress** | R06 per-cluster commit-order serialization and R07 namespace-scoped cursor ownership are implemented with regression coverage; full Batch 3 QA is pending. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | @@ -44,7 +44,18 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi **Batch 2 implementation/closure commits:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, `f2c3e55db67ecc3237dc87494c2828c66f33d5ee`, and `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (adapter/atomic identity verification and codec hardening), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SQL/shared-memory identity), `64fc9ba5fcdfceb12e92efd2912192486e0a1cd6` through `1924a74da3b9d6474696631405e839bd52ec158b` (final schema idempotence, unified Node policy, L1 coherence fencing, regressions/docs, and exact PHPForge formatting). -**Batch 2 closure evidence:** exact commit `1924a74da3b9d6474696631405e839bd52ec158b` passed Security & Standards run #210: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed.\n\n## Decision +**Batch 2 closure evidence:** exact commit `1924a74da3b9d6474696631405e839bd52ec158b` passed Security & Standards run #210: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed. + +### Batch 3 tracker + +| Finding | Implementation | Regression evidence | QA state | +| --- | --- | --- | --- | +| R06 — durable PDO invalidation ordering | **Implemented; QA pending** | PDO publication now acquires a per-cluster transactional lock before event-ID allocation. MySQL/PostgreSQL concurrency coverage forces a second publisher to attempt completion before the first transaction commits, plus rollback and publisher-process-death cases. | Full Batch 3 QA pending. | +| R07 — namespace-scoped cursor ownership | **Implemented; QA pending** | SQLite cursor storage is versioned and keyed by cluster + node + namespace. A same-node/same-store multi-namespace regression proves one namespace cannot advance another namespace's replay cursor; null/new scoped cursors clear local state before replay. | Full Batch 3 QA pending. | + +**Batch 3 implementation commits so far:** `965d90ea9fbc6e39f77988e3aef4900a792b30c9` through `6a658362f6dc777f1f0f50a196289d7ef1ef26c0` (R07 cursor scope/recovery/tests/tracker start), `4b885a133b8ec08dd0ed855e502a708f705e27fc`, `fa131a8278294dba3e290c761bf017bb7e2cd5ed`, `2b2ac8e04a1168204b397f25555e4f5221b0f5a8`, and `63d8fe94ec7df44c5238c527116945fea3a6a2bb` (R06 locking protocol, schema, real-concurrency CI support, and MySQL/PostgreSQL regressions). + +## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 1e79415461ae014ca04c97329bd2c60042b623c3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:16:55 +0600 Subject: [PATCH 078/434] fix(analysis): type cursor scope parameters accurately --- src/Cluster/Cursor/SqliteCursorStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index f5653385..4e02e69b 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -99,7 +99,7 @@ private function read(string $sql, string $failureMessage): mixed } } - /** @return array{cluster:string, node_id:string, namespace:string} */ + /** @return array */ private function scopeParameters(): array { return [ From ffcaec51f83b9a9d2a9dfbffbdb41e57f9ad019a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:17:08 +0600 Subject: [PATCH 079/434] fix(analysis): track publication transaction ownership --- src/Cluster/Transport/Pdo/PdoInvalidationTransport.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php index 711aed23..3e4c2f8a 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php @@ -134,21 +134,24 @@ public function pruneBefore(int $retentionBoundary, int $limit = 5_000): int public function publish(InvalidationEvent $event): string { $ownsTransaction = !$this->connection->inTransaction(); + $transactionStarted = false; try { if ($ownsTransaction) { $this->connection->beginTransaction(); + $transactionStarted = true; } $id = $this->publishLocked($this->connection, $event); if ($ownsTransaction) { $this->connection->commit(); + $transactionStarted = false; } return $id; } catch (Throwable $exception) { - if ($ownsTransaction && $this->connection->inTransaction()) { + if ($transactionStarted) { $this->connection->rollBack(); } if ($exception instanceof ClusterTransportException) { From 2d915a90e22937e8dc68a48826ae40599e9f66f3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:20:43 +0600 Subject: [PATCH 080/434] refactor(cluster): expose cursor recovery state --- src/Cluster/Cursor/CursorStoreInterface.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cluster/Cursor/CursorStoreInterface.php b/src/Cluster/Cursor/CursorStoreInterface.php index 34056ca2..9a15a9f5 100644 --- a/src/Cluster/Cursor/CursorStoreInterface.php +++ b/src/Cluster/Cursor/CursorStoreInterface.php @@ -12,5 +12,7 @@ public function current(): ?string; public function reset(?string $eventId): void; + public function requiresRecovery(): bool; + public function updatedAt(): ?int; } From 831229c22a9f24f59ac2814e7a0476c55fa615f6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:21:00 +0600 Subject: [PATCH 081/434] fix(cluster): distinguish migrated and fresh cursor scopes --- src/Cluster/Cursor/SqliteCursorStore.php | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index 4e02e69b..ec542853 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -13,6 +13,8 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { + private const string LEGACY_TABLE = 'cachelayer_cluster_cursors'; + private const string TABLE = 'cachelayer_cluster_cursors_v2'; private PDO $connection; @@ -59,6 +61,11 @@ public function reset(?string $eventId): void $this->write($eventId); } + public function requiresRecovery(): bool + { + return $this->legacyTableExists() && !$this->scopeExists(); + } + public function updatedAt(): ?int { $updatedAt = $this->read( @@ -87,6 +94,20 @@ private function createSchemaIfMissing(): void } } + private function legacyTableExists(): bool + { + try { + $statement = $this->connection->prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1", + ); + $statement->execute([':table' => self::LEGACY_TABLE]); + + return $statement->fetchColumn() !== false; + } catch (PDOException $exception) { + throw new ClusterCacheException('Unable to inspect the legacy cluster cursor store.', 0, $exception); + } + } + private function read(string $sql, string $failureMessage): mixed { try { @@ -99,6 +120,22 @@ private function read(string $sql, string $failureMessage): mixed } } + private function scopeExists(): bool + { + try { + $statement = $this->connection->prepare( + 'SELECT 1 FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace LIMIT 1', + ); + $statement->execute($this->scopeParameters()); + + return $statement->fetchColumn() !== false; + } catch (PDOException $exception) { + throw new ClusterCacheException('Unable to inspect the scoped cluster cursor.', 0, $exception); + } + } + /** @return array */ private function scopeParameters(): array { From c5bc44c50b507ed8fd3d3d28be2ce6915e9b8352 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:21:12 +0600 Subject: [PATCH 082/434] fix(cluster): recover only migrated cursor scopes --- src/Cluster/Recovery/ClusterRecoveryManager.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Cluster/Recovery/ClusterRecoveryManager.php b/src/Cluster/Recovery/ClusterRecoveryManager.php index 56b8e755..72d6940d 100644 --- a/src/Cluster/Recovery/ClusterRecoveryManager.php +++ b/src/Cluster/Recovery/ClusterRecoveryManager.php @@ -20,18 +20,16 @@ public function __construct( public function recoverIfRequired(): bool { - $cursor = $this->cursorStore->current(); - $oldest = $this->transport->oldestAvailableId($this->cluster); - if ($oldest === null) { - return false; - } - - if ($cursor === null) { + if ($this->cursorStore->requiresRecovery()) { $this->clearLocalCache(); + $this->cursorStore->reset(null); return true; } - if (!$this->transport->isCursorBefore($cursor, $oldest)) { + + $cursor = $this->cursorStore->current(); + $oldest = $this->transport->oldestAvailableId($this->cluster); + if ($cursor === null || $oldest === null || !$this->transport->isCursorBefore($cursor, $oldest)) { return false; } From 13e6ec0e13395b3f6e4fdedf6e04a05480d75474 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:21:59 +0600 Subject: [PATCH 083/434] test(cluster): isolate concurrent PDO publishers --- .../PdoInvalidationPublisherProcess.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/Cache/Support/PdoInvalidationPublisherProcess.php diff --git a/tests/Cache/Support/PdoInvalidationPublisherProcess.php b/tests/Cache/Support/PdoInvalidationPublisherProcess.php new file mode 100644 index 00000000..2cbf130b --- /dev/null +++ b/tests/Cache/Support/PdoInvalidationPublisherProcess.php @@ -0,0 +1,45 @@ + PDO::ERRMODE_EXCEPTION]); +$transport = new PdoInvalidationTransport($connection, initializeSchema: false); +$connection->beginTransaction(); +file_put_contents($stateFile, 'started'); +$id = $transport->publishWithinTransaction( + $connection, + InvalidationEvent::key($cluster, 'application', $identifier, $origin), +); +file_put_contents($stateFile, 'acquired'); + +$holdMicros = (int) $holdMicros; +if ($holdMicros > 0) { + usleep($holdMicros); +} +if ($commit === '1') { + $connection->commit(); + file_put_contents($resultFile, $id); +} From 9b95a3ec8ecea5dd5da4e87f9ce1a9b7a3b23d6a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:22:51 +0600 Subject: [PATCH 084/434] test(cluster): run concurrent publishers in isolated processes --- tests/Cache/PdoSqlIdentityTest.php | 174 +++++++++++++++++------------ 1 file changed, 100 insertions(+), 74 deletions(-) diff --git a/tests/Cache/PdoSqlIdentityTest.php b/tests/Cache/PdoSqlIdentityTest.php index cfc380de..8653a2c5 100644 --- a/tests/Cache/PdoSqlIdentityTest.php +++ b/tests/Cache/PdoSqlIdentityTest.php @@ -101,6 +101,7 @@ }); + $orderingBackends = static function (): array { $servicePassword = getenv('IC_SERVICE_PASSWORD'); $servicePassword = $servicePassword === false ? '' : $servicePassword; @@ -132,10 +133,10 @@ PdoInvalidationSchema::install($pdo); }; -$waitForSignal = static function (string $path): void { +$waitForState = static function (string $path, string $expected): void { for ($attempt = 0; $attempt < 200; ++$attempt) { clearstatcache(true, $path); - if (is_file($path) && filesize($path) > 0) { + if (is_file($path) && trim((string) file_get_contents($path)) === $expected) { return; } usleep(10_000); @@ -144,56 +145,76 @@ throw new RuntimeException('Timed out waiting for the concurrent invalidation publisher.'); }; -$forkPublisher = static function ( +$removeTestFile = static function (string $path): void { + if (is_file($path) && !unlink($path)) { + throw new RuntimeException('Unable to remove an invalidation concurrency test file.'); + } +}; + +$startPublisher = static function ( array $backend, string $cluster, string $identifier, - string $startedFile, + string $stateFile, string $resultFile, bool $commit = true, int $holdMicros = 0, -): int { - $pid = pcntl_fork(); - if ($pid === -1) { - throw new RuntimeException('Unable to fork the invalidation publisher test process.'); +): mixed { + [$dsn, $user, $password] = $backend; + $command = [ + PHP_BINARY, + __DIR__ . '/Support/PdoInvalidationPublisherProcess.php', + $dsn, + $user, + $password, + $cluster, + $identifier, + $stateFile, + $resultFile, + $commit ? '1' : '0', + (string) $holdMicros, + 'worker-child', + ]; + $process = proc_open( + $command, + [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'a'], + 2 => ['file', $resultFile . '.stderr', 'a'], + ], + $pipes, + dirname(__DIR__, 2), + ); + if (!is_resource($process)) { + throw new RuntimeException('Unable to start the invalidation publisher process.'); } - if ($pid !== 0) { - return $pid; + + return $process; +}; + +$finishPublisher = static function (mixed $process, string $stderrFile): int { + if (!is_resource($process)) { + throw new RuntimeException('Invalid concurrent invalidation publisher process.'); } - try { - [$dsn, $user, $password] = $backend; - $connection = new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); - $transport = new PdoInvalidationTransport($connection, initializeSchema: false); - $connection->beginTransaction(); - file_put_contents($startedFile, 'started'); - $id = $transport->publishWithinTransaction( - $connection, - InvalidationEvent::key($cluster, 'application', $identifier, 'worker'), - ); - if ($holdMicros > 0) { - usleep($holdMicros); - } - if ($commit) { - $connection->commit(); - file_put_contents($resultFile, $id); - } - exit(0); - } catch (Throwable $failure) { - file_put_contents($resultFile, 'error:' . $failure->getMessage()); - exit(1); + $exitCode = proc_close($process); + if ($exitCode !== 0) { + $stderr = is_file($stderrFile) ? trim((string) file_get_contents($stderrFile)) : ''; + throw new RuntimeException('Concurrent invalidation publisher failed: ' . $stderr); } + + return $exitCode; }; test('PDO invalidation publication serializes ID allocation through transaction completion', function () use ( $orderingBackends, $connectOrderingBackend, $resetInvalidationSchema, - $waitForSignal, - $forkPublisher, + $waitForState, + $removeTestFile, + $startPublisher, + $finishPublisher, ) { - expect(extension_loaded('pcntl'))->toBeTrue(); - foreach ($orderingBackends() as $backend) { $admin = $connectOrderingBackend($backend); $resetInvalidationSchema($admin); @@ -205,36 +226,37 @@ InvalidationEvent::key('ordered-cluster', 'application', 'first', 'worker-a'), ); - $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-start-'); + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-state-'); $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-result-'); - if ($startedFile === false || $resultFile === false) { + if ($stateFile === false || $resultFile === false) { throw new RuntimeException('Unable to allocate invalidation concurrency test files.'); } - file_put_contents($startedFile, ''); + file_put_contents($stateFile, ''); file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; - $pid = $forkPublisher( + $process = $startPublisher( $backend, 'ordered-cluster', 'second', - $startedFile, + $stateFile, $resultFile, ); try { - $waitForSignal($startedFile); + $waitForState($stateFile, 'started'); usleep(150_000); - expect(file_get_contents($resultFile))->toBe(''); + expect(file_get_contents($resultFile))->toBe('') + ->and(trim((string) file_get_contents($stateFile)))->toBe('started'); $firstConnection->commit(); - pcntl_waitpid($pid, $status); + expect($finishPublisher($process, $stderrFile))->toBe(0); $secondId = trim((string) file_get_contents($resultFile)); $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) ->consumeAfter('ordered-cluster', null, 10) ->events; - expect(pcntl_wexitstatus($status))->toBe(0) - ->and($secondId)->not->toStartWith('error:') + expect($secondId)->not->toStartWith('error:') ->and(array_map(static fn($event): string => (string) $event->id, $events)) ->toBe([$firstId, $secondId]) ->and(array_map(static fn($event): ?string => $event->identifier, $events)) @@ -243,8 +265,9 @@ if ($firstConnection->inTransaction()) { $firstConnection->rollBack(); } - @unlink($startedFile); - @unlink($resultFile); + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); } @@ -255,11 +278,11 @@ $orderingBackends, $connectOrderingBackend, $resetInvalidationSchema, - $waitForSignal, - $forkPublisher, + $waitForState, + $removeTestFile, + $startPublisher, + $finishPublisher, ) { - expect(extension_loaded('pcntl'))->toBeTrue(); - foreach ($orderingBackends() as $backend) { $admin = $connectOrderingBackend($backend); $resetInvalidationSchema($admin); @@ -272,81 +295,84 @@ InvalidationEvent::key('rollback-cluster', 'application', 'aborted', 'worker-a'), ); - $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-start-'); + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-state-'); $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-result-'); - if ($startedFile === false || $resultFile === false) { + if ($stateFile === false || $resultFile === false) { throw new RuntimeException('Unable to allocate invalidation rollback test files.'); } - file_put_contents($startedFile, ''); + file_put_contents($stateFile, ''); file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; - $pid = $forkPublisher( + $process = $startPublisher( $backend, 'rollback-cluster', 'committed', - $startedFile, + $stateFile, $resultFile, ); try { - $waitForSignal($startedFile); + $waitForState($stateFile, 'started'); usleep(150_000); - expect(file_get_contents($resultFile))->toBe(''); + expect(file_get_contents($resultFile))->toBe('') + ->and(trim((string) file_get_contents($stateFile)))->toBe('started'); $abortedConnection->rollBack(); - pcntl_waitpid($pid, $status); + expect($finishPublisher($process, $stderrFile))->toBe(0); $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) ->consumeAfter('rollback-cluster', null, 10) ->events; - expect(pcntl_wexitstatus($status))->toBe(0) - ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + expect(array_map(static fn($event): ?string => $event->identifier, $events)) ->toBe(['committed']); } finally { if ($abortedConnection->inTransaction()) { $abortedConnection->rollBack(); } - @unlink($startedFile); - @unlink($resultFile); + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); } $resetInvalidationSchema($admin); - $startedFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-start-'); + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-state-'); $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-result-'); - if ($startedFile === false || $resultFile === false) { + if ($stateFile === false || $resultFile === false) { throw new RuntimeException('Unable to allocate invalidation process-death test files.'); } - file_put_contents($startedFile, ''); + file_put_contents($stateFile, ''); file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; - $pid = $forkPublisher( + $process = $startPublisher( $backend, 'death-cluster', 'aborted', - $startedFile, + $stateFile, $resultFile, commit: false, holdMicros: 150_000, ); try { - $waitForSignal($startedFile); + $waitForState($stateFile, 'acquired'); $survivor = new PdoInvalidationTransport($connectOrderingBackend($backend), initializeSchema: false); $survivorId = $survivor->publish( InvalidationEvent::key('death-cluster', 'application', 'survivor', 'worker-b'), ); - pcntl_waitpid($pid, $status); + expect($finishPublisher($process, $stderrFile))->toBe(0); $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) ->consumeAfter('death-cluster', null, 10) ->events; - expect(pcntl_wexitstatus($status))->toBe(0) - ->and($survivorId)->not->toBe('') + expect($survivorId)->not->toBe('') ->and(array_map(static fn($event): ?string => $event->identifier, $events)) ->toBe(['survivor']); } finally { - @unlink($startedFile); - @unlink($resultFile); + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); } From 4b6066dc530369b8b3dcc77088919091de6173d6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:23:20 +0600 Subject: [PATCH 085/434] style(cluster): order invalidation schema helpers --- .../Transport/Pdo/PdoInvalidationSchema.php | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index a0fbef7a..5da0d082 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -120,16 +120,6 @@ private static function hardenMysqlLockIdentity(PDO $connection): void ); } - private static function mysqlEventIdentityColumnsAreBinary(PDO $connection): bool - { - return self::mysqlBinaryIdentityCount($connection, self::EVENT_TABLE) === 5; - } - - private static function mysqlLockIdentityIsBinary(PDO $connection): bool - { - return self::mysqlBinaryIdentityCount($connection, self::LOCK_TABLE) === 1; - } - private static function mysqlBinaryIdentityCount(PDO $connection, string $table): int { $columns = $table === self::EVENT_TABLE @@ -145,4 +135,14 @@ private static function mysqlBinaryIdentityCount(PDO $connection, string $table) return (int) $statement->fetchColumn(); } + + private static function mysqlEventIdentityColumnsAreBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::EVENT_TABLE) === 5; + } + + private static function mysqlLockIdentityIsBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::LOCK_TABLE) === 1; + } } From 5c3410c4755f1ce224cb87f0c421b729cfc5aaf7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:43:12 +0600 Subject: [PATCH 086/434] feat(cluster): validate transport cursor identity --- src/Cluster/ClusterInput.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Cluster/ClusterInput.php b/src/Cluster/ClusterInput.php index 2ca3ba1c..890faed6 100644 --- a/src/Cluster/ClusterInput.php +++ b/src/Cluster/ClusterInput.php @@ -71,6 +71,13 @@ public static function nodeId(string $nodeId): string return $nodeId; } + public static function transportIdentity(string $transportIdentity): string + { + self::boundedName($transportIdentity, 128, 'transport identity', ClusterConfigurationException::class); + + return $transportIdentity; + } + /** @param class-string $exceptionClass */ private static function boundedName(string $value, int $maximum, string $label, string $exceptionClass): void { From e28335cd8776addeb42aae669a4901fc3f5826cf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:43:31 +0600 Subject: [PATCH 087/434] feat(cluster): require transport identity for cursor scope --- src/Cluster/ClusterCacheConfig.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cluster/ClusterCacheConfig.php b/src/Cluster/ClusterCacheConfig.php index 22b6b9c0..d15eab4e 100644 --- a/src/Cluster/ClusterCacheConfig.php +++ b/src/Cluster/ClusterCacheConfig.php @@ -11,11 +11,13 @@ public function __construct( public string $cluster, public string $nodeId, + public string $transportIdentity, public int $consumerBatchSize = 1_000, public bool $invalidateLocallyFirst = true, ) { ClusterInput::cluster($cluster); ClusterInput::nodeId($nodeId); + ClusterInput::transportIdentity($transportIdentity); if ($consumerBatchSize < 1) { throw new ClusterConfigurationException('The consumer batch size must be greater than zero.'); From 15c379d7985f329d31903a2268de51be16b79741 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:44:33 +0600 Subject: [PATCH 088/434] fix(cluster): scope cursors by namespace and transport --- src/Cluster/Cursor/SqliteCursorStore.php | 135 +++++++++++++++-------- 1 file changed, 87 insertions(+), 48 deletions(-) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index ec542853..79d1193c 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cluster\Cursor; -use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cluster\ClusterInput; use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; use Infocyph\CacheLayer\Node\NodeCacheConfig; @@ -13,22 +13,30 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { - private const string LEGACY_TABLE = 'cachelayer_cluster_cursors'; - - private const string TABLE = 'cachelayer_cluster_cursors_v2'; + private const string TABLE = 'cachelayer_cluster_cursors'; private PDO $connection; + private string $cluster; + private string $namespace; + private string $nodeId; + + private string $transportIdentity; + public function __construct( string $sqliteFile, - private string $cluster, - private string $nodeId, + string $cluster, + string $nodeId, string $namespace, + string $transportIdentity, ?PDO $connection = null, ) { - $this->namespace = CacheInput::namespace($namespace); + $this->cluster = ClusterInput::cluster($cluster); + $this->nodeId = ClusterInput::nodeId($nodeId); + $this->namespace = ClusterInput::namespace($namespace); + $this->transportIdentity = ClusterInput::transportIdentity($transportIdentity); $this->connection = $connection ?? NodeSqliteConnection::create( new NodeCacheConfig($sqliteFile, 'cluster-cursor'), ); @@ -49,7 +57,7 @@ public function current(): ?string $cursor = $this->read( 'SELECT last_event_id FROM ' . self::TABLE . ' ' . 'WHERE cluster_name = :cluster AND node_id = :node_id ' - . 'AND namespace_name = :namespace LIMIT 1', + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', 'Unable to read the cluster cursor.', ); @@ -61,17 +69,12 @@ public function reset(?string $eventId): void $this->write($eventId); } - public function requiresRecovery(): bool - { - return $this->legacyTableExists() && !$this->scopeExists(); - } - public function updatedAt(): ?int { $updatedAt = $this->read( 'SELECT updated_at FROM ' . self::TABLE . ' ' . 'WHERE cluster_name = :cluster AND node_id = :node_id ' - . 'AND namespace_name = :namespace LIMIT 1', + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', 'Unable to read the cluster cursor update time.', ); @@ -83,56 +86,90 @@ public function updatedAt(): ?int private function createSchemaIfMissing(): void { try { - $this->connection->exec( - 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' - . 'last_event_id TEXT, updated_at INTEGER NOT NULL, ' - . 'PRIMARY KEY (cluster_name, node_id, namespace_name)) WITHOUT ROWID', - ); + $columns = $this->cursorColumns(); + if ($columns === []) { + $this->createScopedSchema(); + + return; + } + + if (in_array('namespace_name', $columns, true) + && in_array('transport_identity', $columns, true)) { + return; + } + + $this->migrateLegacySchema(); } catch (PDOException $exception) { throw new ClusterCacheException('Unable to initialize the cluster cursor store.', 0, $exception); } } - private function legacyTableExists(): bool + /** @return list */ + private function cursorColumns(): array { - try { - $statement = $this->connection->prepare( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1", - ); - $statement->execute([':table' => self::LEGACY_TABLE]); - - return $statement->fetchColumn() !== false; - } catch (PDOException $exception) { - throw new ClusterCacheException('Unable to inspect the legacy cluster cursor store.', 0, $exception); + $statement = $this->connection->query('PRAGMA table_info(' . self::TABLE . ')'); + $columns = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (is_array($row) && is_string($row['name'] ?? null)) { + $columns[] = $row['name']; + } } + + return $columns; } - private function read(string $sql, string $failureMessage): mixed + private function createScopedSchema(): void { - try { - $statement = $this->connection->prepare($sql); - $statement->execute($this->scopeParameters()); + $this->connection->exec( + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'transport_identity TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name, transport_identity)) WITHOUT ROWID', + ); + } - return $statement->fetchColumn(); + private function migrateLegacySchema(): void + { + $this->connection->exec('BEGIN IMMEDIATE'); + + try { + $columns = $this->cursorColumns(); + if (!in_array('namespace_name', $columns, true) + || !in_array('transport_identity', $columns, true)) { + $this->connection->exec('DROP TABLE ' . self::TABLE); + $this->createScopedSchema(); + if ($this->nodeEntriesTableExists()) { + $this->connection->exec('DELETE FROM cachelayer_node_entries'); + } + } + + $this->connection->exec('COMMIT'); } catch (PDOException $exception) { - throw new ClusterCacheException($failureMessage, 0, $exception); + $this->connection->exec('ROLLBACK'); + + throw $exception; } } - private function scopeExists(): bool + private function nodeEntriesTableExists(): bool + { + $statement = $this->connection->prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cachelayer_node_entries' LIMIT 1", + ); + $statement->execute(); + + return $statement->fetchColumn() !== false; + } + + private function read(string $sql, string $failureMessage): mixed { try { - $statement = $this->connection->prepare( - 'SELECT 1 FROM ' . self::TABLE . ' ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id ' - . 'AND namespace_name = :namespace LIMIT 1', - ); + $statement = $this->connection->prepare($sql); $statement->execute($this->scopeParameters()); - return $statement->fetchColumn() !== false; + return $statement->fetchColumn(); } catch (PDOException $exception) { - throw new ClusterCacheException('Unable to inspect the scoped cluster cursor.', 0, $exception); + throw new ClusterCacheException($failureMessage, 0, $exception); } } @@ -143,6 +180,7 @@ private function scopeParameters(): array ':cluster' => $this->cluster, ':node_id' => $this->nodeId, ':namespace' => $this->namespace, + ':transport_identity' => $this->transportIdentity, ]; } @@ -151,12 +189,13 @@ private function write(?string $eventId): void try { $statement = $this->connection->prepare( 'INSERT INTO ' . self::TABLE . ' ' - . '(cluster_name, node_id, namespace_name, last_event_id, updated_at) ' - . 'VALUES (:cluster, :node_id, :namespace, :event_id, :updated_at) ' - . 'ON CONFLICT(cluster_name, node_id, namespace_name) DO UPDATE SET ' + . '(cluster_name, node_id, namespace_name, transport_identity, last_event_id, updated_at) ' + . 'VALUES (:cluster, :node_id, :namespace, :transport_identity, :event_id, :updated_at) ' + . 'ON CONFLICT(cluster_name, node_id, namespace_name, transport_identity) DO UPDATE SET ' . 'last_event_id = excluded.last_event_id, updated_at = excluded.updated_at', ); - $statement->execute($this->scopeParameters() + [ + $statement->execute([ + ...$this->scopeParameters(), ':event_id' => $eventId, ':updated_at' => time(), ]); From d9d3c29cf0068be32e0e20af3cef5c9d2e129ca4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:44:45 +0600 Subject: [PATCH 089/434] fix(cluster): bind runtime cursor to full scope From d40a0d27db9eaf514ee12d2747a170252a7f179e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:47:03 +0600 Subject: [PATCH 090/434] fix(cluster): reconcile scoped cursor recovery --- src/Cluster/Cursor/SqliteCursorStore.php | 141 +++++++++++++---------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index 79d1193c..7b4e59eb 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -13,7 +13,11 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { - private const string TABLE = 'cachelayer_cluster_cursors'; + private const string LEGACY_TABLE = 'cachelayer_cluster_cursors'; + + private const string PREVIOUS_TABLE = 'cachelayer_cluster_cursors_v2'; + + private const string TABLE = 'cachelayer_cluster_cursors_v3'; private PDO $connection; @@ -69,6 +73,15 @@ public function reset(?string $eventId): void $this->write($eventId); } + public function requiresRecovery(): bool + { + if ($this->scopeExists()) { + return false; + } + + return $this->legacyScopeExists() || $this->previousScopeExists(); + } + public function updatedAt(): ?int { $updatedAt = $this->read( @@ -86,79 +99,51 @@ public function updatedAt(): ?int private function createSchemaIfMissing(): void { try { - $columns = $this->cursorColumns(); - if ($columns === []) { - $this->createScopedSchema(); - - return; - } - - if (in_array('namespace_name', $columns, true) - && in_array('transport_identity', $columns, true)) { - return; - } - - $this->migrateLegacySchema(); + $this->connection->exec( + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'transport_identity TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name, transport_identity)) WITHOUT ROWID', + ); } catch (PDOException $exception) { throw new ClusterCacheException('Unable to initialize the cluster cursor store.', 0, $exception); } } - /** @return list */ - private function cursorColumns(): array + private function legacyScopeExists(): bool { - $statement = $this->connection->query('PRAGMA table_info(' . self::TABLE . ')'); - $columns = []; - foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { - if (is_array($row) && is_string($row['name'] ?? null)) { - $columns[] = $row['name']; - } + if (!$this->tableExists(self::LEGACY_TABLE)) { + return false; } - return $columns; - } - - private function createScopedSchema(): void - { - $this->connection->exec( - 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' - . 'transport_identity TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' - . 'PRIMARY KEY (cluster_name, node_id, namespace_name, transport_identity)) WITHOUT ROWID', + return $this->rowExists( + 'SELECT 1 FROM ' . self::LEGACY_TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', + [ + ':cluster' => $this->cluster, + ':node_id' => $this->nodeId, + ], + 'Unable to inspect the legacy cluster cursor scope.', ); } - private function migrateLegacySchema(): void + private function previousScopeExists(): bool { - $this->connection->exec('BEGIN IMMEDIATE'); - - try { - $columns = $this->cursorColumns(); - if (!in_array('namespace_name', $columns, true) - || !in_array('transport_identity', $columns, true)) { - $this->connection->exec('DROP TABLE ' . self::TABLE); - $this->createScopedSchema(); - if ($this->nodeEntriesTableExists()) { - $this->connection->exec('DELETE FROM cachelayer_node_entries'); - } - } - - $this->connection->exec('COMMIT'); - } catch (PDOException $exception) { - $this->connection->exec('ROLLBACK'); - - throw $exception; + if (!$this->tableExists(self::PREVIOUS_TABLE)) { + return false; } - } - private function nodeEntriesTableExists(): bool - { - $statement = $this->connection->prepare( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cachelayer_node_entries' LIMIT 1", + return $this->rowExists( + 'SELECT 1 FROM ' . self::PREVIOUS_TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace LIMIT 1', + [ + ':cluster' => $this->cluster, + ':node_id' => $this->nodeId, + ':namespace' => $this->namespace, + ], + 'Unable to inspect the previous cluster cursor scope.', ); - $statement->execute(); - - return $statement->fetchColumn() !== false; } private function read(string $sql, string $failureMessage): mixed @@ -173,6 +158,21 @@ private function read(string $sql, string $failureMessage): mixed } } + /** + * @param array $parameters + */ + private function rowExists(string $sql, array $parameters, string $failureMessage): bool + { + try { + $statement = $this->connection->prepare($sql); + $statement->execute($parameters); + + return $statement->fetchColumn() !== false; + } catch (PDOException $exception) { + throw new ClusterCacheException($failureMessage, 0, $exception); + } + } + /** @return array */ private function scopeParameters(): array { @@ -184,6 +184,26 @@ private function scopeParameters(): array ]; } + private function scopeExists(): bool + { + return $this->rowExists( + 'SELECT 1 FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', + $this->scopeParameters(), + 'Unable to inspect the scoped cluster cursor.', + ); + } + + private function tableExists(string $table): bool + { + return $this->rowExists( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1", + [':table' => $table], + 'Unable to inspect the cluster cursor schema.', + ); + } + private function write(?string $eventId): void { try { @@ -194,8 +214,7 @@ private function write(?string $eventId): void . 'ON CONFLICT(cluster_name, node_id, namespace_name, transport_identity) DO UPDATE SET ' . 'last_event_id = excluded.last_event_id, updated_at = excluded.updated_at', ); - $statement->execute([ - ...$this->scopeParameters(), + $statement->execute($this->scopeParameters() + [ ':event_id' => $eventId, ':updated_at' => time(), ]); From ff90d2fdf7b7893ec18738567f4314d87bce76f2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:49:11 +0600 Subject: [PATCH 091/434] test(cluster): cover full cursor scope and migration --- tests/Cluster/ClusterCacheTest.php | 107 +++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 3feb7a97..0767fe66 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -24,8 +24,8 @@ beforeEach(function () { $this->clusterDirectory = sys_get_temp_dir() . '/cachelayer-cluster-' . uniqid(); $this->transport = new InMemoryInvalidationTransport(); - $this->clusterConfigA = new ClusterCacheConfig('test-cluster', 'node-a'); - $this->clusterConfigB = new ClusterCacheConfig('test-cluster', 'node-b'); + $this->clusterConfigA = new ClusterCacheConfig('test-cluster', 'node-a', 'memory-primary'); + $this->clusterConfigB = new ClusterCacheConfig('test-cluster', 'node-b', 'memory-primary'); $this->nodeConfigA = new NodeCacheConfig( $this->clusterDirectory . '/node-a.sqlite', 'application', @@ -117,7 +117,7 @@ test('cursor progress is isolated by namespace on the same node and SQLite store', function () { $transport = new InMemoryInvalidationTransport(); $sqliteFile = $this->clusterDirectory . '/shared-node.sqlite'; - $cluster = new ClusterCacheConfig('scope-cluster', 'shared-node'); + $cluster = new ClusterCacheConfig('scope-cluster', 'shared-node', 'memory-scope'); $alpha = ClusterCache::create( new NodeCacheConfig($sqliteFile, 'alpha', apcuEnabled: false), $cluster, @@ -140,6 +140,99 @@ ->and($beta->status()->cursor)->toBe('1'); }); +test('cursor progress is isolated by transport identity on the same node scope', function () { + $transportA = new InMemoryInvalidationTransport(); + $transportB = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/shared-transport-node.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $runtimeA = ClusterCache::create( + $node, + new ClusterCacheConfig('transport-cluster', 'shared-node', 'transport-a'), + $transportA, + ); + $runtimeB = ClusterCache::create( + $node, + new ClusterCacheConfig('transport-cluster', 'shared-node', 'transport-b'), + $transportB, + ); + + $runtimeB->cache()->set('beta', 'stale', 300); + $transportA->publish(InvalidationEvent::key('transport-cluster', 'application', 'alpha', 'writer')); + $transportB->publish(InvalidationEvent::key('transport-cluster', 'application', 'beta', 'writer')); + + expect($runtimeA->consume())->toBe(1) + ->and($runtimeA->status()->cursor)->toBe('1') + ->and($runtimeB->status()->cursor)->toBeNull() + ->and($runtimeB->consume())->toBe(1) + ->and($runtimeB->cache()->get('beta'))->toBeNull() + ->and($runtimeB->status()->cursor)->toBe('1'); +}); + +test('legacy cursor migration clears the affected scope before establishing new progress', function () { + $sqliteFile = $this->clusterDirectory . '/legacy-cursor.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cache = \Infocyph\CacheLayer\Node\NodeCache::create($node); + $cache->set('stale', 'value', 300); + + $pdo = new PDO('sqlite:' . $sqliteFile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec( + 'CREATE TABLE cachelayer_cluster_cursors (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id)) WITHOUT ROWID', + ); + $statement = $pdo->prepare( + 'INSERT INTO cachelayer_cluster_cursors (cluster_name, node_id, last_event_id, updated_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute(['migration-cluster', 'shared-node', '99', time()]); + + $transport = new InMemoryInvalidationTransport(); + $runtime = ClusterCache::create( + $node, + new ClusterCacheConfig('migration-cluster', 'shared-node', 'memory-migrated'), + $transport, + ); + + expect($runtime->status()->cursor)->toBeNull() + ->and($runtime->cache()->get('stale'))->toBe('value') + ->and($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); + +test('namespace-scoped v2 cursor migration is isolated per transport identity', function () { + $sqliteFile = $this->clusterDirectory . '/v2-cursor.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cache = \Infocyph\CacheLayer\Node\NodeCache::create($node); + $cache->set('stale', 'value', 300); + + $pdo = new PDO('sqlite:' . $sqliteFile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec( + 'CREATE TABLE cachelayer_cluster_cursors_v2 (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name)) WITHOUT ROWID', + ); + $statement = $pdo->prepare( + 'INSERT INTO cachelayer_cluster_cursors_v2 ' + . '(cluster_name, node_id, namespace_name, last_event_id, updated_at) VALUES (?, ?, ?, ?, ?)', + ); + $statement->execute(['migration-cluster', 'shared-node', 'application', '42', time()]); + + $runtime = ClusterCache::create( + $node, + new ClusterCacheConfig('migration-cluster', 'shared-node', 'transport-v3'), + new InMemoryInvalidationTransport(), + ); + + expect($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); + + test('cluster status reports cursor position, pending events, and consume results', function () { $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'first', 'writer')); $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'second', 'writer')); @@ -253,9 +346,11 @@ }); test('cluster configuration and runtime inputs enforce transport bounds before publication', function () { - expect(fn() => new ClusterCacheConfig(str_repeat('c', 129), 'node')) + expect(fn() => new ClusterCacheConfig(str_repeat('c', 129), 'node', 'memory')) + ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) + ->and(fn() => new ClusterCacheConfig('cluster', str_repeat('n', 256), 'memory')) ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) - ->and(fn() => new ClusterCacheConfig('cluster', str_repeat('n', 256))) + ->and(fn() => new ClusterCacheConfig('cluster', 'node', str_repeat('t', 129))) ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) ->and(fn() => $this->nodeA->invalidateKey(str_repeat('k', 65))) ->toThrow(ClusterCacheException::class) @@ -317,6 +412,7 @@ 'failed-cluster', 'consumer', 'application', + 'memory-primary', ); $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'failed-cluster'); $consumer = new InvalidationConsumer( @@ -347,6 +443,7 @@ 'recovery-failure', 'consumer', 'application', + 'memory-primary', ); $cursor->advance('1'); $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'recovery-failure'); From a8ef884bcd738421ab0bfb80a108984497911d30 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:50:02 +0600 Subject: [PATCH 092/434] docs(cluster): document ordered publication and full cursor scope --- docs/cluster/_content.inc | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index ebd9c89e..1e65a8a9 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -58,7 +58,7 @@ Cluster Cache is not: Cluster identity, nodes, and namespaces --------------------------------------- -``ClusterCacheConfig`` has four settings: +``ClusterCacheConfig`` has five settings: .. code-block:: php @@ -67,6 +67,7 @@ Cluster identity, nodes, and namespaces $clusterConfig = new ClusterCacheConfig( cluster: 'production', nodeId: 'catalog-web-01', + transportIdentity: 'primary-postgres', consumerBatchSize: 1_000, invalidateLocallyFirst: true, ); @@ -85,6 +86,13 @@ Cluster identity, nodes, and namespaces but their cursor still advances. A stable hostname is suitable for long-lived hosts; use a unique instance/pod identity for ephemeral infrastructure. +``transportIdentity`` + A stable 1--128 character identity for the replay log backing this runtime. + It is part of local cursor ownership together with cluster, node, and + namespace. Use the same value when reconnecting to the same durable event + log and a different value when switching to a different database, Redis + deployment, stream prefix/domain, or other independent transport history. + ``consumerBatchSize`` Maximum events fetched by ``consume()`` when no explicit limit is supplied. It must be greater than zero. Start with 1,000 and tune from observed event @@ -110,6 +118,7 @@ Every node needs: * a local writable SQLite file and optional APCu, as described in :doc:`/node/index`; * the same cluster name and a unique node ID; +* a stable ``transportIdentity`` for the durable event log being consumed; * access to the same durable, replayable event transport; * a consumer process or scheduled task that calls ``consume()``; * retention longer than the largest expected node outage plus an operational @@ -560,8 +569,8 @@ ensure the identity remains unique and monitor for unexpected backlog replay. Retention, recovery, and offline nodes -------------------------------------- -Each node stores ``(cluster, nodeId, lastEventId)`` in its local Node Cache -SQLite database. The transport retains only a finite event history. If a node's +Each node stores ``(cluster, nodeId, namespace, transportIdentity, lastEventId)`` +in its local Node Cache SQLite database. The transport retains only a finite event history. If a node's cursor predates the oldest available event, it cannot know every invalidation it missed. Before normal consumption, Cluster Cache therefore: @@ -577,6 +586,12 @@ If the clear returns ``false`` or throws, recovery fails and preserves the old cursor. The node therefore cannot acknowledge a retention gap while stale local data remains. +During the 4.0 cursor upgrade, legacy cluster/node cursors and the intermediate +cluster/node/namespace cursor format are never copied into the new full scope. +If an affected legacy scope is detected, recovery clears that namespace before +establishing new cursor progress. This deliberately trades cache warmth for +proof that an old shared cursor cannot skip invalidations. + This safe reset is why event retention is an availability and cache-warmth decision rather than a correctness shortcut. Short retention causes more full local clears after outages; long retention increases transport storage and @@ -713,7 +728,8 @@ At deployment: * create a local, private cache directory on every node; * configure one shared durable transport per logical cluster; -* give every node a unique ID and use the same cluster name; +* give every node a unique ID, use the same cluster name, and configure a + stable transport identity for the shared replay log; * start a consumer on every node before or alongside application traffic; * set event retention longer than the expected maximum outage; * choose bounded cache TTLs even with fast consumers. From f6e89c6e15cc91be73d2f8f276f4dbf18ecf1072 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:50:54 +0600 Subject: [PATCH 093/434] fix(cluster): bind cursor to transport identity --- src/Cluster/ClusterCache.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cluster/ClusterCache.php b/src/Cluster/ClusterCache.php index 25325a0a..0c987698 100644 --- a/src/Cluster/ClusterCache.php +++ b/src/Cluster/ClusterCache.php @@ -27,6 +27,7 @@ public static function create( $cluster->cluster, $cluster->nodeId, $node->namespace, + $cluster->transportIdentity, ); $status = new ClusterStatusTracker(); $recovery = new ClusterRecoveryManager($cache, $cursorStore, $transport, $cluster->cluster); From 2917d45b7db368a53aedeb4eb60c82c0c9857174 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:52:24 +0600 Subject: [PATCH 094/434] test(cluster): cover cursor restart and reset --- tests/Cluster/ClusterCacheTest.php | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 0767fe66..b72a7abe 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -233,6 +233,40 @@ }); +test('scoped cursor persists across runtime restart and reset', function () { + $transport = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/restart-node.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cluster = new ClusterCacheConfig('restart-cluster', 'restart-node', 'memory-restart'); + + $transport->publish(InvalidationEvent::key('restart-cluster', 'application', 'first', 'writer')); + $firstRuntime = ClusterCache::create($node, $cluster, $transport); + expect($firstRuntime->consume())->toBe(1) + ->and($firstRuntime->status()->cursor)->toBe('1'); + + unset($firstRuntime); + $secondRuntime = ClusterCache::create($node, $cluster, $transport); + $transport->publish(InvalidationEvent::key('restart-cluster', 'application', 'second', 'writer')); + + expect($secondRuntime->status()->cursor)->toBe('1') + ->and($secondRuntime->consume())->toBe(1) + ->and($secondRuntime->status()->cursor)->toBe('2'); + + $cursor = new SqliteCursorStore( + $sqliteFile, + 'restart-cluster', + 'restart-node', + 'application', + 'memory-restart', + ); + $cursor->reset('1'); + expect($cursor->current())->toBe('1'); + + $cursor->reset(null); + expect($cursor->current())->toBeNull() + ->and($cursor->requiresRecovery())->toBeFalse(); +}); + test('cluster status reports cursor position, pending events, and consume results', function () { $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'first', 'writer')); $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'second', 'writer')); From dedf45b1c0698c7cdd0a69f4495a12d895764fce Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:54:21 +0600 Subject: [PATCH 095/434] style(cluster): order cursor interface methods --- src/Cluster/Cursor/CursorStoreInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cluster/Cursor/CursorStoreInterface.php b/src/Cluster/Cursor/CursorStoreInterface.php index 9a15a9f5..aa40befe 100644 --- a/src/Cluster/Cursor/CursorStoreInterface.php +++ b/src/Cluster/Cursor/CursorStoreInterface.php @@ -10,9 +10,9 @@ public function advance(string $eventId): void; public function current(): ?string; - public function reset(?string $eventId): void; - public function requiresRecovery(): bool; + public function reset(?string $eventId): void; + public function updatedAt(): ?int; } From 3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:54:57 +0600 Subject: [PATCH 096/434] style(cluster): order scoped cursor elements --- src/Cluster/Cursor/SqliteCursorStore.php | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index 7b4e59eb..fca01e09 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -19,10 +19,10 @@ private const string TABLE = 'cachelayer_cluster_cursors_v3'; - private PDO $connection; - private string $cluster; + private PDO $connection; + private string $namespace; private string $nodeId; @@ -68,11 +68,6 @@ public function current(): ?string return is_string($cursor) && $cursor !== '' ? $cursor : null; } - public function reset(?string $eventId): void - { - $this->write($eventId); - } - public function requiresRecovery(): bool { if ($this->scopeExists()) { @@ -82,6 +77,11 @@ public function requiresRecovery(): bool return $this->legacyScopeExists() || $this->previousScopeExists(); } + public function reset(?string $eventId): void + { + $this->write($eventId); + } + public function updatedAt(): ?int { $updatedAt = $this->read( @@ -173,17 +173,6 @@ private function rowExists(string $sql, array $parameters, string $failureMessag } } - /** @return array */ - private function scopeParameters(): array - { - return [ - ':cluster' => $this->cluster, - ':node_id' => $this->nodeId, - ':namespace' => $this->namespace, - ':transport_identity' => $this->transportIdentity, - ]; - } - private function scopeExists(): bool { return $this->rowExists( @@ -195,6 +184,17 @@ private function scopeExists(): bool ); } + /** @return array */ + private function scopeParameters(): array + { + return [ + ':cluster' => $this->cluster, + ':node_id' => $this->nodeId, + ':namespace' => $this->namespace, + ':transport_identity' => $this->transportIdentity, + ]; + } + private function tableExists(string $table): bool { return $this->rowExists( From d85a7037edf8f7a896e4c3e44af76d45f917ce1f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 18:58:32 +0600 Subject: [PATCH 097/434] docs(plan): close verified Batch 3 tracker --- .../cachelayer-4.0-security-correctness-plan.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index e32e0b3c..94ee0128 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batches 1-2 complete, Batch 3 in progress\ +Status: Implementation in progress; Batches 1-3 complete; Batch 4 next\ Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -15,8 +15,8 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | --- | --- | --- | --- | | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | -| 3 — Durable invalidation protocol | R06, R07 | **In progress** | R06 per-cluster commit-order serialization and R07 namespace-scoped cursor ownership are implemented with regression coverage; full Batch 3 QA is pending. | -| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Pending prior batches. | +| 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | +| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Ready to start after verified Batch 3 closure. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | @@ -39,7 +39,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | | R02 — authenticated payload identity | **Complete** | Bound `cache-record:v3` HMAC envelopes authenticate purpose, logical storage identity, and key. Cross-key/cross-namespace substitution, legacy unbound signed payloads, malformed/wrong-key payloads, tier promotion, secure defaults, and adapter/atomic verification paths have regression coverage. | Passed final Batch 2 QA on run #210. | -| R15 — Node storage/topology identity | **Complete for Batch 2 scope** | Node APCu and lock identities include the SQLite store; `NodeCacheConfig` carries one cohesive `CacheOptions` policy; failed L1 mutations fence that L1 from later reads so stale promoted state cannot override authoritative SQLite. Broader cross-process/node invalidation coherence remains coupled to Batch 3 acceptance. | Passed final Batch 2 QA on run #210. | +| R15 — Node storage/topology identity | **Complete** | Node APCu and lock identities include the SQLite store; `NodeCacheConfig` carries one cohesive `CacheOptions` policy; failed L1 mutations fence that L1 from later reads so stale promoted state cannot override authoritative SQLite. Batch 3 additionally established full invalidation cursor scope across cluster, node, namespace, and transport identity. Separate PHP SAPIs/processes still require their own consumer lifecycle; no cross-process APCu coherence is implied. | Core identity/L1 behavior passed Batch 2 run #210; topology follow-through passed Batch 3 run #240. | | R18 — SQL binary identity | **Complete** | New MySQL/MariaDB cache and invalidation schemas create identity columns as `ascii_bin`; existing schemas are metadata-checked and hardened only when required, avoiding repeated identity `ALTER TABLE` work. Backend regressions verify byte-sensitive identity and case-distinct namespaces/keys. | Passed final Batch 2 QA on run #210. Consolidated upgrade/rollback instructions remain a Batch 6 release-documentation gate. | **Batch 2 implementation/closure commits:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, `f2c3e55db67ecc3237dc87494c2828c66f33d5ee`, and `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (adapter/atomic identity verification and codec hardening), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SQL/shared-memory identity), `64fc9ba5fcdfceb12e92efd2912192486e0a1cd6` through `1924a74da3b9d6474696631405e839bd52ec158b` (final schema idempotence, unified Node policy, L1 coherence fencing, regressions/docs, and exact PHPForge formatting). @@ -50,10 +50,12 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | -| R06 — durable PDO invalidation ordering | **Implemented; QA pending** | PDO publication now acquires a per-cluster transactional lock before event-ID allocation. MySQL/PostgreSQL concurrency coverage forces a second publisher to attempt completion before the first transaction commits, plus rollback and publisher-process-death cases. | Full Batch 3 QA pending. | -| R07 — namespace-scoped cursor ownership | **Implemented; QA pending** | SQLite cursor storage is versioned and keyed by cluster + node + namespace. A same-node/same-store multi-namespace regression proves one namespace cannot advance another namespace's replay cursor; null/new scoped cursors clear local state before replay. | Full Batch 3 QA pending. | +| R06 — durable PDO invalidation ordering | **Complete** | PDO publication acquires a per-cluster transactional lock before event-ID allocation, so later publishers cannot commit a higher ID ahead of an earlier same-cluster transaction. Real MySQL/PostgreSQL multi-process coverage verifies blocked reversed-order publication, rollback, publisher process death, and subsequent ordered replay. Different clusters retain independent lock domains. | Passed final Batch 3 QA on run #240. | +| R07 — full cursor-scope ownership | **Complete** | SQLite cursor storage is versioned as v3 and keyed by cluster + node + namespace + transport identity. Regressions cover same-node multi-namespace isolation, independent transport histories with overlapping event IDs, restart persistence, reset behavior, retention recovery, and legacy `(cluster,node)` plus intermediate v2 `(cluster,node,namespace)` migration. Legacy progress is never copied into the new scope; affected local cache state is cleared before new progress is established. | Passed final Batch 3 QA on run #240. | -**Batch 3 implementation commits so far:** `965d90ea9fbc6e39f77988e3aef4900a792b30c9` through `6a658362f6dc777f1f0f50a196289d7ef1ef26c0` (R07 cursor scope/recovery/tests/tracker start), `4b885a133b8ec08dd0ed855e502a708f705e27fc`, `fa131a8278294dba3e290c761bf017bb7e2cd5ed`, `2b2ac8e04a1168204b397f25555e4f5221b0f5a8`, and `63d8fe94ec7df44c5238c527116945fea3a6a2bb` (R06 locking protocol, schema, real-concurrency CI support, and MySQL/PostgreSQL regressions). +**Batch 3 implementation/closure commits:** `965d90ea9fbc6e39f77988e3aef4900a792b30c9` through `6a658362f6dc777f1f0f50a196289d7ef1ef26c0`, plus `2d915a90e22937e8dc68a48826ae40599e9f66f3`, `831229c22a9f24f59ac2814e7a0476c55fa615f6`, and `c5bc44c50b507ed8fd3d3d28be2ce6915e9b8352` (initial scoped-cursor recovery), `4b885a133b8ec08dd0ed855e502a708f705e27fc`, `fa131a8278294dba3e290c761bf017bb7e2cd5ed`, `ffcaec51f83b9a9d2a9dfbffbdb41e57f9ad019a`, `2b2ac8e04a1168204b397f25555e4f5221b0f5a8`, `63d8fe94ec7df44c5238c527116945fea3a6a2bb`, `13e6ec0e13395b3f6e4fdedf6e04a05480d75474`, `9b95a3ec8ecea5dd5da4e87f9ce1a9b7a3b23d6a`, and `4b6066dc530369b8b3dcc77088919091de6173d6` (R06 publication locking, transaction ownership, real MySQL/PostgreSQL process isolation, and QA cleanup), `5c3410c4755f1ce224cb87f0c421b729cfc5aaf7`, `e28335cd8776addeb42aae669a4901fc3f5826cf`, `d40a0d27db9eaf514ee12d2747a170252a7f179e`, `f6e89c6e15cc91be73d2f8f276f4dbf18ecf1072`, `ff90d2fdf7b7893ec18738567f4314d87bce76f2`, `a8ef884bcd738421ab0bfb80a108984497911d30`, `2917d45b7db368a53aedeb4eb60c82c0c9857174`, `dedf45b1c0698c7cdd0a69f4495a12d895764fce`, and `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e` (transport-aware v3 cursor scope, safe legacy/v2 recovery, restart/reset tests, docs, and exact PHPForge ordering). + +**Batch 3 closure evidence:** exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e` passed Security & Standards run #240: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. The run includes real MySQL/PostgreSQL concurrent publication tests, cursor-scope/migration regressions, Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates. All PR code-scanning review threads were resolved on the verified head. ## Decision From ec2680b1eb690393669e262e09548d115621f52f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:09:05 +0600 Subject: [PATCH 098/434] fix(memoize): make callable and value identity collision-safe --- src/Memoize/CallableFingerprint.php | 63 +++++++++++++++++------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/Memoize/CallableFingerprint.php b/src/Memoize/CallableFingerprint.php index 22da4508..94e9386a 100644 --- a/src/Memoize/CallableFingerprint.php +++ b/src/Memoize/CallableFingerprint.php @@ -31,14 +31,14 @@ public static function callable(callable $callable): string } if (is_array($callable)) { $target = is_object($callable[0]) - ? self::object($callable[0]) - : $callable[0]; + ? self::objectIdentity($callable[0]) + : 'class:' . $callable[0]; return 'array:' . $target . '::' . $callable[1]; } if (is_object($callable)) { - return 'invokable:' . self::object($callable); + return 'invokable:' . self::objectIdentity($callable); } throw new \LogicException('Unsupported callable form.'); @@ -50,6 +50,13 @@ public static function flush(): void self::$objects = new WeakMap(); } + public static function objectIdentity(object $object): string + { + self::$objects ??= new WeakMap(); + + return self::$objects[$object] ??= $object::class . '#' . ++self::$nextObjectId; + } + public static function value(mixed $value): mixed { BoundedValueTraversal::assertSafe($value); @@ -69,19 +76,22 @@ private static function closure(Closure $closure): string $captures = []; foreach ($statics as $name => $value) { $reference = ReflectionReference::fromArrayElement($statics, $name); - $captures[$name] = $reference instanceof ReflectionReference - ? ['reference', bin2hex($reference->getId()), self::value($value)] - : self::value($value); + $captures[] = [ + 'name' => $name, + 'reference' => $reference instanceof ReflectionReference ? bin2hex($reference->getId()) : null, + 'value' => self::normalizeValue($value), + ]; } $bound = $reflection->getClosureThis(); $scope = $reflection->getClosureScopeClass(); $identity = [ - $reflection->getFileName() ?: 'internal', - $reflection->getStartLine(), - $reflection->getEndLine(), - $captures, - $bound === null ? null : self::object($bound), - $scope?->getName(), + 'instance' => self::objectIdentity($closure), + 'file' => $reflection->getFileName() ?: 'internal', + 'start' => $reflection->getStartLine(), + 'end' => $reflection->getEndLine(), + 'captures' => $captures, + 'bound' => $bound === null ? null : self::objectIdentity($bound), + 'scope' => $scope?->getName(), ]; return self::$closures[$closure] = 'closure:' . hash('xxh128', serialize($identity)); @@ -90,30 +100,31 @@ private static function closure(Closure $closure): string private static function normalizeValue(mixed $value): mixed { return match (true) { - $value instanceof Closure => self::closure($value), - is_object($value) => self::object($value), - is_resource($value) => 'res:' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => self::values($value), - default => $value, + $value === null => ['null'], + is_bool($value) => ['bool', $value], + is_int($value) => ['int', $value], + is_float($value) => ['float', serialize($value)], + is_string($value) => ['string', $value], + $value instanceof Closure => ['closure', self::closure($value)], + is_object($value) => ['object', self::objectIdentity($value)], + is_resource($value) => ['resource', get_resource_type($value), (int) $value], + is_array($value) => ['array', self::values($value)], + default => ['type', get_debug_type($value)], }; } - private static function object(object $object): string - { - self::$objects ??= new WeakMap(); - - return self::$objects[$object] ??= 'obj:' . $object::class . '#' . ++self::$nextObjectId; - } - /** * @param array $values - * @return array + * @return list */ private static function values(array $values): array { $normalized = []; foreach ($values as $key => $value) { - $normalized[$key] = self::normalizeValue($value); + $normalized[] = [ + 'key' => [is_int($key) ? 'int' : 'string', $key], + 'value' => self::normalizeValue($value), + ]; } return $normalized; From f63a956cf6f779800505c1ca844143cbe15e9c75 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:09:20 +0600 Subject: [PATCH 099/434] fix(memoize): use lifetime-safe once owner identity --- src/Memoize/OnceMemoizer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index a77eb768..1d997827 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -58,7 +58,7 @@ private function cacheKey(callable $callback, int $callerOffset): string . ':' . ($location['line'] ?? 0) . ':' . ($caller['class'] ?? '') . ':' . $this->normalizeCallerFunction($caller['function'] ?? '(unknown)') - . ':' . (is_object($callerObject) ? spl_object_id($callerObject) : '') + . ':' . (is_object($callerObject) ? CallableFingerprint::objectIdentity($callerObject) : '') . ':' . $this->callbackFingerprint($callback); } @@ -74,7 +74,7 @@ private function callbackFingerprint(callable $callback): string $reflection->getStartLine(), $reflection->getEndLine(), $reflection->getClosureScopeClass()?->getName() ?? '', - $bound === null ? '' : $bound::class . '#' . spl_object_id($bound), + $bound === null ? '' : CallableFingerprint::objectIdentity($bound), ]); } From 625c84acc5a179357f58ddc677fe34626321f807 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:09:38 +0600 Subject: [PATCH 100/434] test(memoize): cover identity collision regressions --- tests/Memoize/MemoizeTest.php | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 0b2ee764..1fdbbb0f 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -161,3 +161,40 @@ public function next(): int ->and(array_key_exists('seed-0', $staticCache->getValue($memoizer)))->toBeFalse() ->and(array_key_exists('seed-0', $weakMap[$object]))->toBeFalse(); }); + + +it('same-line closures remain distinct', function () { + [$first, $second] = [static fn(): string => 'first', static fn(): string => 'second']; + + expect(memoize($first))->toBe('first') + ->and(memoize($second))->toBe('second') + ->and(memoize($first))->toBe('first') + ->and(memoize()->stats()['hits'])->toBe(1); +}); + +it('memoizer type-tags object string and resource parameters', function () { + $object = new stdClass(); + $objectToken = 'stdClass#1'; + $resource = fopen('php://memory', 'rb'); + expect($resource)->toBeResource(); + + $identity = static fn(mixed $value): string => get_debug_type($value); + + expect(memoize($identity, [$object]))->toBe('stdClass') + ->and(memoize($identity, [$objectToken]))->toBe('string') + ->and(memoize($identity, [$resource]))->toBe('resource (stream)') + ->and(memoize($identity, ['res:stream#' . (int) $resource]))->toBe('string'); + + fclose($resource); +}); + +it('per-object memoization does not retain collected owners', function () { + $owner = new stdClass(); + $reference = WeakReference::create($owner); + + expect(remember($owner, static fn(): string => 'value'))->toBe('value'); + unset($owner); + gc_collect_cycles(); + + expect($reference->get())->toBeNull(); +}); From 5296c439c3e84be80d07e78985a5ba830bed030b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:10:08 +0600 Subject: [PATCH 101/434] fix(memcached): centralize relative expiration conversion --- src/Cache/Adapter/MemcachedExpiration.php | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Cache/Adapter/MemcachedExpiration.php diff --git a/src/Cache/Adapter/MemcachedExpiration.php b/src/Cache/Adapter/MemcachedExpiration.php new file mode 100644 index 00000000..6b6d0ed1 --- /dev/null +++ b/src/Cache/Adapter/MemcachedExpiration.php @@ -0,0 +1,30 @@ + PHP_INT_MAX - $now) { + throw new CacheInvalidArgumentException('Memcached expiration exceeds the supported timestamp range.'); + } + + return $now + $seconds; + } +} From c7038e3a7162062612e3158c8a9386ed0fee4078 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:10:26 +0600 Subject: [PATCH 102/434] fix(memcached): normalize long TTLs across cache paths --- src/Cache/Adapter/MemcachedCacheAdapter.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 8027d47e..5ca7318a 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -80,7 +80,7 @@ public function atomicCompareAndSet( $extended['cas'], $mapped, $replacementBlob, - $ttl ?? 0, + MemcachedExpiration::fromRelative($ttl), ); } @@ -125,13 +125,13 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool $this->namespaceGeneration(), ); - if ($this->client->add($mapped, $blob, $ttl ?? 0)) { + if ($this->client->add($mapped, $blob, MemcachedExpiration::fromRelative($ttl))) { return true; } $extended = $this->extendedGet($mapped); if ($extended === null) { - return $this->client->add($mapped, $blob, $ttl ?? 0); + return $this->client->add($mapped, $blob, MemcachedExpiration::fromRelative($ttl)); } $current = $extended['value']; @@ -144,7 +144,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return false; } - return $this->client->cas($extended['cas'], $mapped, $blob, $ttl ?? 0); + return $this->client->cas($extended['cas'], $mapped, $blob, MemcachedExpiration::fromRelative($ttl)); } public function clear(): bool @@ -324,8 +324,8 @@ public function saveItems(array $items): bool continue; } - $ttl = $expiration['ttl'] ?? 0; - $groups[$ttl][$this->mapData($item->getKey())] = $this->encodeItem( + $memcachedExpiration = MemcachedExpiration::fromRelative($expiration['ttl']); + $groups[$memcachedExpiration][$this->mapData($item->getKey())] = $this->encodeItem( $item, $expiration['expiresAt'], $generation, @@ -335,8 +335,8 @@ public function saveItems(array $items): bool if (!$this->deleteItems($expired)) { return false; } - foreach ($groups as $ttl => $records) { - if (!$this->client->setMulti($records, (int) $ttl)) { + foreach ($groups as $memcachedExpiration => $records) { + if (!$this->client->setMulti($records, (int) $memcachedExpiration)) { return false; } } From b6883dc0cfbd611280f4e5677174348197f284fd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:10:38 +0600 Subject: [PATCH 103/434] fix(memcached): normalize long lock leases --- src/Cache/Lock/MemcachedLockProvider.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Cache/Lock/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index 016653e6..9d554b0e 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Lock; +use Infocyph\CacheLayer\Cache\Adapter\MemcachedExpiration; use RuntimeException; final readonly class MemcachedLockProvider implements LockProviderInterface @@ -26,7 +27,7 @@ public function __construct( public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - $ttlSeconds = self::leaseSeconds($leaseSeconds); + $ttlSeconds = MemcachedExpiration::fromRelative(self::leaseSeconds($leaseSeconds)); return $this->acquireWithRetry( $this->prefix, @@ -43,7 +44,7 @@ public function refresh(?LockHandle $handle, float $leaseSeconds): bool return false; } - $ttlSeconds = self::leaseSeconds($leaseSeconds); + $ttlSeconds = MemcachedExpiration::fromRelative(self::leaseSeconds($leaseSeconds)); $values = $this->memcached->getMulti([$handle->key], \Memcached::GET_EXTENDED); if (!is_array($values)) { return false; From 455a3733f1bab754f7364350b44506e79f879a8a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:11:13 +0600 Subject: [PATCH 104/434] test(memcached): cover long TTL and lease semantics --- tests/Cache/MemcachedCachePoolTest.php | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index 23c503fb..efaa7a64 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -151,3 +151,48 @@ ->and($items['m2']->get())->toBe('bar') ->and($items['missing']->isHit())->toBeFalse(); }); + + +test('Memcached long TTLs remain relative at the CacheLayer boundary', function () { + $thirtyDays = 2_592_000; + $thirtyDaysAndOne = $thirtyDays + 1; + $thirtyOneDays = 2_678_400; + + expect($this->cache->set('ttl-30d', 'exact', $thirtyDays))->toBeTrue() + ->and($this->cache->get('ttl-30d'))->toBe('exact') + ->and($this->cache->set('ttl-30d-plus', 'plus', $thirtyDaysAndOne))->toBeTrue() + ->and($this->cache->get('ttl-30d-plus'))->toBe('plus') + ->and($this->cache->set('ttl-31d', 'month', $thirtyOneDays))->toBeTrue() + ->and($this->cache->get('ttl-31d'))->toBe('month') + ->and($this->cache->set('ttl-interval', 'interval', new DateInterval('P31D')))->toBeTrue() + ->and($this->cache->get('ttl-interval'))->toBe('interval') + ->and($this->cache->set( + 'ttl-absolute', + 'absolute', + (new DateTimeImmutable())->modify('+31 days'), + ))->toBeTrue() + ->and($this->cache->get('ttl-absolute'))->toBe('absolute'); +}); + +test('Memcached atomic writes and leases normalize long TTLs', function () { + $longTtl = 2_592_001; + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull(); + if ($atomic === null) { + return; + } + + expect($atomic->setIfAbsent('atomic-long', 'first', $longTtl))->toBeTrue() + ->and($this->cache->get('atomic-long'))->toBe('first') + ->and($atomic->compareAndSet('atomic-long', 'first', 'second', $longTtl))->toBeTrue() + ->and($this->cache->get('atomic-long'))->toBe('second'); + + $provider = new MemcachedLockProvider($this->client); + $handle = $provider->acquire('long-lease', 0.0, (float) $longTtl); + expect($handle)->not->toBeNull(); + if ($handle !== null) { + expect($this->client->get($handle->key))->toBe($handle->token) + ->and($provider->refresh($handle, (float) $longTtl))->toBeTrue(); + $provider->release($handle); + } +}); From e5e09e8cb53d685a5efe2c735f3b78e58b6e7c0f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:13:13 +0600 Subject: [PATCH 105/434] fix(tiered): invalidate or fence skipped upper tiers --- src/Cache/Adapter/TieredCacheAdapter.php | 155 ++++++++++++++++++++--- 1 file changed, 139 insertions(+), 16 deletions(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 33702b3f..9b1f1f30 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -14,6 +14,11 @@ final class TieredCacheAdapter extends AbstractCacheAdapter { + private bool $bypassUpperTier = false; + + /** @var array */ + private array $bypassedKeys = []; + /** @param list $pools */ public function __construct( private readonly array $pools, @@ -50,8 +55,16 @@ public function assertStorageIdentityCompatible(string $storageIdentity): void public function clear(): bool { $cleared = true; - foreach ($this->pools as $pool) { - $cleared = $pool->clear() && $cleared; + foreach ($this->pools as $index => $pool) { + $poolCleared = $pool->clear(); + if ($index === 0 && !$poolCleared) { + $this->bypassUpperTier = true; + } + $cleared = $poolCleared && $cleared; + } + if ($cleared) { + $this->bypassUpperTier = false; + $this->bypassedKeys = []; } $this->deferred = []; @@ -85,8 +98,12 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { $deleted = true; - foreach ($this->pools as $pool) { - $deleted = $pool->deleteItem($key) && $deleted; + foreach ($this->pools as $index => $pool) { + $poolDeleted = $pool->deleteItem($key); + if ($index === 0) { + $this->setUpperTierFence($key, !$poolDeleted); + } + $deleted = $poolDeleted && $deleted; } return $deleted; @@ -96,8 +113,14 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $deleted = true; - foreach ($this->pools as $pool) { - $deleted = $pool->deleteItems($keys) && $deleted; + foreach ($this->pools as $index => $pool) { + $poolDeleted = $pool->deleteItems($keys); + if ($index === 0) { + foreach ($keys as $key) { + $this->setUpperTierFence($key, !$poolDeleted); + } + } + $deleted = $poolDeleted && $deleted; } return $deleted; @@ -106,6 +129,10 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { foreach ($this->pools as $index => $pool) { + if ($this->shouldBypass($index, $key)) { + continue; + } + $item = $pool->getItem($key); if (!$item->isHit()) { continue; @@ -146,24 +173,43 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - $remaining = array_fill_keys($keys, true); + $remaining = $keys; $results = []; foreach ($this->pools as $index => $pool) { if ($remaining === []) { break; } - $wanted = array_keys($remaining); + + $wanted = []; + foreach ($remaining as $key) { + if (!$this->shouldBypass($index, $key)) { + $wanted[] = $key; + } + } + if ($wanted === []) { + continue; + } + $fetched = $pool->multiFetch($wanted); $hits = []; - foreach ($wanted as $key) { + $next = []; + foreach ($remaining as $key) { + if ($this->shouldBypass($index, $key)) { + $next[] = $key; + + continue; + } + $item = $fetched[$key] ?? null; if (!$item instanceof CacheItemInterface || !$item->isHit()) { + $next[] = $key; + continue; } $hits[$key] = $this->copyItem($item); $results[$key] = $hits[$key]; - unset($remaining[$key]); } + $remaining = $next; if ($index > 0 && $hits !== []) { $this->promote($hits, $index); } @@ -201,7 +247,18 @@ public function save(CacheItemInterface $item): bool $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; } - return $written; + if ($start === 0) { + if ($written) { + $this->setUpperTierFence($item->getKey(), false); + } + + return $written; + } + + $invalidated = $this->pools[0]->deleteItem($item->getKey()); + $this->setUpperTierFence($item->getKey(), !$invalidated); + + return $written && $invalidated; } /** @param array $items */ @@ -230,9 +287,30 @@ private function copyItem(CacheItemInterface $source): CacheItem private function promote(array $items, int $tierIndex): void { for ($index = 0; $index < $tierIndex; $index++) { - if ($this->saveIntoPool($this->pools[$index], $items)) { + if ($index === 0 && $this->bypassUpperTier) { + continue; + } + + $promotable = []; + foreach ($items as $item) { + if (!$this->shouldBypass($index, $item->getKey())) { + $promotable[$item->getKey()] = $item; + } + } + if ($promotable === []) { + continue; + } + + if ($this->saveIntoPool($this->pools[$index], $promotable)) { $this->metrics->increment(self::class, 'promotion_batch'); - $this->metrics->increment(self::class, 'promotion_keys', count($items)); + $this->metrics->increment(self::class, 'promotion_keys', count($promotable)); + foreach ($promotable as $item) { + $this->setUpperTierFence($item->getKey(), false); + } + } elseif ($index === 0) { + foreach ($promotable as $item) { + $this->setUpperTierFence($item->getKey(), true); + } } } } @@ -240,7 +318,14 @@ private function promote(array $items, int $tierIndex): void private function promoteOne(CacheItemInterface $item, int $tierIndex): void { for ($index = 0; $index < $tierIndex; $index++) { - $this->saveOneIntoPool($this->pools[$index], $item); + if ($this->shouldBypass($index, $item->getKey())) { + continue; + } + + $stored = $this->saveOneIntoPool($this->pools[$index], $item); + if ($index === 0) { + $this->setUpperTierFence($item->getKey(), !$stored); + } } } @@ -248,7 +333,8 @@ private function promoteOne(CacheItemInterface $item, int $tierIndex): void private function saveIntoPool(InternalCachePoolInterface $pool, array $items): bool { $targets = []; - foreach ($items as $key => $item) { + foreach ($items as $item) { + $key = $item->getKey(); $target = $pool->createItem($key); $target->set($item->get()); $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); @@ -273,6 +359,24 @@ private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInte return $pool->save($target); } + private function setUpperTierFence(string $key, bool $fenced): void + { + $identity = hash('xxh128', $key); + if ($fenced) { + $this->bypassedKeys[$identity] = true; + + return; + } + + unset($this->bypassedKeys[$identity]); + } + + private function shouldBypass(int $tierIndex, string $key): bool + { + return $tierIndex === 0 + && ($this->bypassUpperTier || isset($this->bypassedKeys[hash('xxh128', $key)])); + } + /** @param array $items */ private function writeBatch(array $items): bool { @@ -282,6 +386,25 @@ private function writeBatch(array $items): bool $written = $this->saveIntoPool($this->pools[$index], $items) && $written; } - return $written; + if ($start === 0) { + if ($written) { + foreach ($items as $item) { + $this->setUpperTierFence($item->getKey(), false); + } + } + + return $written; + } + + $keys = []; + foreach ($items as $item) { + $keys[] = $item->getKey(); + } + $invalidated = $this->pools[0]->deleteItems($keys); + foreach ($keys as $key) { + $this->setUpperTierFence($key, !$invalidated); + } + + return $written && $invalidated; } } From 1cda1afe7616a1c1d3b52206d5bb3c444c421078 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:13:39 +0600 Subject: [PATCH 106/434] test(tiered): cover stale L1 and numeric keys --- tests/Cache/TieredCachePoolTest.php | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Cache/TieredCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php index f1de0c31..fa172616 100644 --- a/tests/Cache/TieredCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -68,3 +68,41 @@ expect(fn() => Cache::tiered([['driver' => 'unknown-tier']])) ->toThrow(CacheInvalidArgumentException::class); }); + + +test('skipped L1 write-through invalidates promoted values before later reads', function () { + $l1 = new ArrayCacheAdapter('skip-stale'); + $l2 = new ArrayCacheAdapter('skip-stale'); + $cache = Cache::tiered([$l1, $l2], writeToL1: false); + + expect($cache->set('single', 'old'))->toBeTrue() + ->and($cache->get('single'))->toBe('old') + ->and($l1->getItem('single')->get())->toBe('old') + ->and($cache->set('single', 'new'))->toBeTrue() + ->and($l1->getItem('single')->isHit())->toBeFalse() + ->and($cache->get('single'))->toBe('new'); + + expect($cache->setMultiple(['one' => 'old-1', 'two' => 'old-2']))->toBeTrue() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 'old-1', 'two' => 'old-2']) + ->and($cache->setMultiple(['one' => 'new-1', 'two' => 'new-2']))->toBeTrue() + ->and($l1->getItem('one')->isHit())->toBeFalse() + ->and($l1->getItem('two')->isHit())->toBeFalse() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 'new-1', 'two' => 'new-2']); +}); + +test('tiered bulk reads preserve numeric-string logical keys', function () { + $l1 = new ArrayCacheAdapter('numeric-tier'); + $l2 = new ArrayCacheAdapter('numeric-tier'); + $cache = Cache::tiered([$l1, $l2], writeToL1: false); + + foreach (['0', '123', '-1', '01'] as $key) { + expect($cache->set($key, 'value-' . $key))->toBeTrue(); + } + + expect($cache->getMultiple(['0', '123', '-1', '01']))->toBe([ + 0 => 'value-0', + 123 => 'value-123', + -1 => 'value--1', + '01' => 'value-01', + ]); +}); From 0ec7b0726f9ea442fb32844877e74a4188ab1663 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:14:21 +0600 Subject: [PATCH 107/434] fix(cache): preserve numeric logical identities in tag snapshots --- src/Cache/CacheTagSnapshots.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Cache/CacheTagSnapshots.php b/src/Cache/CacheTagSnapshots.php index 65313b90..8055e108 100644 --- a/src/Cache/CacheTagSnapshots.php +++ b/src/Cache/CacheTagSnapshots.php @@ -16,23 +16,31 @@ final class CacheTagSnapshots */ public static function collectTags(array $items): array { - $tagSet = []; + $tags = []; + $seen = []; foreach ($items as $item) { if (!$item instanceof CacheItem || !$item->isHit()) { continue; } foreach ($item->getTagGenerations() as $tag => $_generation) { - $tagSet[$tag] = true; + $tag = (string) $tag; + $identity = 'tag:' . $tag; + if (isset($seen[$identity])) { + continue; + } + $seen[$identity] = true; + $tags[] = $tag; } } - return array_keys($tagSet); + return $tags; } /** @param array $generations */ public static function isCurrent(CacheItem $item, array $generations): bool { foreach ($item->getTagGenerations() as $tag => $expected) { + $tag = (string) $tag; $current = $generations[$tag] ?? null; if (!is_string($current) || !hash_equals($expected, $current)) { return false; @@ -51,7 +59,8 @@ public static function missTagged(array $items, callable $miss): array { foreach ($items as $key => $item) { if (self::isTaggedHit($item)) { - $items[$key] = $miss($key); + $logicalKey = (string) $key; + $items[$key] = $miss($logicalKey); } } @@ -71,8 +80,9 @@ public static function rejectStale(array $items, array $generations, callable $m if (!$item instanceof CacheItem || !$item->isHit() || self::isCurrent($item, $generations)) { continue; } - $stale[] = $key; - $items[$key] = $miss($key); + $logicalKey = (string) $key; + $stale[] = $logicalKey; + $items[$key] = $miss($logicalKey); } return ['items' => $items, 'stale' => $stale]; From 2aeacb7f34b1afe1a13410fc3b3de537fe7637aa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:14:44 +0600 Subject: [PATCH 108/434] fix(cache): retain numeric-string keys through bulk writes --- src/Cache/Cache.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index d95243ef..a40d6f4e 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -649,20 +649,21 @@ public function setMultiple(iterable $values, mixed $ttl = null): bool { $normalized = []; foreach ($values as $key => $value) { - if (!is_string($key)) { + if (!is_string($key) && !is_int($key)) { throw new CacheInvalidArgumentException('Cache keys must be strings.'); } + $key = (string) $key; CacheInput::key($key); - $normalized[$key] = $value; + $normalized[] = [$key, $value]; } $ttlSeconds = CacheInput::ttl($ttl); if ($ttlSeconds !== null && $ttlSeconds <= 0) { - return $this->deleteItems(array_keys($normalized)); + return $this->deleteItems(array_column($normalized, 0)); } $items = []; - foreach ($normalized as $key => $value) { - $items[$key] = $this->adapter->createItem($key)->set($value)->expiresAfter($ttlSeconds); + foreach ($normalized as [$key, $value]) { + $items["key:\0" . $key] = $this->adapter->createItem($key)->set($value)->expiresAfter($ttlSeconds); } $saved = $this->backendBool(fn(): bool => $this->adapter->saveItems($items)); $this->metric('set_batch'); @@ -775,6 +776,7 @@ private function captureTagGenerations(array $tags): ?array $generations = []; foreach ($tags as $tag) { + $tag = (string) $tag; $generation = $stored[$tag] ?? null; if (!is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { return null; @@ -880,7 +882,10 @@ private function tagGenerationsUnchanged(array $expected): bool return true; } - $current = $this->captureTagGenerations(array_keys($expected)); + $current = $this->captureTagGenerations(array_map( + static fn(int|string $tag): string => (string) $tag, + array_keys($expected), + )); return $current !== null && $current === $expected; } From cf0d3b1d59739ea7072d2937228383de2b0f2226 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:15:19 +0600 Subject: [PATCH 109/434] fix(cache): accept PHP-coerced numeric tag keys --- src/Cache/Adapter/CachePayloadCodec.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index c983ec31..c904a26c 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -288,7 +288,7 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord } foreach ($tags as $tag => $generation) { - if (!is_string($tag) + if ((!is_string($tag) && !is_int($tag)) || !is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { From 5b776f38307487386d93654b4b11f088f72e6790 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:15:40 +0600 Subject: [PATCH 110/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/PdoAtomicOperations.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/PdoAtomicOperations.php b/src/Cache/Adapter/PdoAtomicOperations.php index 39553c1c..aa0214e6 100644 --- a/src/Cache/Adapter/PdoAtomicOperations.php +++ b/src/Cache/Adapter/PdoAtomicOperations.php @@ -158,8 +158,9 @@ private function atomicRecordTagsAreCurrent(CacheRecord $record): bool return true; } - $rows = $this->fetchRows(self::KIND_TAG, array_keys($record->tags)); + $rows = $this->fetchRows(self::KIND_TAG, array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($rows[$tag]['payload'] ?? null) !== $generation) { return false; } From 91e98919ab28a284add585504172ef59066e1996 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:15:45 +0600 Subject: [PATCH 111/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/RedisCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 1008c8db..d008b86e 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -437,8 +437,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } From d269ff2956e70cd025d815de3fef12e71696caa0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:15:50 +0600 Subject: [PATCH 112/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/MongoDbCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index 3450bc02..60f5fb16 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -443,8 +443,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } From 81f93307e71d8af93946e2156fab9964da9f795d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:15:54 +0600 Subject: [PATCH 113/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/MemcachedCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 5ca7318a..4901e7d0 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -403,8 +403,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } From 333f6c60da5e7ee37491501969abbbff5d672c15 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:11 +0600 Subject: [PATCH 114/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 6468165b..f739adff 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -331,8 +331,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } From 365211f7059a1c3e98823418e97dc7768d8faaa0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:15 +0600 Subject: [PATCH 115/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/ArrayCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 859f95eb..0d72b1c5 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -271,6 +271,7 @@ private function map(string $key): string private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($this->metadata[$tag] ?? null) !== $generation) { return false; } From f6673c91082b56a11e8882cbd5a1ff03fa38ba02 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:19 +0600 Subject: [PATCH 116/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/FileCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 958c8af9..6d04b741 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -316,6 +316,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; $current = is_file($this->metadataFileFor($tag)) ? file_get_contents($this->metadataFileFor($tag)) : false; From 4155ebc25497d44bbaa9f5689bc0f156bd1ee14e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:24 +0600 Subject: [PATCH 117/434] fix(cache): preserve numeric tags in backend validation --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 3009d77d..d9083989 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -313,6 +313,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; $current = is_file($this->metadataFileFor($tag)) ? file_get_contents($this->metadataFileFor($tag)) : false; From 365cdefd87ff6a626758d1e515f343aa18fd3b91 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:34 +0600 Subject: [PATCH 118/434] fix(cache): preserve numeric tags in shared memory --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 81d1d7c1..3505f7af 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -480,6 +480,7 @@ private function prepareDirectory(string $directory): void private function recordTagsAreCurrent(CacheRecord $record, array $store): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($store[$this->mapTag($tag)] ?? null) !== $generation) { return false; } From ed718611811e73150b34bddf2280219ba1eb8d79 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:56 +0600 Subject: [PATCH 119/434] fix(cache): normalize numeric tag map keys --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index ffa915a1..afe8daf6 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -367,6 +367,7 @@ public function storeTagGenerations(array $generations): bool $this->assertWritableTransaction(); $rows = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From fc4ea3636d8bfbe598210a6c96763d6ecb8df4da Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:16:59 +0600 Subject: [PATCH 120/434] fix(cache): normalize numeric tag map keys --- src/Cache/Adapter/MongoDbCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index 60f5fb16..a7c422fa 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -338,6 +338,7 @@ public function storeTagGenerations(array $generations): bool { $operations = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From 2d4acbfcf11d41c220f8c9b225f2f8ba638338f2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:17:03 +0600 Subject: [PATCH 121/434] fix(cache): normalize numeric tag map keys --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 03ca95b9..1af3326f 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -260,6 +260,7 @@ public function saveItems(array $items): bool public function storeTagGenerations(array $generations): bool { foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From 4ea5cacbf2d03c88d693c46e3b3c8e243a0f34e5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:17:07 +0600 Subject: [PATCH 122/434] fix(cache): normalize numeric tag map keys --- src/Cache/Adapter/ArrayCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 0d72b1c5..3e37b499 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -237,6 +237,7 @@ public function saveItems(array $items): bool public function storeTagGenerations(array $generations): bool { foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From 5532883503e6233ccb39c53b45df8522a455c131 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:17:21 +0600 Subject: [PATCH 123/434] fix(cache): normalize numeric tag map keys --- src/Cache/Adapter/ApcuCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 0ba4f99b..1d90dc34 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -242,6 +242,7 @@ public function storeTagGenerations(array $generations): bool { $mapped = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From 725c96e37a6d5f7d07735b06b7b9c0e0388b24ae Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:17:25 +0600 Subject: [PATCH 124/434] fix(cache): normalize numeric tag map keys --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 3505f7af..5749f13c 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -337,6 +337,7 @@ public function storeTagGenerations(array $generations): bool return $this->withExclusiveLock(function () use ($generations): bool { $store = $this->loadStore(); foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } From 4dc032458eece34dcc3f1bb9f398b615b3e9009e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:18:55 +0600 Subject: [PATCH 125/434] fix(psr6): make deferred state coherent and retryable --- src/Cache/Adapter/AbstractCacheAdapter.php | 127 +++++++++++++++++++-- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 72464824..a3e56e3c 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -4,11 +4,13 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; +use Throwable; abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalCachePoolInterface { @@ -17,6 +19,8 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC private ?CachePayloadCodec $codec = null; + private bool $committing = false; + /** @var array */ private array $localMetadata = []; @@ -33,6 +37,18 @@ abstract public function multiFetch(array $keys): array; /** @param array $items */ abstract public function saveItems(array $items): bool; + public function __destruct() + { + if ($this->deferred === []) { + return; + } + + try { + $this->commit(); + } catch (Throwable) { + } + } + /** @internal */ public function assertOptionsCompatible(CacheOptions $options): void { @@ -56,7 +72,14 @@ public function commit(): bool } $deferred = $this->deferred; - $saved = $this->saveItems($deferred); + $this->committing = true; + + try { + $saved = $this->saveItems($deferred); + } finally { + $this->committing = false; + } + if ($saved) { $this->deferred = []; } @@ -80,7 +103,9 @@ public function configureStorageIdentity(string $storageIdentity): void public function createItem(string $key): CacheItemInterface { - return $this->genericMiss($key); + CacheInput::key($key); + + return new CacheItem($this, $key); } /** @@ -89,7 +114,19 @@ public function createItem(string $key): CacheItemInterface */ public function getItems(array $keys = []): array { - return $this->multiFetch($keys); + $keys = CacheInput::keys($keys); + $items = $this->multiFetch($keys); + + foreach ($keys as $key) { + $pending = $this->deferredRead($key); + if ($pending !== null) { + $items[$key] = $pending; + } elseif (!isset($items[$key])) { + $items[$key] = new CacheItem($this, $key); + } + } + + return $items; } /** @param list $tags */ @@ -129,11 +166,26 @@ public function saveDeferred(CacheItemInterface $item): bool return false; } - $this->deferred[$item->getKey()] = $item; + $snapshot = $this->deferredSnapshot($item); + $this->deferred[$this->deferredKey($item->getKey())] = $snapshot; return true; } + protected function discardDeferredKey(string $key): void + { + CacheInput::key($key); + unset($this->deferred[$this->deferredKey($key)]); + } + + /** @param list $keys */ + protected function discardDeferredKeys(array $keys): void + { + foreach (CacheInput::keys($keys) as $key) { + unset($this->deferred[$this->deferredKey($key)]); + } + } + protected static function isGeneration(mixed $value): bool { return is_string($value) && strlen($value) === 32 && ctype_xdigit($value); @@ -223,6 +275,12 @@ protected function genericFromBlobWithInvalidator( protected function genericItemFromRecord(string $key, CacheRecord $record): CacheItem { + CacheInput::key($key); + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + return new CacheItem( $this, $key, @@ -235,7 +293,9 @@ protected function genericItemFromRecord(string $key, CacheRecord $record): Cach protected function genericMiss(string $key): CacheItem { - return new CacheItem($this, $key); + CacheInput::key($key); + + return $this->deferredRead($key) ?? new CacheItem($this, $key); } protected function options(): CacheOptions @@ -267,41 +327,90 @@ protected function saveEncoded(CacheItemInterface $item, callable $writer): bool protected function supportsItem(CacheItemInterface $item): bool { - return $item instanceof CacheItem && $item->belongsTo($this); + if (!$this->ownsItem($item)) { + return false; + } + if (!$this->committing) { + $this->discardDeferredKey($item->getKey()); + } + + return true; } /** @param array $items */ protected function supportsItems(array $items): bool { foreach ($items as $item) { - if (!$this->supportsItem($item)) { + if (!$this->ownsItem($item)) { return false; } } + if (!$this->committing) { + foreach ($items as $item) { + $this->discardDeferredKey($item->getKey()); + } + } return true; } + private function deferredKey(string $key): string + { + return "key:\0" . $key; + } + + private function deferredRead(string $key): ?CacheItem + { + $pending = $this->deferred[$this->deferredKey($key)] ?? null; + if (!$pending instanceof CacheItem) { + return null; + } + if (!$pending->isHit()) { + return new CacheItem($this, $key); + } + + return clone $pending; + } + + private function deferredSnapshot(CacheItemInterface $item): CacheItem + { + $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; + $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; + + return (new CacheItem($this, $item->getKey(), $item->get(), true)) + ->expiresAfter($ttl) + ->setTagGenerations($tags); + } + private function genericFromEncodedWithInvalidator( string $key, ?string $encoded, callable $onInvalid, callable $decoder, ): CacheItem { + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } if ($encoded === null) { - return $this->genericMiss($key); + return new CacheItem($this, $key); } $record = $decoder($encoded); if (!$record instanceof CacheRecord) { $onInvalid(); - return $this->genericMiss($key); + return new CacheItem($this, $key); } return $this->genericItemFromRecord($key, $record); } + private function ownsItem(CacheItemInterface $item): bool + { + return $item instanceof CacheItem && $item->belongsTo($this); + } + private function payloadCodec(): CachePayloadCodec { $this->options ??= new CacheOptions(); From 44e395e466f6e6257aad2ddc4aec0f61197029c7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:19:12 +0600 Subject: [PATCH 126/434] fix(psr6): validate cache item keys at construction --- src/Cache/Item/CacheItem.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Cache/Item/CacheItem.php b/src/Cache/Item/CacheItem.php index dce9d16d..3ae2f83c 100644 --- a/src/Cache/Item/CacheItem.php +++ b/src/Cache/Item/CacheItem.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use DateTimeInterface; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; +use Infocyph\CacheLayer\Cache\CacheInput; use Psr\Cache\CacheItemInterface; final class CacheItem implements CacheItemInterface @@ -22,7 +23,9 @@ public function __construct( private readonly bool $hit = false, private ?DateTimeInterface $expiration = null, private array $tags = [], - ) {} + ) { + CacheInput::key($key); + } public function belongsTo(InternalCachePoolInterface $pool): bool { From 1aa03570c5277329a475fe3e654a62b025e4d699 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:19:29 +0600 Subject: [PATCH 127/434] fix(psr6): read through deferred-aware pool boundary --- src/Cache/Cache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index a40d6f4e..13e1041c 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -793,7 +793,7 @@ private function captureTagGenerations(array $tags): ?array */ private function fetchItems(array $keys): array { - return $this->adapter->multiFetch($keys); + return $this->adapter->getItems($keys); } private function jitteredTtl(?int $ttl): ?int From 0f89ba66f5d37728a18381da9cc3949555fdf72e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:19:51 +0600 Subject: [PATCH 128/434] fix(psr6): retain deferred state during commit attempts --- src/Cache/Adapter/AbstractCacheAdapter.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index a3e56e3c..a9947701 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -175,13 +175,22 @@ public function saveDeferred(CacheItemInterface $item): bool protected function discardDeferredKey(string $key): void { CacheInput::key($key); + if ($this->committing) { + return; + } + unset($this->deferred[$this->deferredKey($key)]); } /** @param list $keys */ protected function discardDeferredKeys(array $keys): void { - foreach (CacheInput::keys($keys) as $key) { + $keys = CacheInput::keys($keys); + if ($this->committing) { + return; + } + + foreach ($keys as $key) { unset($this->deferred[$this->deferredKey($key)]); } } From e9272f0e73defa8971a2d229aa447e284d20a8d4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:43:38 +0600 Subject: [PATCH 129/434] docs(plan): sync active Batch 4 tracker --- .../cachelayer-4.0-security-correctness-plan.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 94ee0128..e4b552df 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -16,7 +16,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | -| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | Not started | Ready to start after verified Batch 3 closure. | +| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **In progress** | R08/R11/R12/R13/R16/R17 implementation is active; current QA run #266 exposed deferred/bulk, Memcached TTL, static-analysis, formatting, and Rector regressions that are being resolved before closure. | | 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | @@ -57,6 +57,20 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi **Batch 3 closure evidence:** exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e` passed Security & Standards run #240: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. The run includes real MySQL/PostgreSQL concurrent publication tests, cursor-scope/migration regressions, Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates. All PR code-scanning review threads were resolved on the verified head. + +### Batch 4 tracker + +| Finding | Implementation | Regression evidence | QA state | +| --- | --- | --- | --- | +| R08 — memoizer identity | **Implemented; QA pending** | Callable/value fingerprints are type-tagged; same-line closure identity and lifetime-safe object identity coverage added. | Current Batch 4 gate still failing elsewhere. | +| R11 — deferred PSR-6 lifecycle | **Implemented; QA fixes in progress** | Pending reads, snapshot semantics, overwrite/delete/clear ordering, commit retry/finalization paths are being consolidated at the pool boundary. | Run #266 exposed Node bulk-copy and stale legacy expectation failures. | +| R12 — numeric-string keys/tags | **Implemented broadly; QA fixes in progress** | Logical keys/tags are normalized through batch/tag paths instead of being rejected solely because PHP coerces numeric-string array keys. | Run #266 exposed remaining typing/legacy-test cleanup. | +| R13 — skipped-L1 coherence | **Implemented; QA pending** | Tier writes with disabled L1 write-through invalidate/fence upper-tier state; single and bulk regressions added. | Static complexity cleanup still required. | +| R16 — Memcached >30-day TTL | **Implemented; QA fix in progress** | Shared expiration conversion covers atomic and lease paths with long-TTL regressions. | Run #266 exposed one missed ordinary single-save conversion. | +| R17 — direct PSR contracts | **Implemented broadly; QA fixes in progress** | Direct key validation, deferred visibility, missing-delete and expiration contracts are being aligned across adapters. | Full common-contract gate pending. | + +**Batch 4 current QA evidence:** Security & Standards run #266 reached 296 passing Pest tests but failed seven regressions plus PHPStan/Pint/Rector. These failures are treated as open Batch 4 work; the batch is not closed until an exact-head full gate passes. + ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 05ab683f0019737a5e05e06177d9e241048a2e59 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:44:27 +0600 Subject: [PATCH 130/434] fix(node): copy bulk items by logical key --- src/Node/Adapter/NodeCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index dd4db12f..6003d810 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -428,7 +428,8 @@ private function saveInto( string $failureMetric, ): bool { $targets = []; - foreach ($items as $key => $item) { + foreach ($items as $item) { + $key = $item->getKey(); $target = $pool->createItem($key)->set($item->get()); if ($item instanceof CacheItem) { $target->expiresAfter($item->ttlSeconds()); From 1d0e62db7a2f83fe7918f273e175fdf49d9aa16e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:44:40 +0600 Subject: [PATCH 131/434] fix(memcached): normalize single-item long TTLs --- src/Cache/Adapter/MemcachedCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 4901e7d0..9a931a59 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -304,7 +304,7 @@ public function save(CacheItemInterface $item): bool return $this->client->set( $this->mapData($item->getKey()), $this->encodeItem($item, $expiration['expiresAt'], $this->namespaceGeneration()), - $expiration['ttl'] ?? 0, + MemcachedExpiration::fromRelative($expiration['ttl']), ); } From f230c08c78ed178e7365a33045cabca3d17326ca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:45:06 +0600 Subject: [PATCH 132/434] test(psr6): expect deferred Memcached reads to be visible --- tests/Cache/MemcachedCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index efaa7a64..e2572958 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -88,7 +88,7 @@ test('saveDeferred() + commit()', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); From 3ce3a38ad8f1ab6d10cad5b287fbd0a5bbafb57e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:45:24 +0600 Subject: [PATCH 133/434] test(psr6): expect deferred SQLite reads to be visible --- tests/Cache/SqliteCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 4ebd0046..44b4c325 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -67,7 +67,7 @@ /* ── 3. deferred queue ──────────────────────────────────────────── */ test('saveDeferred() & commit() (sqlite)', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); From 0924eefbf579df2488fd528afd992c1b1d8ac2fa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:45:47 +0600 Subject: [PATCH 134/434] test(cache): align numeric-string bulk key contract --- tests/Cache/ArchitectureHardeningTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Cache/ArchitectureHardeningTest.php b/tests/Cache/ArchitectureHardeningTest.php index fd8737eb..4e204278 100644 --- a/tests/Cache/ArchitectureHardeningTest.php +++ b/tests/Cache/ArchitectureHardeningTest.php @@ -176,9 +176,9 @@ public function resetOperationCounts(): void expect(fn() => $cache->setMultiple(['valid' => 1, 'bad key' => 2])) ->toThrow(CacheInvalidArgumentException::class) ->and($adapter->saveBatches)->toBe(0); - expect(fn() => $cache->setMultiple([1 => 'numeric key'])) - ->toThrow(CacheInvalidArgumentException::class) - ->and($adapter->saveBatches)->toBe(0); + expect($cache->setMultiple(['1' => 'numeric key']))->toBeTrue() + ->and($cache->get('1'))->toBe('numeric key') + ->and($adapter->saveBatches)->toBe(1); expect(fn() => $cache->deleteMultiple(['valid', 'bad:key'])) ->toThrow(CacheInvalidArgumentException::class) ->and($adapter->deleteBatches)->toBe(0); From dc5c1092c2c8ccda167c24627fe6a8f262dc4063 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:46:04 +0600 Subject: [PATCH 135/434] fix(cache): align numeric bulk keys and PSR iterable reads --- src/Cache/Cache.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 13e1041c..753237b3 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -649,9 +649,6 @@ public function setMultiple(iterable $values, mixed $ttl = null): bool { $normalized = []; foreach ($values as $key => $value) { - if (!is_string($key) && !is_int($key)) { - throw new CacheInvalidArgumentException('Cache keys must be strings.'); - } $key = (string) $key; CacheInput::key($key); $normalized[] = [$key, $value]; @@ -793,7 +790,9 @@ private function captureTagGenerations(array $tags): ?array */ private function fetchItems(array $keys): array { - return $this->adapter->getItems($keys); + $items = $this->adapter->getItems($keys); + + return is_array($items) ? $items : iterator_to_array($items); } private function jitteredTtl(?int $ttl): ?int From 977f48876dd4ee5ed3af4833659bc53f918ebfa5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:46:27 +0600 Subject: [PATCH 136/434] fix(cache): type numeric-string tag storage accurately --- src/Cache/CacheRecord.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/CacheRecord.php b/src/Cache/CacheRecord.php index 57931321..cf0be729 100644 --- a/src/Cache/CacheRecord.php +++ b/src/Cache/CacheRecord.php @@ -8,7 +8,7 @@ final readonly class CacheRecord { /** - * @param array $tags + * @param array $tags */ public function __construct( public mixed $value, From f1055f529af498cc3ef6c8e4245af537314f2fa9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:46:38 +0600 Subject: [PATCH 137/434] fix(cache): type numeric-string item tags accurately --- src/Cache/Item/CacheItem.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Cache/Item/CacheItem.php b/src/Cache/Item/CacheItem.php index 3ae2f83c..611728f1 100644 --- a/src/Cache/Item/CacheItem.php +++ b/src/Cache/Item/CacheItem.php @@ -14,7 +14,7 @@ final class CacheItem implements CacheItemInterface { /** - * @param array $tags + * @param array $tags */ public function __construct( private readonly InternalCachePoolInterface $pool, @@ -61,7 +61,7 @@ public function getKey(): string return $this->key; } - /** @return array */ + /** @return array */ public function getTagGenerations(): array { return $this->tags; @@ -95,7 +95,7 @@ public function set(mixed $value): static } /** - * @param array $tags + * @param array $tags */ public function setTagGenerations(array $tags): static { From 1262eaf9902dade78de17f06cc08975e1fe6752b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:46:50 +0600 Subject: [PATCH 138/434] fix(cache): validate numeric-string tag payloads cleanly --- src/Cache/Adapter/CachePayloadCodec.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index c904a26c..4255a1b7 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -80,7 +80,7 @@ public function decode( } /** - * @param array $tags + * @param array $tags */ public function encode( mixed $value, @@ -287,9 +287,8 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return null; } - foreach ($tags as $tag => $generation) { - if ((!is_string($tag) && !is_int($tag)) - || !is_string($generation) + foreach ($tags as $generation) { + if (!is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { return null; From 185d9433a32c5f9e3a63c473ffb299efcbfbb4fe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:48:48 +0600 Subject: [PATCH 139/434] refactor(tiered): simplify L1 coherence fencing --- src/Cache/Adapter/TieredCacheAdapter.php | 268 ++++++++++------------- 1 file changed, 120 insertions(+), 148 deletions(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 9b1f1f30..4b4b6529 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -14,10 +14,7 @@ final class TieredCacheAdapter extends AbstractCacheAdapter { - private bool $bypassUpperTier = false; - - /** @var array */ - private array $bypassedKeys = []; + private bool $l1Readable = true; /** @param list $pools */ public function __construct( @@ -57,14 +54,10 @@ public function clear(): bool $cleared = true; foreach ($this->pools as $index => $pool) { $poolCleared = $pool->clear(); - if ($index === 0 && !$poolCleared) { - $this->bypassUpperTier = true; - } $cleared = $poolCleared && $cleared; - } - if ($cleared) { - $this->bypassUpperTier = false; - $this->bypassedKeys = []; + if ($index === 0) { + $this->l1Readable = $poolCleared; + } } $this->deferred = []; @@ -100,10 +93,10 @@ public function deleteItem(string $key): bool $deleted = true; foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItem($key); + $deleted = $poolDeleted && $deleted; if ($index === 0) { - $this->setUpperTierFence($key, !$poolDeleted); + $this->l1Readable = $poolDeleted; } - $deleted = $poolDeleted && $deleted; } return $deleted; @@ -115,12 +108,10 @@ public function deleteItems(array $keys): bool $deleted = true; foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItems($keys); + $deleted = $poolDeleted && $deleted; if ($index === 0) { - foreach ($keys as $key) { - $this->setUpperTierFence($key, !$poolDeleted); - } + $this->l1Readable = $poolDeleted; } - $deleted = $poolDeleted && $deleted; } return $deleted; @@ -128,21 +119,18 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { - foreach ($this->pools as $index => $pool) { - if ($this->shouldBypass($index, $key)) { - continue; - } - + foreach ($this->readablePools() as $index => $pool) { $item = $pool->getItem($key); if (!$item->isHit()) { continue; } - $out = $this->copyItem($item); + + $copy = $this->copyItem($item); if ($index > 0) { - $this->promoteOne($out, $index); + $this->promoteOne($copy, $index); } - return $out; + return $copy; } return $this->genericMiss($key); @@ -150,7 +138,7 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] public function getTagGenerations(array $tags): array @@ -175,41 +163,16 @@ public function multiFetch(array $keys): array { $remaining = $keys; $results = []; - foreach ($this->pools as $index => $pool) { + foreach ($this->readablePools() as $index => $pool) { if ($remaining === []) { break; } - $wanted = []; - foreach ($remaining as $key) { - if (!$this->shouldBypass($index, $key)) { - $wanted[] = $key; - } - } - if ($wanted === []) { - continue; - } - - $fetched = $pool->multiFetch($wanted); - $hits = []; - $next = []; - foreach ($remaining as $key) { - if ($this->shouldBypass($index, $key)) { - $next[] = $key; - - continue; - } - - $item = $fetched[$key] ?? null; - if (!$item instanceof CacheItemInterface || !$item->isHit()) { - $next[] = $key; - - continue; - } - $hits[$key] = $this->copyItem($item); - $results[$key] = $hits[$key]; + $fetched = $pool->multiFetch($remaining); + [$hits, $remaining] = $this->extractHits($remaining, $fetched); + foreach ($hits as $key => $item) { + $results[$key] = $item; } - $remaining = $next; if ($index > 0 && $hits !== []) { $this->promote($hits, $index); } @@ -241,33 +204,22 @@ public function save(CacheItemInterface $item): bool return false; } + $start = $this->writeStart(); $written = true; - $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; - for ($index = $start, $count = count($this->pools); $index < $count; $index++) { + for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; } - if ($start === 0) { - if ($written) { - $this->setUpperTierFence($item->getKey(), false); - } - - return $written; - } - - $invalidated = $this->pools[0]->deleteItem($item->getKey()); - $this->setUpperTierFence($item->getKey(), !$invalidated); - - return $written && $invalidated; + return $start === 0 + ? $this->finishL1Write($written) + : $this->invalidateSkippedL1([$item->getKey()], $written); } /** @param array $items */ public function saveItems(array $items): bool { - foreach ($items as $item) { - if (!$this->supportsItem($item)) { - return false; - } + if (!$this->supportsItems($items)) { + return false; } return $this->writeBatch($items); @@ -283,65 +235,109 @@ private function copyItem(CacheItemInterface $source): CacheItem ->setTagGenerations($tags); } + /** + * @param list $wanted + * @param array $fetched + * @return array{array, list} + */ + private function extractHits(array $wanted, array $fetched): array + { + $hits = []; + $misses = []; + foreach ($wanted as $key) { + $item = $fetched[$key] ?? null; + if ($item instanceof CacheItemInterface && $item->isHit()) { + $hits[$key] = $this->copyItem($item); + } else { + $misses[] = $key; + } + } + + return [$hits, $misses]; + } + + private function finishL1Write(bool $written): bool + { + if ($written) { + $this->l1Readable = true; + } + + return $written; + } + + /** @param list $keys */ + private function invalidateSkippedL1(array $keys, bool $written): bool + { + if (count($this->pools) === 1) { + return $written; + } + + $invalidated = $this->pools[0]->deleteItems($keys); + $this->l1Readable = $invalidated; + + return $written && $invalidated; + } + /** @param array $items */ private function promote(array $items, int $tierIndex): void { - for ($index = 0; $index < $tierIndex; $index++) { - if ($index === 0 && $this->bypassUpperTier) { - continue; - } + if (!$this->l1Readable) { + return; + } - $promotable = []; - foreach ($items as $item) { - if (!$this->shouldBypass($index, $item->getKey())) { - $promotable[$item->getKey()] = $item; + for ($index = 0; $index < $tierIndex; ++$index) { + if (!$this->saveIntoPool($this->pools[$index], $items)) { + if ($index === 0) { + $this->l1Readable = false; } - } - if ($promotable === []) { - continue; - } - if ($this->saveIntoPool($this->pools[$index], $promotable)) { - $this->metrics->increment(self::class, 'promotion_batch'); - $this->metrics->increment(self::class, 'promotion_keys', count($promotable)); - foreach ($promotable as $item) { - $this->setUpperTierFence($item->getKey(), false); - } - } elseif ($index === 0) { - foreach ($promotable as $item) { - $this->setUpperTierFence($item->getKey(), true); - } + continue; } + $this->metrics->increment(self::class, 'promotion_batch'); + $this->metrics->increment(self::class, 'promotion_keys', count($items)); } } private function promoteOne(CacheItemInterface $item, int $tierIndex): void { - for ($index = 0; $index < $tierIndex; $index++) { - if ($this->shouldBypass($index, $item->getKey())) { - continue; - } + if (!$this->l1Readable) { + return; + } - $stored = $this->saveOneIntoPool($this->pools[$index], $item); - if ($index === 0) { - $this->setUpperTierFence($item->getKey(), !$stored); + for ($index = 0; $index < $tierIndex; ++$index) { + if (!$this->saveOneIntoPool($this->pools[$index], $item) && $index === 0) { + $this->l1Readable = false; + + return; } } } + /** @return array */ + private function readablePools(): array + { + if ($this->l1Readable || count($this->pools) === 1) { + return $this->pools; + } + + $pools = $this->pools; + unset($pools[0]); + + return $pools; + } + /** @param array $items */ private function saveIntoPool(InternalCachePoolInterface $pool, array $items): bool { $targets = []; foreach ($items as $item) { $key = $item->getKey(); - $target = $pool->createItem($key); - $target->set($item->get()); - $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + $target = $pool->createItem($key)->set($item->get()); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); + $target->expiresAfter($item->ttlSeconds()) + ->setTagGenerations($item->getTagGenerations()); } - $targets[$key] = $target; + $targets["key:\0" . $key] = $target; } return $pool->saveItems($targets); @@ -349,62 +345,38 @@ private function saveIntoPool(InternalCachePoolInterface $pool, array $items): b private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInterface $item): bool { - $target = $pool->createItem($item->getKey()); - $target->set($item->get()); - $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + $target = $pool->createItem($item->getKey())->set($item->get()); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); + $target->expiresAfter($item->ttlSeconds()) + ->setTagGenerations($item->getTagGenerations()); } return $pool->save($target); } - private function setUpperTierFence(string $key, bool $fenced): void - { - $identity = hash('xxh128', $key); - if ($fenced) { - $this->bypassedKeys[$identity] = true; - - return; - } - - unset($this->bypassedKeys[$identity]); - } - - private function shouldBypass(int $tierIndex, string $key): bool - { - return $tierIndex === 0 - && ($this->bypassUpperTier || isset($this->bypassedKeys[hash('xxh128', $key)])); - } - /** @param array $items */ private function writeBatch(array $items): bool { + $start = $this->writeStart(); $written = true; - $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; - for ($index = $start, $count = count($this->pools); $index < $count; $index++) { + for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { $written = $this->saveIntoPool($this->pools[$index], $items) && $written; } if ($start === 0) { - if ($written) { - foreach ($items as $item) { - $this->setUpperTierFence($item->getKey(), false); - } - } - - return $written; + return $this->finishL1Write($written); } - $keys = []; - foreach ($items as $item) { - $keys[] = $item->getKey(); - } - $invalidated = $this->pools[0]->deleteItems($keys); - foreach ($keys as $key) { - $this->setUpperTierFence($key, !$invalidated); - } + $keys = array_map( + static fn(CacheItemInterface $item): string => $item->getKey(), + array_values($items), + ); - return $written && $invalidated; + return $this->invalidateSkippedL1($keys, $written); + } + + private function writeStart(): int + { + return $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; } } From 782faf8c343c6807c1a9d585a65aa875e8cb9fcb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:49:15 +0600 Subject: [PATCH 140/434] style(memcached): remove redundant expiration cast --- src/Cache/Adapter/MemcachedCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 9a931a59..e0cf5d95 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -336,7 +336,7 @@ public function saveItems(array $items): bool return false; } foreach ($groups as $memcachedExpiration => $records) { - if (!$this->client->setMulti($records, (int) $memcachedExpiration)) { + if (!$this->client->setMulti($records, $memcachedExpiration)) { return false; } } From 8f7dbb616ee11463be4c289559ccc5241c199190 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:51:15 +0600 Subject: [PATCH 141/434] style(cache): order deferred pool elements for PHPForge --- src/Cache/Adapter/AbstractCacheAdapter.php | 64 +++++++++++----------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index a9947701..a3288180 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -28,15 +28,6 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC private ?string $storageIdentity = null; - /** - * @param list $keys - * @return array - */ - abstract public function multiFetch(array $keys): array; - - /** @param array $items */ - abstract public function saveItems(array $items): bool; - public function __destruct() { if ($this->deferred === []) { @@ -49,6 +40,15 @@ public function __destruct() } } + /** + * @param list $keys + * @return array + */ + abstract public function multiFetch(array $keys): array; + + /** @param array $items */ + abstract public function saveItems(array $items): bool; + /** @internal */ public function assertOptionsCompatible(CacheOptions $options): void { @@ -172,29 +172,6 @@ public function saveDeferred(CacheItemInterface $item): bool return true; } - protected function discardDeferredKey(string $key): void - { - CacheInput::key($key); - if ($this->committing) { - return; - } - - unset($this->deferred[$this->deferredKey($key)]); - } - - /** @param list $keys */ - protected function discardDeferredKeys(array $keys): void - { - $keys = CacheInput::keys($keys); - if ($this->committing) { - return; - } - - foreach ($keys as $key) { - unset($this->deferred[$this->deferredKey($key)]); - } - } - protected static function isGeneration(mixed $value): bool { return is_string($value) && strlen($value) === 32 && ctype_xdigit($value); @@ -230,6 +207,29 @@ protected function decodeRecordFromBlob(string $blob, ?string $key = null): ?Cac : null; } + protected function discardDeferredKey(string $key): void + { + CacheInput::key($key); + if ($this->committing) { + return; + } + + unset($this->deferred[$this->deferredKey($key)]); + } + + /** @param list $keys */ + protected function discardDeferredKeys(array $keys): void + { + $keys = CacheInput::keys($keys); + if ($this->committing) { + return; + } + + foreach ($keys as $key) { + unset($this->deferred[$this->deferredKey($key)]); + } + } + protected function encodeItem( CacheItemInterface $item, ?int $expiresAt, From 5fa5875c6dba82731f49c878c5e56f98641ae2b7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:53:28 +0600 Subject: [PATCH 142/434] test(psr6): align deferred read visibility --- tests/Cache/ApcuCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 965da839..56c0878e 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -69,7 +69,7 @@ /* ─── deferred queue ──────────────────────────────────────────────── */ test('saveDeferred() and commit() (apcu)', function () { $this->cache->getItem('x')->set('X')->saveDeferred(); - expect($this->cache->get('x'))->toBeNull(); + expect($this->cache->get('x'))->toBe('X'); $this->cache->commit(); expect($this->cache->get('x'))->toBe('X'); From 48fd8fbe541072a419fcc4cbb8fa79e98a23c61d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:53:32 +0600 Subject: [PATCH 143/434] test(psr6): align deferred read visibility --- tests/Cache/RedisCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index bd69d5c9..69a4187a 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -108,7 +108,7 @@ /* ── 3. deferred queue ──────────────────────────────────────────── */ test('saveDeferred() & commit() (redis)', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); From 6dae4bf4324ef1b59be2fe247f7b03e1a0bf80e0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:53:38 +0600 Subject: [PATCH 144/434] test(psr6): align deferred read visibility --- tests/Cache/FileCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index fd4bf786..18d1ca4f 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -76,7 +76,7 @@ $this->cache->getItem('a')->set('A')->saveDeferred(); $this->cache->getItem('b')->set('B')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); // not yet persisted + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); From 69590d63303f3da8dbc83d67176e94057c7a2c22 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:55:29 +0600 Subject: [PATCH 145/434] fix(cache): type weak-map numeric tag snapshots --- src/Cache/Adapter/WeakMapCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index 1624cc5c..2fa9897b 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -23,7 +23,7 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter implements AtomicCa /** @var array> */ private array $weakRefs = []; - /** @var array> */ + /** @var array> */ private array $weakTags = []; public function __construct(string $namespace = 'default') From 100afccf8934786113fd5074bbac1bb19c2ee9d8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:55:41 +0600 Subject: [PATCH 146/434] fix(cache): normalize iterable reads and numeric tag keys --- src/Cache/Cache.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 753237b3..08f064da 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -790,9 +790,10 @@ private function captureTagGenerations(array $tags): ?array */ private function fetchItems(array $keys): array { - $items = $this->adapter->getItems($keys); + /** @var array $items */ + $items = [...$this->adapter->getItems($keys)]; - return is_array($items) ? $items : iterator_to_array($items); + return $items; } private function jitteredTtl(?int $ttl): ?int @@ -894,7 +895,10 @@ private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterfa if (!$item instanceof CacheItem || !$item->isHit() || $item->getTagGenerations() === []) { return $item; } - $tags = array_keys($item->getTagGenerations()); + $tags = array_map( + static fn(int|string $tag): string => (string) $tag, + array_keys($item->getTagGenerations()), + ); $generations = $this->backend( fn(): array => $this->adapter->getTagGenerations($tags), null, From 08fd2c064689c1396ed09ed19bdbaed857611ba8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:18 +0600 Subject: [PATCH 147/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/ApcuCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 1d90dc34..5c0e0dc7 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -51,6 +51,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); if (!apcu_exists($mapped)) { return true; @@ -65,6 +66,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } From 17ee203fdd2e96841c145fdd8f60ad02ac9612be Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:22 +0600 Subject: [PATCH 148/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/ArrayCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 3e37b499..0be784c7 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -94,6 +94,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); unset($this->store[$this->map($key)]); return true; @@ -105,6 +106,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($keys as $key) { unset($this->store[$this->map($key)]); } From e8a4de76707907f65171533e7ec3501de7cbb836 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:26 +0600 Subject: [PATCH 149/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/FileCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 6d04b741..7d35ebf8 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -97,12 +97,14 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $ok = true; foreach ($keys as $k) { $ok = $this->deleteItem($k) && $ok; From 10261c2deb45681649527cfe0f3c15447e6c41e7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:30 +0600 Subject: [PATCH 150/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/MemcachedCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index e0cf5d95..e395e037 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -157,6 +157,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->client->delete($this->mapData($key)); return !in_array( @@ -169,6 +170,7 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } From b8a60a5838e2646c3f17b2d5377769b21763b708 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:35 +0600 Subject: [PATCH 151/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/MongoDbCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index a7c422fa..c725a7e5 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -140,6 +140,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->collection->deleteOne(['_id' => $this->mapData($key)]); return true; @@ -151,6 +152,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys !== []) { $this->collection->deleteMany([ '_id' => ['$in' => array_map($this->mapData(...), $keys)], From 0fd2c771c2382fff34ca4abcda5178777e8cfbe3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:39 +0600 Subject: [PATCH 152/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/NullCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index 6b950729..ebb71107 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -18,6 +18,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); unset($key); return true; @@ -29,6 +30,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); unset($keys); return true; From a814a1aa99efb1fa43edf65bd3b626ed9c5a53e2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:42 +0600 Subject: [PATCH 153/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/PdoCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index af860f2d..b0edf7e0 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -101,6 +101,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $statement = $this->pdo->prepare( "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", ); @@ -111,6 +112,7 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); return $this->deleteByKind(self::KIND_DATA, $keys); } From 8db024f4729ad927b533d6f6f8102f8d1ce29974 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:46 +0600 Subject: [PATCH 154/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index d9083989..6c3bf22d 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -98,12 +98,14 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $ok = true; foreach ($keys as $key) { $ok = $this->deleteItem($key) && $ok; From 60a91fb2ce76c20dbba841443bfc8ba0a11b16d9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:51 +0600 Subject: [PATCH 155/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/RedisCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index d008b86e..7e65f000 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -197,6 +197,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); return $this->redis->del($this->map($key)) !== false; } @@ -206,6 +207,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } From f4197f4770edbe9d728433ef237112818d0403e3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:57:56 +0600 Subject: [PATCH 156/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index f739adff..02f69fe0 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -60,12 +60,14 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); return $this->call('del', $this->mapData($key)) !== false; } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($this->groupByBucket($keys) as $group) { if ($this->call('del', array_map($this->mapData(...), $group)) === false) { return false; From 7144f84fd44220c2834e1bfdb0f1dc3c8ac4f489 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:17 +0600 Subject: [PATCH 157/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 1af3326f..718a067b 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -66,6 +66,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->executeCql( "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ? AND ckey = ?", [$this->ns, $this->bucket($key), $this->mapData($key)], @@ -80,6 +81,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($this->groupByBucket($keys) as $bucket => $group) { $marks = implode(',', array_fill(0, count($group), '?')); $this->executeCql( From 6282285d66915071307c34355337f6df7a5b4d5d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:21 +0600 Subject: [PATCH 158/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 5749f13c..7a6b990b 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -148,6 +148,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); return $this->withExclusiveLock(function () use ($mapped): bool { @@ -164,6 +165,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $mappedKeys = []; foreach ($keys as $key) { $mappedKeys[] = $this->map($key); From 2f243226f25e9f304e28f1941a203b25b404e8a3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:25 +0600 Subject: [PATCH 159/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/TieredCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 4b4b6529..f0744dfa 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -90,6 +90,7 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $deleted = true; foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItem($key); @@ -105,6 +106,7 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $deleted = true; foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItems($keys); From 1254f025322a32fc877dd8f907107085af1bfd92 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:29 +0600 Subject: [PATCH 160/434] fix(psr6): cancel deferred state on delete --- src/Cache/Adapter/WeakMapCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index 2fa9897b..785b24fa 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -97,6 +97,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); unset($this->scalarStore[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); @@ -111,6 +112,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($keys as $key) { $this->deleteItem((string) $key); } From fd017df2a67b37503c65f1eec63a9f6941032efe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:33 +0600 Subject: [PATCH 161/434] fix(psr6): cancel deferred state on delete --- src/Node/Adapter/NodeCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 6003d810..5b5a4a2a 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -82,6 +82,7 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $l2 = $this->attempt(fn(): bool => $this->l2->deleteItem($key), false, 'l2_failure'); $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItem($key), false, 'l1_failure'); @@ -95,6 +96,7 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $l2 = $this->attempt(fn(): bool => $this->l2->deleteItems($keys), false, 'l2_failure'); $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItems($keys), false, 'l1_failure'); From f902b2eb7f314714f8c3593f1c9e1213af934939 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 19:58:38 +0600 Subject: [PATCH 162/434] fix(psr6): cancel deferred state on delete --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index afe8daf6..a5132527 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -84,6 +84,7 @@ public function connection(): PDO public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->assertWritableTransaction(); try { @@ -102,6 +103,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } From 38e5234d6ca652adf90f9f3a72001c64dc5e4f81 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:02:59 +0600 Subject: [PATCH 163/434] fix(psr6): expose pending reads to composed pools --- src/Cache/Adapter/AbstractCacheAdapter.php | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index a3288180..c99192c9 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -218,6 +218,19 @@ protected function discardDeferredKey(string $key): void } /** @param list $keys */ + protected function deferredRead(string $key): ?CacheItem + { + $pending = $this->deferred[$this->deferredKey($key)] ?? null; + if (!$pending instanceof CacheItem) { + return null; + } + if (!$pending->isHit()) { + return new CacheItem($this, $key); + } + + return clone $pending; + } + protected function discardDeferredKeys(array $keys): void { $keys = CacheInput::keys($keys); @@ -368,19 +381,6 @@ private function deferredKey(string $key): string return "key:\0" . $key; } - private function deferredRead(string $key): ?CacheItem - { - $pending = $this->deferred[$this->deferredKey($key)] ?? null; - if (!$pending instanceof CacheItem) { - return null; - } - if (!$pending->isHit()) { - return new CacheItem($this, $key); - } - - return clone $pending; - } - private function deferredSnapshot(CacheItemInterface $item): CacheItem { $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; From c66cb6c575b05b2a15203efd691d253376464fdd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:03:12 +0600 Subject: [PATCH 164/434] fix(psr6): prioritize deferred reads in composed pools --- src/Node/Adapter/NodeCacheAdapter.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 5b5a4a2a..55dd0a3f 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -109,6 +109,11 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + if ($this->l1 !== null && $this->l1Readable) { $l1 = $this->attempt( fn(): CacheItemInterface => $this->l1->getItem($key), From e006bf0b299192864e877b88dda16e5639848178 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:03:17 +0600 Subject: [PATCH 165/434] fix(psr6): prioritize deferred reads in composed pools --- src/Cache/Adapter/TieredCacheAdapter.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index f0744dfa..fc708256 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -121,6 +121,11 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + foreach ($this->readablePools() as $index => $pool) { $item = $pool->getItem($key); if (!$item->isHit()) { From c21942eb877c2d3502b2eff1f34c47440af5d563 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:03:42 +0600 Subject: [PATCH 166/434] test(psr6): cover deferred ordering and direct pool contracts --- tests/Cache/ArrayCachePoolTest.php | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php index 8b8b70a0..022f1961 100644 --- a/tests/Cache/ArrayCachePoolTest.php +++ b/tests/Cache/ArrayCachePoolTest.php @@ -4,6 +4,8 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; +use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; @@ -112,3 +114,62 @@ public function saveItems(array $items): bool ->and($adapter->commit())->toBeTrue() ->and($adapter->bulkCalls)->toBe(2); }); + + +test('deferred state obeys PSR ordering and snapshot semantics', function () { + $cache = Cache::memory('deferred-contract'); + $cache->set('overwrite', 'stored'); + $queued = $cache->getItem('overwrite')->set('queued'); + expect($cache->saveDeferred($queued))->toBeTrue() + ->and($cache->get('overwrite'))->toBe('queued') + ->and($cache->hasItem('overwrite'))->toBeTrue() + ->and($cache->getItems(['overwrite'])['overwrite']->get())->toBe('queued'); + + $queued->set('mutated-after-queue'); + expect($cache->get('overwrite'))->toBe('queued'); + + expect($cache->set('overwrite', 'immediate'))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('overwrite'))->toBe('immediate'); + + $delete = $cache->getItem('delete')->set('queued-delete'); + expect($cache->saveDeferred($delete))->toBeTrue() + ->and($cache->delete('delete'))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('delete'))->toBeNull(); + + $clear = $cache->getItem('clear')->set('queued-clear'); + expect($cache->saveDeferred($clear))->toBeTrue() + ->and($cache->clear())->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('clear'))->toBeNull(); +}); + +test('deferred null and expired values retain hit and expiry semantics', function () { + $cache = Cache::memory('deferred-values'); + + $null = $cache->getItem('null')->set(null); + expect($cache->saveDeferred($null))->toBeTrue() + ->and($cache->hasItem('null'))->toBeTrue() + ->and($cache->get('null', 'fallback'))->toBeNull(); + + $expired = $cache->getItem('expired')->set('gone')->expiresAfter(-1); + expect($cache->saveDeferred($expired))->toBeTrue() + ->and($cache->hasItem('expired'))->toBeFalse() + ->and($cache->get('expired'))->toBeNull() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('expired'))->toBeNull(); +}); + +test('direct PSR pool rejects invalid keys and missing deletes succeed', function () { + $pool = new ArrayCacheAdapter('direct-contract'); + $pool->save($pool->getItem('valid')->set('value')); + + expect(fn() => $pool->getItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->hasItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->deleteItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->deleteItems(['valid', 'bad:key']))->toThrow(CacheInvalidArgumentException::class) + ->and($pool->getItem('valid')->get())->toBe('value') + ->and($pool->deleteItem('missing'))->toBeTrue() + ->and($pool->deleteItems(['missing-a', 'missing-b']))->toBeTrue(); +}); From eac3f36a76617639fba4428e0b7cf013af84e08d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:04:07 +0600 Subject: [PATCH 167/434] fix(psr6): make array hasItem deferred-aware --- src/Cache/Adapter/ArrayCacheAdapter.php | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 0be784c7..40061858 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -144,20 +144,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - $mapped = $this->map($key); - $blob = $this->store[$mapped] ?? null; - if (!is_string($blob)) { - return false; - } - - $record = $this->decodeRecordFromBlob($blob, $key); - if ($record === null) { - unset($this->store[$mapped]); - - return false; - } - - return true; + return $this->getItem($key)->isHit(); } /** From 6e684f0ba244be3c92bed277deddb47df7b959d3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:05:17 +0600 Subject: [PATCH 168/434] fix(psr6): align null pool deferred read semantics --- src/Cache/Adapter/NullCacheAdapter.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index ebb71107..d34b7a30 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -38,7 +38,7 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ @@ -55,9 +55,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - unset($key); - - return false; + return $this->getItem($key)->isHit(); } /** @@ -69,7 +67,7 @@ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[$key] = new CacheItem($this, $key); + $items[$key] = $this->genericMiss($key); } return $items; From 1d2ee66289c230ea383163cce12cded574228ce5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:08:34 +0600 Subject: [PATCH 169/434] fix(qa): align deferred helper annotations and order --- src/Cache/Adapter/AbstractCacheAdapter.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index c99192c9..3c13cac9 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -207,17 +207,6 @@ protected function decodeRecordFromBlob(string $blob, ?string $key = null): ?Cac : null; } - protected function discardDeferredKey(string $key): void - { - CacheInput::key($key); - if ($this->committing) { - return; - } - - unset($this->deferred[$this->deferredKey($key)]); - } - - /** @param list $keys */ protected function deferredRead(string $key): ?CacheItem { $pending = $this->deferred[$this->deferredKey($key)] ?? null; @@ -231,6 +220,17 @@ protected function deferredRead(string $key): ?CacheItem return clone $pending; } + protected function discardDeferredKey(string $key): void + { + CacheInput::key($key); + if ($this->committing) { + return; + } + + unset($this->deferred[$this->deferredKey($key)]); + } + + /** @param list $keys */ protected function discardDeferredKeys(array $keys): void { $keys = CacheInput::keys($keys); From 49fa34eea8080defe34d8a81bdaa1a7be18ba067 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:13:07 +0600 Subject: [PATCH 170/434] fix(psr6): overlay deferred state on single reads --- src/Cache/Adapter/ApcuCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 5c0e0dc7..86b308a5 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -89,7 +89,7 @@ public function getItem(string $key): CacheItem apcu_delete($apcuKey); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ From bc8394f16e6fd7c16c0737d859a926613828a2ff Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:13:25 +0600 Subject: [PATCH 171/434] fix(psr6): overlay deferred state on single reads --- src/Cache/Adapter/FileCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 7d35ebf8..393268d4 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -120,7 +120,7 @@ public function getItem(string $key): CacheItem return $this->genericItemFromRecord($key, $record); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** From b81921f9328e57702e727f73c42029c803bbc93e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:13:30 +0600 Subject: [PATCH 172/434] fix(psr6): overlay deferred state on single reads --- src/Cache/Adapter/RedisCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 7e65f000..fdb13fce 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -233,7 +233,7 @@ public function getItem(string $key): CacheItem $this->redis->del($this->map($key)); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ From e14f278c5aec2e0368ea04469d9a58c8daf67787 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:13:34 +0600 Subject: [PATCH 173/434] fix(psr6): overlay deferred state on single reads --- src/Cache/Adapter/WeakMapCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index 785b24fa..8513b74f 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -145,7 +145,7 @@ public function getItem(string $key): CacheItem } if (!isset($this->scalarStore[$mapped])) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } return $this->genericFromBlobWithInvalidator( From e0b553fb97f599f039ebecdbeff5979f5c749eda Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:13:41 +0600 Subject: [PATCH 174/434] fix(psr6): overlay deferred state on single reads --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index a5132527..deffaa45 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -137,12 +137,12 @@ public function getItem(string $key): CacheItem } if (!is_array($row) || !is_string($row['payload'] ?? null)) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } $record = $this->decodeRecordFromBlob($row['payload'], $key); if ($record === null) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } return $this->genericItemFromRecord($key, $record); From ed169ec4d69034d20971af074577e7a392ad4990 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:02 +0600 Subject: [PATCH 175/434] fix(cache): index bulk reads by logical item identity --- src/Cache/Cache.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 08f064da..15ec4bc3 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -418,9 +418,15 @@ public function getItems(array $keys = []): array } $fetched = $this->backend(fn(): array => $this->fetchItems($keys), []); + $byIdentity = []; + foreach ($fetched as $item) { + if ($item instanceof CacheItemInterface) { + $byIdentity["key:\0" . $item->getKey()] = $item; + } + } $items = []; foreach ($keys as $key) { - $item = $fetched[$key] ?? null; + $item = $byIdentity["key:\0" . $key] ?? null; $items[$key] = $item instanceof CacheItemInterface ? $item : $this->miss($key); } $items = $this->validateTagSnapshots($items); @@ -786,14 +792,13 @@ private function captureTagGenerations(array $tags): ?array /** * @param list $keys - * @return array + * @return list */ private function fetchItems(array $keys): array { - /** @var array $items */ - $items = [...$this->adapter->getItems($keys)]; + $items = $this->adapter->getItems($keys); - return $items; + return array_values(is_array($items) ? $items : iterator_to_array($items)); } private function jitteredTtl(?int $ttl): ?int From 8074e0df30f4171330ce13c02bda6ed78825dc32 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:16 +0600 Subject: [PATCH 176/434] style(psr6): separate deferred reconciliation --- src/Cache/Adapter/FileCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 393268d4..a8100b61 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -98,6 +98,7 @@ public function clear(): bool public function deleteItem(string $key): bool { $this->discardDeferredKey($key); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } @@ -105,6 +106,7 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); + $ok = true; foreach ($keys as $k) { $ok = $this->deleteItem($k) && $ok; From e9c1d30c4d1674e839fd8a61b44d9cd172782b89 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:23 +0600 Subject: [PATCH 177/434] style(psr6): separate deferred reconciliation --- src/Cache/Adapter/PdoCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index b0edf7e0..5175c09a 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -102,6 +102,7 @@ public function clear(): bool public function deleteItem(string $key): bool { $this->discardDeferredKey($key); + $statement = $this->pdo->prepare( "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", ); From bf2922012770344143132bc253cd639f9e0a321f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:31 +0600 Subject: [PATCH 178/434] style(psr6): separate deferred reconciliation --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 6c3bf22d..7366498b 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -99,6 +99,7 @@ public function clear(): bool public function deleteItem(string $key): bool { $this->discardDeferredKey($key); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } @@ -106,6 +107,7 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); + $ok = true; foreach ($keys as $key) { $ok = $this->deleteItem($key) && $ok; From 1da563969d48078df838198ffa1d2f0b81fcaa31 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:35 +0600 Subject: [PATCH 179/434] style(psr6): separate deferred reconciliation --- src/Cache/Adapter/RedisCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index fdb13fce..2cc16e90 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -198,6 +198,7 @@ public function clear(): bool public function deleteItem(string $key): bool { $this->discardDeferredKey($key); + return $this->redis->del($this->map($key)) !== false; } @@ -208,6 +209,7 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); + if ($keys === []) { return true; } From d755f895e172131ffce0c9155565ed5b4f22863b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:14:41 +0600 Subject: [PATCH 180/434] style(psr6): separate deferred reconciliation --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 02f69fe0..d93f91cb 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -61,6 +61,7 @@ public function clear(): bool public function deleteItem(string $key): bool { $this->discardDeferredKey($key); + return $this->call('del', $this->mapData($key)) !== false; } From b25331c3bf9ab3fd8e9d0f589018b53c5ac1af70 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:30:49 +0600 Subject: [PATCH 181/434] fix(psr6): type internal bulk item reads --- src/Cache/Adapter/InternalCachePoolInterface.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Cache/Adapter/InternalCachePoolInterface.php b/src/Cache/Adapter/InternalCachePoolInterface.php index fb90b2c7..02635a92 100644 --- a/src/Cache/Adapter/InternalCachePoolInterface.php +++ b/src/Cache/Adapter/InternalCachePoolInterface.php @@ -16,6 +16,12 @@ interface InternalCachePoolInterface extends CacheItemPoolInterface { public function createItem(string $key): CacheItemInterface; + /** + * @param list $keys + * @return iterable + */ + public function getItems(array $keys = []): iterable; + /** * @param list $tags * @return array From f0f60cdc69c6e1f0bd20b0ac31185be2f75eb405 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:30:53 +0600 Subject: [PATCH 182/434] fix(cache): simplify typed bulk item normalization --- src/Cache/Cache.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 15ec4bc3..8d07b01f 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -420,14 +420,11 @@ public function getItems(array $keys = []): array $fetched = $this->backend(fn(): array => $this->fetchItems($keys), []); $byIdentity = []; foreach ($fetched as $item) { - if ($item instanceof CacheItemInterface) { - $byIdentity["key:\0" . $item->getKey()] = $item; - } + $byIdentity["key:\0" . $item->getKey()] = $item; } $items = []; foreach ($keys as $key) { - $item = $byIdentity["key:\0" . $key] ?? null; - $items[$key] = $item instanceof CacheItemInterface ? $item : $this->miss($key); + $items[$key] = $byIdentity["key:\0" . $key] ?? $this->miss($key); } $items = $this->validateTagSnapshots($items); $hits = 0; @@ -797,8 +794,10 @@ private function captureTagGenerations(array $tags): ?array private function fetchItems(array $keys): array { $items = $this->adapter->getItems($keys); + /** @var list $normalized */ + $normalized = array_values(is_array($items) ? $items : iterator_to_array($items)); - return array_values(is_array($items) ? $items : iterator_to_array($items)); + return $normalized; } private function jitteredTtl(?int $ttl): ?int From 02078be9e29876fd74d8cbd2fa6947e247cb8bc1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:30:57 +0600 Subject: [PATCH 183/434] style(pdo): satisfy PHPForge statement spacing --- src/Cache/Adapter/PdoCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 5175c09a..9bcc04bb 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -114,6 +114,7 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); + return $this->deleteByKind(self::KIND_DATA, $keys); } From e473da2c064a77b23a6cffe78ee3b3f2a1b222c7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:33:55 +0600 Subject: [PATCH 184/434] docs(plan): close Batch 4 and start Batch 5 --- .../cachelayer-4.0-security-correctness-plan.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index e4b552df..0be9dc8c 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -16,8 +16,8 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | -| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **In progress** | R08/R11/R12/R13/R16/R17 implementation is active; current QA run #266 exposed deferred/bulk, Memcached TTL, static-analysis, formatting, and Rector regressions that are being resolved before closure. | -| 5 — Counters and backend races | R14 plus race review | Not started | Pending prior batches. | +| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | +| 5 — Counters and backend races | R14 plus race review | **In progress** | Counter keyspace/precision work and deterministic backend race review are active after verified Batch 4 closure. | | 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | ### Batch 1 tracker @@ -71,6 +71,17 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi **Batch 4 current QA evidence:** Security & Standards run #266 reached 296 passing Pest tests but failed seven regressions plus PHPStan/Pint/Rector. These failures are treated as open Batch 4 work; the batch is not closed until an exact-head full gate passes. + +**Batch 4 closure evidence:** exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1` passed Security & Standards run #312: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed. Deferred PSR-6 state is visible before commit, immediate writes/deletes/clear reconcile queued state, numeric-string key/tag handling no longer relies on PHP array identity, Tiered/Node bulk copies use logical item keys, skipped-L1 writes fence stale upper-tier data, Memcached long TTLs are normalized, and direct-pool contract regressions are covered. + +### Batch 5 tracker + +| Finding | Implementation | Regression evidence | QA state | +| --- | --- | --- | --- | +| R14 — Redis/Valkey counter isolation and exact integers | **In progress** | Separate counter keyspace, exact Lua-return parsing, overflow/malformed handling, TTL/decrement/concurrency coverage to be completed. | Pending Batch 5 gate. | +| Backend race review | **In progress** | Stale-read cleanup, tag initialization, clear/write-consume, lease loss, partial bulk failure, and backend false/error handling are being reviewed with deterministic tests where the race is actionable. | Pending Batch 5 gate. | + + ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 910f4a89add967aec210da1c405a854b8b7f5eac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:35:59 +0600 Subject: [PATCH 185/434] fix(counter): isolate keyspace and preserve exact integers --- src/Counter/RedisAtomicCounterStore.php | 57 ++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/src/Counter/RedisAtomicCounterStore.php b/src/Counter/RedisAtomicCounterStore.php index 0aace255..2b471912 100644 --- a/src/Counter/RedisAtomicCounterStore.php +++ b/src/Counter/RedisAtomicCounterStore.php @@ -10,13 +10,16 @@ final readonly class RedisAtomicCounterStore implements AtomicCounterStoreInterface { + private const string COUNTER_PREFIX = 'cachelayer:counter:'; + private const string INCREMENT_SCRIPT = <<<'LUA' -local exists = redis.call('EXISTS', KEYS[1]) -local value = redis.call('INCRBY', KEYS[1], ARGV[1]) -if exists == 0 and tonumber(ARGV[2]) > 0 then +local existed = redis.call('EXISTS', KEYS[1]) +redis.call('INCRBY', KEYS[1], ARGV[1]) +if existed == 0 and tonumber(ARGV[2]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end -return { value, exists == 0 and 1 or 0 } +local value = redis.call('GET', KEYS[1]) +return { value, existed == 0 and '1' or '0' } LUA; private string $namespace; @@ -57,11 +60,11 @@ public function get(string $key): ?int return null; } - if (!is_string($value) || !preg_match('/^-?\d+$/D', $value)) { + if (!is_string($value)) { throw new AtomicCounterException('Atomic counter contains a non-integer value.'); } - return (int) $value; + return $this->parseInteger($value); } public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): AtomicCounterValue @@ -76,12 +79,27 @@ public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): At private function change(string $key, int $by, ?int $ttlSeconds): AtomicCounterValue { $ttl = $this->normalizeTtl($ttlSeconds); - $result = $this->client->eval(self::INCREMENT_SCRIPT, [$this->map($key), (string) $by, (string) $ttl], 1); - if (!is_array($result) || !isset($result[0], $result[1]) || !is_numeric($result[0]) || !is_numeric($result[1])) { + + try { + $result = $this->client->eval( + self::INCREMENT_SCRIPT, + [$this->map($key), (string) $by, (string) $ttl], + 1, + ); + } catch (\RedisException $failure) { + throw new AtomicCounterException('Unable to update atomic counter.', 0, $failure); + } + + if (!is_array($result) || !isset($result[0], $result[1]) || !is_string($result[0])) { throw new AtomicCounterException('Unable to update atomic counter.'); } + $initialized = match ($result[1]) { + 1, '1' => true, + 0, '0' => false, + default => throw new AtomicCounterException('Unable to update atomic counter.'), + }; - return new AtomicCounterValue((int) $result[0], (int) $result[1] === 1); + return new AtomicCounterValue($this->parseInteger($result[0]), $initialized); } private function map(string $key): string @@ -92,7 +110,26 @@ private function map(string $key): string throw new AtomicCounterException($failure->getMessage(), 0, $failure); } - return $this->namespace . ':counter:' . $key; + return self::COUNTER_PREFIX . $this->namespace . ':' . $key; + } + + + private function parseInteger(string $value): int + { + if (preg_match('/^-?\d+$/D', $value) !== 1) { + throw new AtomicCounterException('Atomic counter contains a non-integer value.'); + } + + $negative = str_starts_with($value, '-'); + $digits = ltrim($negative ? substr($value, 1) : $value, '0'); + $digits = $digits === '' ? '0' : $digits; + $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX; + if (strlen($digits) > strlen($limit) + || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new AtomicCounterException('Atomic counter value is outside the PHP integer range.'); + } + + return (int) (($negative && $digits !== '0' ? '-' : '') . $digits); } private function normalizeTtl(?int $ttlSeconds): int From 670240a538c728d4701ded112ec9ceb96c4a2ec9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:38:17 +0600 Subject: [PATCH 186/434] feat(redis): guard stale cleanup against concurrent replacement --- src/Support/RedisValueGuard.php | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Support/RedisValueGuard.php diff --git a/src/Support/RedisValueGuard.php b/src/Support/RedisValueGuard.php new file mode 100644 index 00000000..6e424cdf --- /dev/null +++ b/src/Support/RedisValueGuard.php @@ -0,0 +1,49 @@ +eval(self::DELETE_IF_UNCHANGED_SCRIPT, [$key, $observed], 1) === 1; + } + + public static function replaceIfUnchanged( + \Redis $client, + string $key, + string $observed, + string $replacement, + ): string|false { + $result = $client->eval( + self::REPLACE_IF_UNCHANGED_SCRIPT, + [$key, $observed, $replacement], + 1, + ); + + return is_string($result) ? $result : false; + } +} From 5cb513697d36d3bb05f436df92c1526ed1e2931e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:38:36 +0600 Subject: [PATCH 187/434] fix(redis): make stale cleanup and tag repair compare-safe --- src/Cache/Adapter/RedisCacheAdapter.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 2cc16e90..cbc51491 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -9,6 +9,7 @@ use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Support\RedisConnection; +use Infocyph\CacheLayer\Support\RedisValueGuard; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -304,13 +305,13 @@ public function multiFetch(array $keys): array continue; } - $stale[] = $this->map($k); + $stale[] = [$this->map($k), $v]; } $items[$k] = new CacheItem($this, $k); } - if ($stale !== []) { - $this->redis->del($stale); + foreach ($stale as [$mapped, $observed]) { + RedisValueGuard::deleteIfUnchanged($this->redis, $mapped, $observed); } return $items; @@ -408,12 +409,15 @@ private function initializeTagGenerations(array $missing): array foreach ($missing as $tag => $value) { $candidate = self::newGeneration(); $key = $this->mapTag($tag); - if ($value === false || $value === null) { + if (!is_string($value)) { $stored = $this->redis->set($key, $candidate, ['nx']); $current = $stored ? $candidate : $this->redis->get($key); } else { - $this->redis->set($key, $candidate); - $current = $candidate; + $current = RedisValueGuard::replaceIfUnchanged($this->redis, $key, $value, $candidate); + if ($current === false) { + $stored = $this->redis->set($key, $candidate, ['nx']); + $current = $stored ? $candidate : $this->redis->get($key); + } } $generation = self::normalizeGeneration($current); if ($generation === null) { From e86f9c0bf7761df8cb3c27771a14d8d69f3872f6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:39:48 +0600 Subject: [PATCH 188/434] test(counter): share concurrent initialization probe --- tests/Support/AtomicCounterProcessProbe.php | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/Support/AtomicCounterProcessProbe.php diff --git a/tests/Support/AtomicCounterProcessProbe.php b/tests/Support/AtomicCounterProcessProbe.php new file mode 100644 index 00000000..e279ac57 --- /dev/null +++ b/tests/Support/AtomicCounterProcessProbe.php @@ -0,0 +1,55 @@ +connect($host, $port); + if ($password !== '') { + $client->auth($password); + } + $counter = $backend === 'valkey' + ? AtomicCounters::valkey($namespace, client: $client) + : AtomicCounters::redis($namespace, client: $client); + $initialized = $counter->increment($key)->initialized; + pcntl_exec('/bin/sh', ['-c', $initialized ? 'true' : 'false']); + + exit(255); + } + if ($pid > 0) { + $children[] = $pid; + } + } + + $wins = 0; + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + $wins += pcntl_wexitstatus($status) === 0 ? 1 : 0; + } + + return $wins; + } +} From 41e2efbc4c93236c4a469b4215e18bd6d9b2c8eb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:40:18 +0600 Subject: [PATCH 189/434] test(counter): cover Redis isolation precision and races --- tests/Cache/RedisCachePoolTest.php | 74 +++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 69a4187a..2d04f2a0 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -15,7 +15,11 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Counter\AtomicCounters; +use Infocyph\CacheLayer\Counter\Exception\AtomicCounterException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\RedisValueGuard; +use Infocyph\CacheLayer\Tests\Support\AtomicCounterProcessProbe; /* ── skip whole file when Redis unavailable ───────────────────────── */ if (! class_exists(Redis::class)) { @@ -61,13 +65,13 @@ } $client->flushDB(); // fresh DB 0 + $this->redisClient = $client; $this->cache = Cache::redis( 'tests', sprintf('redis://%s:%d', $redisHost, $redisPort), $client, new CacheOptions(allowClosures: true), ); - }); afterEach(function () { @@ -348,3 +352,71 @@ expect($wins)->toBe(1); }); + + +test('Redis cache clear does not reset isolated atomic counters and large integers stay exact', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $large = 9_007_199_254_740_993; + + expect($counters->increment('large', $large)) + ->value->toBe($large) + ->and($this->cache->set('ordinary', 'value'))->toBeTrue() + ->and($this->cache->clear())->toBeTrue() + ->and($counters->get('large'))->toBe($large); +}); + +test('Redis atomic counters preserve TTL, decrement, overflow, and invalid-value contracts', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $first = $counters->increment('window', 5, 30); + $physical = 'cachelayer:counter:tests:window'; + $ttlBefore = $this->redisClient->ttl($physical); + $later = $counters->decrement('window', 2, 30); + $ttlAfter = $this->redisClient->ttl($physical); + + expect($first->initialized)->toBeTrue() + ->and($later->initialized)->toBeFalse() + ->and($later->value)->toBe(3) + ->and($ttlBefore)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeLessThanOrEqual($ttlBefore); + + $this->redisClient->set('cachelayer:counter:tests:max', (string) PHP_INT_MAX); + expect($counters->get('max'))->toBe(PHP_INT_MAX) + ->and(fn () => $counters->increment('max'))->toThrow(AtomicCounterException::class); + + $this->redisClient->set('cachelayer:counter:tests:out-of-range', '9223372036854775808'); + $this->redisClient->set('cachelayer:counter:tests:malformed', '12x'); + expect(fn () => $counters->get('out-of-range'))->toThrow(AtomicCounterException::class) + ->and(fn () => $counters->get('malformed'))->toThrow(AtomicCounterException::class); +}); + +test('Redis atomic counter initialization has exactly one winner under contention', function () use ($redisHost, $redisPort, $redisPassword) { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $wins = AtomicCounterProcessProbe::initializedWinners( + 'redis', + $redisHost, + $redisPort, + $redisPassword, + 'tests', + 'contended-counter', + ); + + expect($wins)->toBe(1) + ->and($counters->get('contended-counter'))->toBe(8); +}); + +test('Redis stale cleanup never deletes or overwrites a concurrent replacement', function () { + $key = 'cachelayer:guard:race'; + + $this->redisClient->set($key, 'fresh'); + expect(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, 'stale'))->toBeFalse() + ->and($this->redisClient->get($key))->toBe('fresh') + ->and(RedisValueGuard::replaceIfUnchanged($this->redisClient, $key, 'stale', 'repair'))->toBe('fresh') + ->and($this->redisClient->get($key))->toBe('fresh'); + + $this->redisClient->set($key, 'stale'); + expect(RedisValueGuard::replaceIfUnchanged($this->redisClient, $key, 'stale', 'repair'))->toBe('repair') + ->and($this->redisClient->get($key))->toBe('repair') + ->and(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, 'repair'))->toBeTrue() + ->and($this->redisClient->get($key))->toBeFalse(); +}); From 378775916e7ee179f52a19d1ce22de5be44a7810 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:40:48 +0600 Subject: [PATCH 190/434] test(counter): cover Valkey isolation precision and contention --- tests/Cache/ValkeyCachePoolTest.php | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 6f3ff89a..255e65a9 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -4,6 +4,9 @@ use Infocyph\CacheLayer\Cache\AtomicCacheInterface; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Counter\AtomicCounters; +use Infocyph\CacheLayer\Counter\Exception\AtomicCounterException; +use Infocyph\CacheLayer\Tests\Support\AtomicCounterProcessProbe; if (! class_exists(Redis::class)) { throw new RuntimeException('phpredis is required for the configured Valkey test matrix.'); @@ -32,6 +35,7 @@ } $client->flushDB(); + $this->valkeyClient = $client; $this->cache = Cache::valkey( 'valkey-tests', sprintf('valkey://%s:%d', $valkeyHost, $valkeyPort), @@ -109,3 +113,42 @@ expect($atomic->setIfAbsent('claim', 'second', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('second'); }); + + +test('Valkey atomic counters stay isolated from cache clear and preserve exact integers', function () { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + $large = 9_007_199_254_740_993; + $first = $counters->increment('window', $large, 30); + $physical = 'cachelayer:counter:valkey-tests:window'; + $ttlBefore = $this->valkeyClient->ttl($physical); + $later = $counters->decrement('window', 2, 30); + $ttlAfter = $this->valkeyClient->ttl($physical); + + expect($first->value)->toBe($large) + ->and($first->initialized)->toBeTrue() + ->and($later->value)->toBe($large - 2) + ->and($later->initialized)->toBeFalse() + ->and($ttlAfter)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeLessThanOrEqual($ttlBefore) + ->and($this->cache->set('ordinary', 'value'))->toBeTrue() + ->and($this->cache->clear())->toBeTrue() + ->and($counters->get('window'))->toBe($large - 2); + + $this->valkeyClient->set('cachelayer:counter:valkey-tests:invalid', '9223372036854775808'); + expect(fn () => $counters->get('invalid'))->toThrow(AtomicCounterException::class); +}); + +test('Valkey atomic counter initialization has exactly one winner under contention', function () use ($valkeyHost, $valkeyPort, $valkeyPassword) { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + $wins = AtomicCounterProcessProbe::initializedWinners( + 'valkey', + $valkeyHost, + $valkeyPort, + $valkeyPassword, + 'valkey-tests', + 'contended-counter', + ); + + expect($wins)->toBe(1) + ->and($counters->get('contended-counter'))->toBe(8); +}); From 9980b53b81351aeb1e96002ea1201e139b787a30 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:41:32 +0600 Subject: [PATCH 191/434] docs(counter): document isolated exact counter semantics --- docs/counters.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/counters.rst b/docs/counters.rst index a3b6e8f8..3e7eec56 100644 --- a/docs/counters.rst +++ b/docs/counters.rst @@ -8,7 +8,10 @@ PHP workers or application nodes: rate-limit windows, authentication lockout attempts, quotas, and replay-attempt counts. Use Redis or Valkey as the shared backend. Node Cache, APCu, and SQLite are not -distributed atomic-counter stores. +distributed atomic-counter stores. Counter records live in a dedicated +``cachelayer:counter::`` keyspace, so clearing ordinary cache data +never resets rate-limit, quota, or replay counters that happen to share the same +logical namespace. .. code-block:: php @@ -25,7 +28,10 @@ distributed atomic-counter stores. When a positive TTL is supplied, it is assigned only when that key is first created; later increments do not extend the fixed window. Each operation returns ``AtomicCounterValue`` with the resulting ``value`` and an -``initialized`` flag. +``initialized`` flag. The Lua operation reads the resulting counter back as an +exact decimal string before returning it, so values above JavaScript/Lua's +2^53 precision boundary remain exact. Values outside PHP's integer range and +malformed stored values fail closed with ``AtomicCounterException``. .. code-block:: php From ce74f1cf266b8ed44b0e4c07601d7498a847dea9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:41:46 +0600 Subject: [PATCH 192/434] docs(counter): describe isolated exact counter storage --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b12b080..2c67e26b 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ Failed local invalidation stops consumption without advancing the cursor; operat ## Atomic counters and memoization -`AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. Atomic counters are separate from `Cache::atomic()`: counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records. +`AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. They live in a dedicated `cachelayer:counter::` keyspace, so ordinary cache `clear()` does not reset them, and the Lua update returns an exact decimal string before PHP range validation. Atomic counters are separate from `Cache::atomic()`: counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records. The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization. Their state survives requests in persistent workers until evicted or reset with `flush_memoizers()`; call that reset at request boundaries when cross-request reuse is not intended. They are independent of persistent backend caching. From b39a56b946f7ddad97f1aabbfc9a453e12e9628c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:46:39 +0600 Subject: [PATCH 193/434] feat(memcached): add compare-safe value guard --- src/Support/MemcachedValueGuard.php | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Support/MemcachedValueGuard.php diff --git a/src/Support/MemcachedValueGuard.php b/src/Support/MemcachedValueGuard.php new file mode 100644 index 00000000..e1df0418 --- /dev/null +++ b/src/Support/MemcachedValueGuard.php @@ -0,0 +1,37 @@ +get($key, null, \Memcached::GET_EXTENDED); + if (!is_array($entry) || !is_string($entry['value'] ?? null)) { + return false; + } + + $current = $entry['value']; + if ($current !== $observed) { + return $current; + } + + $cas = $entry['cas'] ?? null; + if ((!is_int($cas) && !is_float($cas)) + || !$client->cas((float) $cas, $key, $replacement, $expiration)) { + $latest = $client->get($key); + + return is_string($latest) ? $latest : false; + } + + return $replacement; + } +} From 47e783f46fdde2b09fbb7f38914c027abba9dc72 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:47:09 +0600 Subject: [PATCH 194/434] fix(memcached): guard stale cleanup and status handling --- src/Cache/Adapter/MemcachedCacheAdapter.php | 105 +++++++++++--------- 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index e395e037..b0163b89 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -7,6 +7,7 @@ use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\MemcachedValueGuard; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -160,11 +161,7 @@ public function deleteItem(string $key): bool $this->discardDeferredKey($key); $this->client->delete($this->mapData($key)); - return !in_array( - $this->client->getResultCode(), - [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], - true, - ); + return $this->deleteResultSucceeded(); } /** @param list $keys */ @@ -177,11 +174,7 @@ public function deleteItems(array $keys): bool $this->client->deleteMulti(array_map($this->mapData(...), $keys)); - return !in_array( - $this->client->getResultCode(), - [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], - true, - ); + return $this->deleteResultSucceeded(); } public function getClient(): \Memcached @@ -204,7 +197,13 @@ public function getItem(string $key): CacheItem return $this->genericItemFromRecord($key, $record); } if (is_string($blob)) { - $this->client->delete($mapped); + MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $mapped, + $blob, + self::ATOMIC_TOMBSTONE, + 1, + ); } return $this->genericMiss($key); @@ -267,15 +266,21 @@ public function multiFetch(array $keys): array if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); if (is_string($blob)) { - $stale[] = $mapped; + $stale[] = [$mapped, $blob]; } continue; } $items[$key] = $this->genericItemFromRecord($key, $record); } - if ($stale !== []) { - $this->client->deleteMulti($stale); + foreach ($stale as [$mapped, $observed]) { + MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $mapped, + $observed, + self::ATOMIC_TOMBSTONE, + 1, + ); } return $items; @@ -347,6 +352,35 @@ public function saveItems(array $items): bool } /** @return array{value:string, cas:int|float}|null */ + + private function deleteResultSucceeded(): bool + { + return in_array( + $this->client->getResultCode(), + [\Memcached::RES_SUCCESS, \Memcached::RES_NOTFOUND], + true, + ); + } + + private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string + { + $generation = self::normalizeGeneration($observed); + if ($generation !== null) { + return $generation; + } + + $candidate = self::newGeneration(); + $current = is_string($observed) + ? MemcachedValueGuard::replaceIfUnchanged($this->client, $key, $observed, $candidate) + : ($this->client->add($key, $candidate) ? $candidate : $this->client->get($key)); + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException($failureMessage); + } + + return $generation; + } + private function extendedGet(string $key): ?array { $value = $this->client->get($key, null, \Memcached::GET_EXTENDED); @@ -379,24 +413,12 @@ private function mapTag(string $tag): string private function namespaceGeneration(mixed $value = null): string { $value ??= $this->client->get($this->generationKey()); - $generation = self::normalizeGeneration($value); - if ($generation !== null) { - return $generation; - } - $candidate = self::newGeneration(); - $value = $this->client->add($this->generationKey(), $candidate) - ? $candidate - : $this->client->get($this->generationKey()); - $generation = self::normalizeGeneration($value); - if ($generation === null) { - $generation = self::newGeneration(); - if (!$this->client->set($this->generationKey(), $generation)) { - throw new RuntimeException('Unable to initialize Memcached namespace generation.'); - } - } - - return $generation; + return $this->initializeGeneration( + $this->generationKey(), + $value, + 'Unable to initialize Memcached namespace generation.', + ); } private function recordTagsAreCurrent(CacheRecord $record): bool @@ -418,23 +440,10 @@ private function recordTagsAreCurrent(CacheRecord $record): bool private function tagGeneration(string $key, mixed $value): string { - $generation = self::normalizeGeneration($value); - if ($generation !== null) { - return $generation; - } - - $candidate = self::newGeneration(); - $generation = self::normalizeGeneration( - $this->client->add($key, $candidate) ? $candidate : $this->client->get($key), + return $this->initializeGeneration( + $key, + $value, + 'Unable to initialize Memcached tag generation.', ); - if ($generation !== null) { - return $generation; - } - $generation = self::newGeneration(); - if (!$this->client->set($key, $generation)) { - throw new RuntimeException('Unable to initialize Memcached tag generation.'); - } - - return $generation; } } From 9380a46ff3dfc4008107e6d948f75efa778f87fc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:47:55 +0600 Subject: [PATCH 195/434] fix(redis-cluster): avoid racy stale cleanup and generation overwrite --- .../Adapter/RedisClusterCacheAdapter.php | 78 +++++++++---------- 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index d93f91cb..44da0f09 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -94,10 +94,6 @@ public function getItem(string $key): CacheItem if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } - if (is_string($blob)) { - $this->call('del', $this->mapData($key)); - } - return $this->genericMiss($key); } @@ -112,17 +108,12 @@ public function getTagGenerations(array $tags): array foreach ($this->groupByBucket($tags) as $group) { $values = $this->call('mget', array_map($this->mapTag(...), $group)); $values = is_array($values) ? array_values($values) : []; - $initialize = []; foreach ($group as $index => $tag) { - $generation = self::normalizeGeneration($values[$index] ?? null); - if ($generation === null) { - $generation = self::newGeneration(); - $initialize[$this->mapTag($tag)] = $generation; - } - $generations[$tag] = $generation; - } - if ($initialize !== [] && !$this->call('mset', $initialize)) { - throw new RuntimeException('Unable to initialize Redis Cluster tag generations.'); + $generations[$tag] = $this->initializeGeneration( + $this->mapTag($tag), + $values[$index] ?? null, + 'Unable to initialize Redis Cluster tag generation.', + ); } } @@ -141,13 +132,9 @@ public function hasItem(string $key): bool public function multiFetch(array $keys): array { $items = []; - $stale = []; foreach ($this->groupByBucket($keys) as $bucket => $group) { - $bucketResult = $this->fetchBucket($bucket, $group); - $items += $bucketResult['items']; - $stale = [...$stale, ...$bucketResult['stale']]; + $items += $this->fetchBucket($bucket, $group); } - $this->deleteItems($stale); $ordered = []; foreach ($keys as $key) { @@ -231,7 +218,7 @@ private function callObject(object $target, string $method, mixed ...$arguments) /** * @param list $keys - * @return array{items: array, stale: list} + * @return array */ private function fetchBucket(int $bucket, array $keys): array { @@ -240,7 +227,6 @@ private function fetchBucket(int $bucket, array $keys): array $values = is_array($values) ? array_values($values) : []; $generation = $this->namespaceGeneration($bucket, $values[0] ?? null); $items = []; - $stale = []; foreach ($keys as $index => $key) { $blob = $values[$index + 1] ?? null; $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; @@ -250,12 +236,9 @@ private function fetchBucket(int $bucket, array $keys): array continue; } $items[$key] = $this->genericMiss($key); - if (is_string($blob)) { - $stale[] = $key; - } } - return ['items' => $items, 'stale' => $stale]; + return $items; } private function generationKey(int $bucket): string @@ -291,38 +274,47 @@ private function groupItemsByBucket(array $items): array return $groups; } - private function mapData(string $key): string - { - return $this->prefix($this->bucket($key)) . ':d:' . $key; - } - private function mapTag(string $tag): string - { - return $this->prefix($this->bucket($tag)) . ':m:tag:' . $tag; - } - - private function namespaceGeneration(int $bucket, mixed $value = null): string + private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string { - $value ??= $this->call('get', $this->generationKey($bucket)); - $generation = self::normalizeGeneration($value); + $generation = self::normalizeGeneration($observed); if ($generation !== null) { return $generation; } $candidate = self::newGeneration(); - $stored = $this->call('set', $this->generationKey($bucket), $candidate, ['nx']); - $current = $stored ? $candidate : $this->call('get', $this->generationKey($bucket)); + $stored = $this->call('set', $key, $candidate, ['nx']); + $current = $stored ? $candidate : $this->call('get', $key); $generation = self::normalizeGeneration($current); if ($generation === null) { - $generation = self::newGeneration(); - if (!$this->call('set', $this->generationKey($bucket), $generation)) { - throw new RuntimeException('Unable to initialize Redis Cluster namespace generation.'); - } + throw new RuntimeException($failureMessage); } return $generation; } + private function mapData(string $key): string + { + return $this->prefix($this->bucket($key)) . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->prefix($this->bucket($tag)) . ':m:tag:' . $tag; + } + + private function namespaceGeneration(int $bucket, mixed $value = null): string + { + $key = $this->generationKey($bucket); + $value ??= $this->call('get', $key); + + return $this->initializeGeneration( + $key, + $value, + 'Unable to initialize Redis Cluster namespace generation.', + ); + } + private function prefix(int $bucket): string { return $this->namespace . ':{' . $this->namespace . '-' . $bucket . '}'; From dd5f30acd143019c9bad54f439302da59ce22967 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:48:16 +0600 Subject: [PATCH 196/434] test(memcached): cover guarded cleanup and failure statuses --- tests/Cache/MemcachedCachePoolTest.php | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index e2572958..62dc4e1e 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -14,6 +14,7 @@ use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\MemcachedValueGuard; /* ── Skip suite if Memcached unavailable ─────────────────────────── */ @@ -196,3 +197,42 @@ $provider->release($handle); } }); + + +test('Memcached compare-safe guard preserves a concurrent replacement', function () { + $key = 'tests:guard:race'; + + $this->client->set($key, 'fresh', 30); + expect(MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $key, + 'stale', + 'repair', + 1, + ))->toBe('fresh') + ->and($this->client->get($key))->toBe('fresh'); + + $this->client->set($key, 'stale', 30); + expect(MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $key, + 'stale', + 'repair', + 1, + ))->toBe('repair') + ->and($this->client->get($key))->toBe('repair'); +}); + +test('Memcached delete reports backend errors but treats missing keys as success', function () { + $unavailable = new Memcached; + $adapter = new \Infocyph\CacheLayer\Cache\Adapter\MemcachedCacheAdapter( + 'unavailable', + [], + $unavailable, + ); + + expect($this->cache->delete('missing-delete'))->toBeTrue() + ->and($this->cache->deleteItems(['missing-one', 'missing-two']))->toBeTrue() + ->and($adapter->deleteItem('key'))->toBeFalse() + ->and($adapter->deleteItems(['key']))->toBeFalse(); +}); From 9eff41be361d74450e7b3b5cf5863c121e45d56e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:49:52 +0600 Subject: [PATCH 197/434] fix(pdo): make stale reads and expiry pruning race-safe --- src/Cache/Adapter/PdoCacheAdapter.php | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 9bcc04bb..d68d8859 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -132,12 +132,8 @@ public function getItem(string $key): CacheItem } $item = $this->hydrate($key, $row); - if ($item instanceof CacheItem) { - return $item; - } - $this->deleteItem($key); - return $this->genericMiss($key); + return $item ?? $this->genericMiss($key); } /** @@ -183,16 +179,11 @@ public function multiFetch(array $keys): array { $rows = $this->fetchRows(self::KIND_DATA, $keys); $items = []; - $stale = []; foreach ($keys as $key) { $row = $rows[$key] ?? null; $item = is_array($row) ? $this->hydrate($key, $row) : null; $items[$key] = $item ?? $this->genericMiss($key); - if (is_array($row) && $item === null) { - $stale[] = $key; - } } - $this->deleteByKind(self::KIND_DATA, $stale); return $items; } @@ -208,13 +199,14 @@ public function pruneExpired(int $limit = 1000): int ); $statement->bindValue(1, $this->namespace, PDO::PARAM_STR); $statement->bindValue(2, self::KIND_DATA, PDO::PARAM_STR); - $statement->bindValue(3, time(), PDO::PARAM_INT); + $cutoff = time(); + $statement->bindValue(3, $cutoff, PDO::PARAM_INT); $statement->bindValue(4, $limit, PDO::PARAM_INT); $statement->execute(); $keys = $statement->fetchAll(PDO::FETCH_COLUMN); $keys = array_values(array_filter($keys, is_string(...))); - return $this->deleteByKind(self::KIND_DATA, $keys) ? count($keys) : 0; + return $this->deleteExpiredKeys($keys, $cutoff); } /** @param list $tags */ @@ -291,6 +283,26 @@ private static function assertSqliteTarget(string $dsn): void } } + + /** @param list $keys */ + private function deleteExpiredKeys(array $keys, int $cutoff): int + { + $deleted = 0; + foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { + $marks = implode(',', array_fill(0, count($chunk), '?')); + $statement = $this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? " + . "AND expires IS NOT NULL AND expires <= ? AND cache_key IN ({$marks})", + ); + if (!$statement->execute([$this->namespace, self::KIND_DATA, $cutoff, ...$chunk])) { + return $deleted; + } + $deleted += $statement->rowCount(); + } + + return $deleted; + } + /** @param list $keys */ private function deleteByKind(string $kind, array $keys): bool { From 0220d281c7e94f4195ad0fb149363f680b1146f8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:52:38 +0600 Subject: [PATCH 198/434] fix(apcu): avoid racy stale cleanup and tag overwrite --- src/Cache/Adapter/ApcuCacheAdapter.php | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 86b308a5..68cd9692 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -86,7 +86,6 @@ public function getItem(string $key): CacheItem return $item; } - apcu_delete($apcuKey); } return $this->genericMiss($key); @@ -108,8 +107,7 @@ public function getTagGenerations(array $tags): array $candidate = self::newGeneration(); $generation = self::normalizeGeneration(apcu_add($key, $candidate) ? $candidate : apcu_fetch($key)); if ($generation === null) { - $generation = self::newGeneration(); - apcu_store($key, $generation); + throw new RuntimeException('Unable to initialize APCu tag generation.'); } } $generations[$tag] = $generation; @@ -141,17 +139,12 @@ public function multiFetch(array $keys): array } $items = []; - $stale = []; foreach ($keys as $k) { - if ($this->appendFetchedHit($items, $stale, $k, $raw)) { + if ($this->appendFetchedHit($items, $k, $raw)) { continue; } - $items[$k] = new CacheItem($this, $k); - } - - if ($stale !== []) { - apcu_delete($stale); + $items[$k] = $this->genericMiss($k); } return $items; @@ -263,7 +256,7 @@ public function storeTagGenerations(array $generations): bool * @phpstan-param list $stale * @phpstan-param array $raw */ - private function appendFetchedHit(array &$items, array &$stale, string $key, array $raw): bool + private function appendFetchedHit(array &$items, string $key, array $raw): bool { $mapped = $this->map($key); if (!isset($raw[$mapped]) || !is_string($raw[$mapped])) { @@ -277,8 +270,6 @@ private function appendFetchedHit(array &$items, array &$stale, string $key, arr return true; } - $stale[] = $mapped; - return false; } From ebcb826165c9104ef0fd47730fd40c4464abd137 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:53:09 +0600 Subject: [PATCH 199/434] fix(cache): leave stale cleanup to safe maintenance --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 7a6b990b..d174272d 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -195,7 +195,7 @@ function () use ($mapped): ?string { return $this->genericFromBlobWithInvalidator( $key, $blob, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -231,10 +231,9 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - [$items, $invalid] = $this->withSharedLock(function () use ($keys): array { + $items = $this->withSharedLock(function () use ($keys): array { $store = $this->loadStore(); $items = []; - $invalid = []; foreach ($keys as $key) { $mapped = $this->map($key); $blob = $store[$mapped] ?? null; @@ -242,16 +241,10 @@ public function multiFetch(array $keys): array $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); - if ($blob !== null && $record === null) { - $invalid[] = $key; - } } - return [$items, $invalid]; + return $items; }); - if ($invalid !== []) { - $this->deleteItems($invalid); - } return $items; } From 7d4a470692e41772076c1595e88187dc96b2087e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:53:15 +0600 Subject: [PATCH 200/434] fix(cache): leave stale cleanup to safe maintenance --- src/Cache/Adapter/MongoDbCacheAdapter.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index c725a7e5..d46626e7 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -176,7 +176,7 @@ public function getItem(string $key): CacheItem return $this->genericFromBlobWithInvalidator( $key, $payload, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -227,17 +227,15 @@ public function multiFetch(array $keys): array } $items = []; - $stale = []; foreach ($keys as $key) { $row = $byId[$this->mapData($key)] ?? null; $payload = is_array($row) ? $this->binaryString($row['payload'] ?? null) : null; - $item = $this->genericFromBlobWithInvalidator($key, $payload, static fn(): bool => true); - $items[$key] = $item; - if (is_array($row) && !$item->isHit()) { - $stale[] = $key; - } + $items[$key] = $this->genericFromBlobWithInvalidator( + $key, + $payload, + static fn(): bool => true, + ); } - $this->deleteItems($stale); return $items; } From 24e9678f6bb372b3c0e960db19c21a6dc684a2a5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:53:24 +0600 Subject: [PATCH 201/434] fix(cache): leave stale cleanup to safe maintenance --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 718a067b..9fd0ffcb 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -106,7 +106,7 @@ public function getItem(string $key): CacheItem $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); if ($expiresAt !== null && $expiresAt <= time()) { - return $this->genericDeleteAndMiss($key); + return $this->genericMiss($key); } $payload = $this->normalizeString($row['payload'] ?? null); @@ -114,7 +114,7 @@ public function getItem(string $key): CacheItem return $this->genericFromBlobWithInvalidator( $key, $payload, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -152,14 +152,8 @@ public function hasItem(string $key): bool public function multiFetch(array $keys): array { $items = []; - $invalid = []; foreach ($this->groupByBucket($keys) as $bucket => $group) { - $result = $this->fetchBucketItems($bucket, $group); - $items += $result['items']; - array_push($invalid, ...$result['invalid']); - } - if ($invalid !== []) { - $this->deleteItems($invalid); + $items += $this->fetchBucketItems($bucket, $group); } return $items; @@ -372,7 +366,6 @@ private function fetchBucketItems(int $bucket, array $keys): array } $items = []; - $invalid = []; foreach ($keys as $key) { $row = $byKey[$this->mapData($key)] ?? null; $payload = is_array($row) ? $this->normalizeString($row['payload'] ?? null) : null; @@ -380,12 +373,9 @@ private function fetchBucketItems(int $bucket, array $keys): array $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); - if (is_array($row) && $record === null) { - $invalid[] = $key; - } } - return ['items' => $items, 'invalid' => $invalid]; + return $items; } /** From 30776f5463cac96f768f74d85cfcd81f452ff594 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:53:29 +0600 Subject: [PATCH 202/434] fix(cache): leave stale cleanup to safe maintenance --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index deffaa45..e1e1c95c 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -199,7 +199,6 @@ public function multiFetch(array $keys): array } } $items = []; - $invalid = []; foreach ($keys as $key) { $payload = $rows[$this->mapData($key)] ?? null; if (!is_string($payload)) { @@ -209,17 +208,12 @@ public function multiFetch(array $keys): array } $record = $this->decodeRecordFromBlob($payload, $key); if ($record === null) { - $invalid[] = $key; $items[$key] = $this->genericMiss($key); continue; } $items[$key] = $this->genericItemFromRecord($key, $record); } - if ($invalid !== []) { - $this->deleteItems($invalid); - } - return $items; } From 500bbd771ef3edeaba68600ac6ac2ca40254ffcd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:53:57 +0600 Subject: [PATCH 203/434] test(counter): verify Redis fixed-window expiry --- tests/Cache/RedisCachePoolTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 2d04f2a0..f573ecb9 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -420,3 +420,14 @@ ->and(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, 'repair'))->toBeTrue() ->and($this->redisClient->get($key))->toBeFalse(); }); + + +test('Redis atomic counters expire fixed windows', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + + expect($counters->increment('short-window', 1, 1)->initialized)->toBeTrue() + ->and($counters->get('short-window'))->toBe(1); + usleep(2_000_000); + + expect($counters->get('short-window'))->toBeNull(); +}); From fbda5a9b3cee9de0b45dafd1ba034ab168f0e97c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:54:02 +0600 Subject: [PATCH 204/434] test(counter): verify Valkey fixed-window expiry --- tests/Cache/ValkeyCachePoolTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 255e65a9..3637af28 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -152,3 +152,14 @@ expect($wins)->toBe(1) ->and($counters->get('contended-counter'))->toBe(8); }); + + +test('Valkey atomic counters expire fixed windows', function () { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + + expect($counters->increment('short-window', 1, 1)->initialized)->toBeTrue() + ->and($counters->get('short-window'))->toBe(1); + usleep(2_000_000); + + expect($counters->get('short-window'))->toBeNull(); +}); From 1cdb25aea7717212e6f9870119a8f47f3b525b4b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:54:39 +0600 Subject: [PATCH 205/434] test(redis-cluster): cover safe stale and generation races --- tests/Cache/RedisClusterCachePoolTest.php | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 361b34ca..775e8baa 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -353,3 +353,46 @@ private function prune(string $key): void expect($atomic->setIfAbsent('claim', 'second', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('second'); }); + + +test('redis cluster leaves stale data for safe overwrite instead of deleting after read', function () { + expect($this->cache->set('stale-race', 'value'))->toBeTrue(); + + $physical = null; + foreach ($this->cluster->keys() as $key) { + if (str_ends_with($key, ':d:stale-race')) { + $physical = $key; + break; + } + } + expect($physical)->not->toBeNull(); + if (!is_string($physical)) { + return; + } + + $this->cluster->set($physical, 'invalid-payload'); + + expect($this->cache->get('stale-race'))->toBeNull() + ->and($this->cluster->get($physical))->toBe('invalid-payload'); +}); + +test('redis cluster generation repair fails closed without overwriting unexpected state', function () { + expect($this->cache->set('generation-race', 'value'))->toBeTrue(); + + $generation = null; + foreach ($this->cluster->keys() as $key) { + if (str_ends_with($key, ':m:generation')) { + $generation = $key; + break; + } + } + expect($generation)->not->toBeNull(); + if (!is_string($generation)) { + return; + } + + $this->cluster->set($generation, 'malformed-generation'); + + expect(fn () => $this->cache->get('generation-race'))->toThrow(RuntimeException::class) + ->and($this->cluster->get($generation))->toBe('malformed-generation'); +}); From 0f5d94e5689667ecdfd8f808d0b1c1128c295af3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:56:15 +0600 Subject: [PATCH 206/434] fix(shared-memory): initialize tags under one exclusive lock --- .../Adapter/SharedMemoryCacheAdapter.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index d174272d..41afbc7a 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -206,18 +206,26 @@ function () use ($mapped): ?string { #[\Override] public function getTagGenerations(array $tags): array { - $generations = $this->readTagGenerations($tags); - $missing = []; - foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + return $this->withExclusiveLock(function () use ($tags): array { + $store = $this->loadStore(); + $generations = []; + $changed = false; + foreach ($tags as $tag) { + $mapped = $this->mapTag($tag); + $generation = self::normalizeGeneration($store[$mapped] ?? null); + if ($generation === null) { + $generation = self::newGeneration(); + $store[$mapped] = $generation; + $changed = true; + } + $generations[$tag] = $generation; + } + if ($changed && !$this->store($store)) { + throw new RuntimeException('Unable to initialize shared-memory tag generations.'); } - } - if ($missing !== []) { - $this->storeTagGenerations($missing); - } - return $generations + $missing; + return $generations; + }); } public function hasItem(string $key): bool From 4175bb3606ccd031d2acf6596014e3d6856a4499 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:56:32 +0600 Subject: [PATCH 207/434] fix(mongodb): initialize tag generations without overwrites --- src/Cache/Adapter/MongoDbCacheAdapter.php | 35 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index d46626e7..ec70c305 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -188,17 +188,40 @@ public function getItem(string $key): CacheItem public function getTagGenerations(array $tags): array { $generations = $this->readTagGenerations($tags); - $missing = []; foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + if (isset($generations[$tag])) { + continue; + } + + $candidate = self::newGeneration(); + try { + $this->collection->updateOne( + ['_id' => $this->mapTag($tag)], + [ + '$setOnInsert' => [ + 'ns' => $this->ns, + 'kind' => 'metadata', + 'tag' => $tag, + 'generation' => $candidate, + ], + ], + ['upsert' => true], + ); + } catch (Throwable $failure) { + if (!$this->isDuplicateKeyFailure($failure)) { + throw $failure; + } } } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { - throw new RuntimeException('Unable to initialize MongoDB tag generations.'); + + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new RuntimeException('Unable to initialize MongoDB tag generations.'); + } } - return $generations + $missing; + return $actual; } public function hasItem(string $key): bool From 4eae413f8c357cddbe65e0237f0ff38f87a69e2c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:57:19 +0600 Subject: [PATCH 208/434] fix(pdo): initialize tag generations without lost races --- src/Cache/Adapter/PdoCacheAdapter.php | 66 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index d68d8859..8dd36731 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -149,18 +149,36 @@ public function getTagGenerations(array $tags): array $rows = $this->fetchRows(self::KIND_TAG, $tags); $generations = []; - $initialize = []; + $missing = []; foreach ($tags as $tag) { $row = $rows[$tag] ?? null; - $generation = is_array($row) ? $row['payload'] : null; - if (!self::isGeneration($generation)) { - $generation = self::newGeneration(); - $initialize[] = [self::KIND_TAG, $tag, $generation, null]; + if (is_array($row)) { + $generation = $row['payload']; + if (!self::isGeneration($generation)) { + throw new RuntimeException('PDO tag generation contains invalid state.'); + } + $generations[$tag] = strtolower($generation); + + continue; + } + $missing[$tag] = self::newGeneration(); + } + foreach ($missing as $tag => $candidate) { + if (!$this->insertTagGenerationIfMissing($tag, $candidate)) { + throw new RuntimeException('Unable to initialize PDO tag generation.'); } - $generations[$tag] = strtolower((string) $generation); } - if (!$this->upsertRows($initialize)) { - throw new RuntimeException('Unable to initialize PDO tag generations.'); + if ($missing === []) { + return $generations; + } + + $actual = $this->fetchRows(self::KIND_TAG, array_keys($missing)); + foreach ($missing as $tag => $_candidate) { + $generation = $actual[$tag]['payload'] ?? null; + if (!self::isGeneration($generation)) { + throw new RuntimeException('Unable to initialize PDO tag generation.'); + } + $generations[$tag] = strtolower($generation); } return $generations; @@ -265,6 +283,38 @@ public function saveItems(array $items): bool return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } + + private function insertTagGenerationIfMissing(string $tag, string $generation): bool + { + $sql = match ($this->driver) { + 'pgsql', 'sqlite' => "INSERT INTO {$this->table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON CONFLICT(namespace, kind, cache_key) DO NOTHING', + 'mysql', 'mariadb' => "INSERT INTO {$this->table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON DUPLICATE KEY UPDATE cache_key = cache_key', + default => "INSERT INTO {$this->table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL)', + }; + + try { + return $this->pdo->prepare($sql)->execute([ + $this->namespace, + self::KIND_TAG, + $tag, + strtolower($generation), + ]); + } catch (PDOException $failure) { + if (in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + throw $failure; + } + + $row = $this->fetchRows(self::KIND_TAG, [$tag])[$tag] ?? null; + + return is_array($row) && self::isGeneration($row['payload']); + } + } + private static function assertSqliteTarget(string $dsn): void { if (!str_starts_with($dsn, 'sqlite:')) { From 81b2b8d267775d4f5eba20f846cd638b1954c1f9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:57:42 +0600 Subject: [PATCH 209/434] fix(node): initialize tag generations without lost races --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 34 +++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index e1e1c95c..725429ba 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -162,11 +162,18 @@ public function getTagGenerations(array $tags): array $missing[$tag] = self::newGeneration(); } } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { + if ($missing !== [] && !$this->insertTagGenerationsIfMissing($missing)) { throw new NodeCacheStorageException('Unable to initialize node SQLite tag generations.'); } - return $generations + $missing; + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new NodeCacheStorageException('Unable to initialize node SQLite tag generation.'); + } + } + + return $actual; } public function hasItem(string $key): bool @@ -399,6 +406,29 @@ private function createSchemaIfMissing(): void } } + + /** @param array $generations */ + private function insertTagGenerationsIfMissing(array $generations): bool + { + $this->assertWritableTransaction(); + foreach ($generations as $tag => $generation) { + $statement = $this->connection->prepare( + 'INSERT INTO ' . self::TABLE + . ' (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, NULL) ' + . 'ON CONFLICT(namespace, cache_key) DO NOTHING', + ); + if (!$statement->execute([ + $this->namespace, + $this->mapTag((string) $tag), + strtolower($generation), + ])) { + return false; + } + } + + return true; + } + private function mapData(string $key): string { return 'd:' . $key; From 1a6601383a766a1246352c3c24a84b9f1fb11b87 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:58:32 +0600 Subject: [PATCH 210/434] fix(scylla): initialize tag generations with LWT --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 9fd0ffcb..86907107 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -126,17 +126,26 @@ public function getItem(string $key): CacheItem public function getTagGenerations(array $tags): array { $generations = $this->readTagGenerations($tags); - $missing = []; foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + if (isset($generations[$tag])) { + continue; } + + $this->executeCql( + "INSERT INTO {$this->metadataTable} (ns, bucket, tag, generation) " + . 'VALUES (?, ?, ?, ?) IF NOT EXISTS', + [$this->ns, $this->bucket($tag), $tag, self::newGeneration()], + ); } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { - throw new RuntimeException('Unable to initialize ScyllaDB tag generations.'); + + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new RuntimeException('Unable to initialize ScyllaDB tag generation.'); + } } - return $generations + $missing; + return $actual; } public function hasItem(string $key): bool @@ -347,7 +356,7 @@ private function executionOptions(array $arguments): mixed /** * @param list $keys - * @return array{items:array, invalid:list} + * @return array */ private function fetchBucketItems(int $bucket, array $keys): array { From 0c9b624f0af6a72c313fe0ef5e50c72ce8e5a30d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 20:58:57 +0600 Subject: [PATCH 211/434] test(scylla): model conditional tag initialization --- tests/Cache/ScyllaDbCachePoolTest.php | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/Cache/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php index f0f6a7e0..14ff0b5b 100644 --- a/tests/Cache/ScyllaDbCachePoolTest.php +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -13,6 +13,9 @@ /** @var array */ private array $rows = []; + /** @var array */ + private array $metadata = []; + public int $bucketReads = 0; public int $writeBatches = 0; @@ -34,6 +37,46 @@ public function execute(mixed $statement, mixed $options = []): array return []; } + if (str_contains($cql, 'cachelayer_entries_metadata')) { + if (str_starts_with($cql, 'SELECT tag, generation')) { + $ns = (string) ($args[0] ?? ''); + $bucket = (int) ($args[1] ?? 0); + $tags = array_map('strval', array_slice($args, 2)); + + return array_values(array_filter( + $this->metadata, + static fn(array $row, string $key): bool => str_starts_with( + $key, + $ns . ':' . $bucket . ':', + ) && in_array($row['tag'], $tags, true), + ARRAY_FILTER_USE_BOTH, + )); + } + + if (str_starts_with($cql, 'INSERT INTO')) { + $key = $this->rowKey($args); + if (!str_contains($cql, 'IF NOT EXISTS') || !isset($this->metadata[$key])) { + $this->metadata[$key] = [ + 'tag' => (string) ($args[2] ?? ''), + 'generation' => (string) ($args[3] ?? ''), + ]; + } + + return []; + } + + if (str_starts_with($cql, 'DELETE FROM')) { + $prefix = (string) ($args[0] ?? '') . ':' . (int) ($args[1] ?? 0) . ':'; + foreach (array_keys($this->metadata) as $key) { + if (str_starts_with($key, $prefix)) { + unset($this->metadata[$key]); + } + } + + return []; + } + } + if (str_starts_with($cql, 'DELETE FROM') && str_contains($cql, 'AND ckey = ?')) { unset($this->rows[$this->rowKey($args)]); @@ -275,3 +318,14 @@ function scylladbHttpGet(string $url, mixed $context): ?string expect(is_array($decoded))->toBeTrue(); }); + + +test('scylladb tag initialization preserves the first generation', function () { + $first = Cache::scylla('tag-race', $this->session, 'cachelayer', 'cachelayer_entries', 1); + $second = Cache::scylla('tag-race', $this->session, 'cachelayer', 'cachelayer_entries', 1); + + expect($first->setTagged('one', 'A', ['shared']))->toBeTrue() + ->and($second->setTagged('two', 'B', ['shared']))->toBeTrue() + ->and($first->get('one'))->toBe('A') + ->and($second->get('two'))->toBe('B'); +}); From c6d2fadfa1be7c41e3253282858079b7786eb030 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:02:02 +0600 Subject: [PATCH 212/434] fix(apcu): remove stale helper annotation --- src/Cache/Adapter/ApcuCacheAdapter.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 68cd9692..112416ea 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -249,11 +249,9 @@ public function storeTagGenerations(array $generations): bool /** * @param array $items The items argument. - * @param array $stale The stale argument. * @param string $key The key argument. * @param array $raw The raw argument. * @phpstan-param array $items - * @phpstan-param list $stale * @phpstan-param array $raw */ private function appendFetchedHit(array &$items, string $key, array $raw): bool From 5a6f00ac657225de1a289da6bb67f7f638cb97a2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:02:07 +0600 Subject: [PATCH 213/434] fix(memcached): narrow extended CAS result types --- src/Cache/Adapter/MemcachedCacheAdapter.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index b0163b89..0e1ab7b2 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -351,8 +351,6 @@ public function saveItems(array $items): bool return true; } - /** @return array{value:string, cas:int|float}|null */ - private function deleteResultSucceeded(): bool { return in_array( @@ -381,6 +379,7 @@ private function initializeGeneration(string $key, mixed $observed, string $fail return $generation; } + /** @return array{value:string, cas:float}|null */ private function extendedGet(string $key): ?array { $value = $this->client->get($key, null, \Memcached::GET_EXTENDED); @@ -392,7 +391,7 @@ private function extendedGet(string $key): ?array return null; } - return ['value' => $value['value'], 'cas' => $cas]; + return ['value' => $value['value'], 'cas' => (float) $cas]; } private function generationKey(): string From b6e6fb1434c5fa8e017f24245734dde44f58d65c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:02:46 +0600 Subject: [PATCH 214/434] refactor(pdo): isolate tag generation initialization --- src/Cache/Adapter/PdoTagGenerationStore.php | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/Cache/Adapter/PdoTagGenerationStore.php diff --git a/src/Cache/Adapter/PdoTagGenerationStore.php b/src/Cache/Adapter/PdoTagGenerationStore.php new file mode 100644 index 00000000..46dfea45 --- /dev/null +++ b/src/Cache/Adapter/PdoTagGenerationStore.php @@ -0,0 +1,135 @@ + $tags + * @return array + */ + public static function getOrInitialize( + PDO $pdo, + string $driver, + string $table, + string $namespace, + array $tags, + ): array { + if ($tags === []) { + return []; + } + + $stored = self::fetch($pdo, $table, $namespace, $tags); + $generations = []; + $missing = []; + foreach ($tags as $tag) { + $generation = self::normalize($stored[$tag] ?? null); + if ($generation !== null) { + $generations[$tag] = $generation; + + continue; + } + if (array_key_exists($tag, $stored)) { + throw new RuntimeException('PDO tag generation contains invalid state.'); + } + $missing[$tag] = bin2hex(random_bytes(16)); + } + + foreach ($missing as $tag => $candidate) { + self::insertIfMissing($pdo, $driver, $table, $namespace, $tag, $candidate); + } + if ($missing === []) { + return $generations; + } + + $actual = self::fetch($pdo, $table, $namespace, array_keys($missing)); + foreach ($missing as $tag => $_candidate) { + $generation = self::normalize($actual[$tag] ?? null); + if ($generation === null) { + throw new RuntimeException('Unable to initialize PDO tag generation.'); + } + $generations[$tag] = $generation; + } + + return $generations; + } + + /** @param list $tags + * @return array + */ + private static function fetch(PDO $pdo, string $table, string $namespace, array $tags): array + { + $stored = []; + foreach (array_chunk($tags, self::BATCH_SIZE) as $chunk) { + $marks = implode(',', array_fill(0, count($chunk), '?')); + $statement = $pdo->prepare( + "SELECT cache_key, payload FROM {$table} " + . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + ); + $statement->execute([$namespace, self::KIND_TAG, ...$chunk]); + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (!is_array($row) || !is_string($row['cache_key'] ?? null)) { + continue; + } + $payload = $row['payload'] ?? null; + if (is_resource($payload)) { + $payload = stream_get_contents($payload); + } + if (is_string($payload)) { + $stored[$row['cache_key']] = $payload; + } + } + } + + return $stored; + } + + private static function insertIfMissing( + PDO $pdo, + string $driver, + string $table, + string $namespace, + string $tag, + string $generation, + ): void { + $sql = match ($driver) { + 'pgsql', 'sqlite' => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON CONFLICT(namespace, kind, cache_key) DO NOTHING', + 'mysql', 'mariadb' => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON DUPLICATE KEY UPDATE cache_key = cache_key', + default => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL)', + }; + + try { + $pdo->prepare($sql)->execute([$namespace, self::KIND_TAG, $tag, $generation]); + } catch (PDOException $failure) { + if (in_array($driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + throw $failure; + } + if (self::normalize(self::fetch($pdo, $table, $namespace, [$tag])[$tag] ?? null) === null) { + throw $failure; + } + } + } + + private static function normalize(mixed $value): ?string + { + return is_string($value) && strlen($value) === 32 && ctype_xdigit($value) + ? strtolower($value) + : null; + } +} From bff73a5a5c1d139771e181ca1b27059374c7cf9c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:03:04 +0600 Subject: [PATCH 215/434] refactor(pdo): delegate tag generation initialization --- src/Cache/Adapter/PdoCacheAdapter.php | 77 +++------------------------ 1 file changed, 7 insertions(+), 70 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 8dd36731..d0a46c62 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -143,45 +143,13 @@ public function getItem(string $key): CacheItem #[\Override] public function getTagGenerations(array $tags): array { - if ($tags === []) { - return []; - } - - $rows = $this->fetchRows(self::KIND_TAG, $tags); - $generations = []; - $missing = []; - foreach ($tags as $tag) { - $row = $rows[$tag] ?? null; - if (is_array($row)) { - $generation = $row['payload']; - if (!self::isGeneration($generation)) { - throw new RuntimeException('PDO tag generation contains invalid state.'); - } - $generations[$tag] = strtolower($generation); - - continue; - } - $missing[$tag] = self::newGeneration(); - } - foreach ($missing as $tag => $candidate) { - if (!$this->insertTagGenerationIfMissing($tag, $candidate)) { - throw new RuntimeException('Unable to initialize PDO tag generation.'); - } - } - if ($missing === []) { - return $generations; - } - - $actual = $this->fetchRows(self::KIND_TAG, array_keys($missing)); - foreach ($missing as $tag => $_candidate) { - $generation = $actual[$tag]['payload'] ?? null; - if (!self::isGeneration($generation)) { - throw new RuntimeException('Unable to initialize PDO tag generation.'); - } - $generations[$tag] = strtolower($generation); - } - - return $generations; + return PdoTagGenerationStore::getOrInitialize( + $this->pdo, + $this->driver, + $this->table, + $this->namespace, + $tags, + ); } public function hasItem(string $key): bool @@ -284,37 +252,6 @@ public function saveItems(array $items): bool } - private function insertTagGenerationIfMissing(string $tag, string $generation): bool - { - $sql = match ($this->driver) { - 'pgsql', 'sqlite' => "INSERT INTO {$this->table} " - . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' - . 'ON CONFLICT(namespace, kind, cache_key) DO NOTHING', - 'mysql', 'mariadb' => "INSERT INTO {$this->table} " - . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' - . 'ON DUPLICATE KEY UPDATE cache_key = cache_key', - default => "INSERT INTO {$this->table} " - . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL)', - }; - - try { - return $this->pdo->prepare($sql)->execute([ - $this->namespace, - self::KIND_TAG, - $tag, - strtolower($generation), - ]); - } catch (PDOException $failure) { - if (in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { - throw $failure; - } - - $row = $this->fetchRows(self::KIND_TAG, [$tag])[$tag] ?? null; - - return is_array($row) && self::isGeneration($row['payload']); - } - } - private static function assertSqliteTarget(string $dsn): void { if (!str_starts_with($dsn, 'sqlite:')) { From 86a78a276fe71d6d07225e391c69e4d359536803 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:03:30 +0600 Subject: [PATCH 216/434] refactor(redis): simplify tag generation initialization --- src/Cache/Adapter/RedisCacheAdapter.php | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index cbc51491..12b68ecd 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -407,28 +407,32 @@ private function initializeTagGenerations(array $missing): array { $generations = []; foreach ($missing as $tag => $value) { - $candidate = self::newGeneration(); - $key = $this->mapTag($tag); - if (!is_string($value)) { - $stored = $this->redis->set($key, $candidate, ['nx']); - $current = $stored ? $candidate : $this->redis->get($key); - } else { - $current = RedisValueGuard::replaceIfUnchanged($this->redis, $key, $value, $candidate); - if ($current === false) { - $stored = $this->redis->set($key, $candidate, ['nx']); - $current = $stored ? $candidate : $this->redis->get($key); - } - } - $generation = self::normalizeGeneration($current); - if ($generation === null) { - throw new RuntimeException('Unable to initialize Redis tag generation.'); - } - $generations[$tag] = $generation; + $generations[$tag] = $this->initializeTagGeneration((string) $tag, $value); } return $generations; } + private function initializeTagGeneration(string $tag, mixed $observed): string + { + $candidate = self::newGeneration(); + $key = $this->mapTag($tag); + $current = is_string($observed) + ? RedisValueGuard::replaceIfUnchanged($this->redis, $key, $observed, $candidate) + : false; + if ($current === false) { + $stored = $this->redis->set($key, $candidate, ['nx']); + $current = $stored ? $candidate : $this->redis->get($key); + } + + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException('Unable to initialize Redis tag generation.'); + } + + return $generation; + } + private function map(string $key): string { return $this->ns . ':d:' . $key; From 31b76718fabe61e168167d7862da5cc8d98eead3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:15:46 +0600 Subject: [PATCH 217/434] test(counter): assert exact Redis counter value correctly --- tests/Cache/RedisCachePoolTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index f573ecb9..7c68e835 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -358,8 +358,7 @@ $counters = AtomicCounters::redis('tests', client: $this->redisClient); $large = 9_007_199_254_740_993; - expect($counters->increment('large', $large)) - ->value->toBe($large) + expect($counters->increment('large', $large)->value)->toBe($large) ->and($this->cache->set('ordinary', 'value'))->toBeTrue() ->and($this->cache->clear())->toBeTrue() ->and($counters->get('large'))->toBe($large); From 4c62848c510bf0316f45bcac44da3d3fe9bf9eb1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:15:53 +0600 Subject: [PATCH 218/434] test(redis-cluster): assert fail-open generation repair semantics --- tests/Cache/RedisClusterCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 775e8baa..083e493a 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -393,6 +393,6 @@ private function prune(string $key): void $this->cluster->set($generation, 'malformed-generation'); - expect(fn () => $this->cache->get('generation-race'))->toThrow(RuntimeException::class) + expect($this->cache->get('generation-race'))->toBeNull() ->and($this->cluster->get($generation))->toBe('malformed-generation'); }); From 11588fb83bc53e96ec2a697d4e3ef5edf27215bf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:15:59 +0600 Subject: [PATCH 219/434] test(counter): avoid forbidden process exit helper --- tests/Support/AtomicCounterProcessProbe.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Support/AtomicCounterProcessProbe.php b/tests/Support/AtomicCounterProcessProbe.php index e279ac57..11d9b87e 100644 --- a/tests/Support/AtomicCounterProcessProbe.php +++ b/tests/Support/AtomicCounterProcessProbe.php @@ -37,7 +37,7 @@ public static function initializedWinners( $initialized = $counter->increment($key)->initialized; pcntl_exec('/bin/sh', ['-c', $initialized ? 'true' : 'false']); - exit(255); + throw new RuntimeException('Unable to terminate atomic counter child process.'); } if ($pid > 0) { $children[] = $pid; From 089c357345783bd7f79dc5dbac13b10c7fbc7d5e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:16:04 +0600 Subject: [PATCH 220/434] refactor(shared-memory): return locked bulk reads directly --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 41afbc7a..f14d0c7b 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -239,7 +239,7 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - $items = $this->withSharedLock(function () use ($keys): array { + return $this->withSharedLock(function () use ($keys): array { $store = $this->loadStore(); $items = []; foreach ($keys as $key) { @@ -253,8 +253,6 @@ public function multiFetch(array $keys): array return $items; }); - - return $items; } /** @param list $tags */ From 8645f97b07236c2d76ed972645cde02d007bb3e7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:02 +0600 Subject: [PATCH 221/434] style(memcached): order private helpers for PHPForge --- src/Cache/Adapter/MemcachedCacheAdapter.php | 38 ++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 0e1ab7b2..d0767713 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -360,25 +360,6 @@ private function deleteResultSucceeded(): bool ); } - private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string - { - $generation = self::normalizeGeneration($observed); - if ($generation !== null) { - return $generation; - } - - $candidate = self::newGeneration(); - $current = is_string($observed) - ? MemcachedValueGuard::replaceIfUnchanged($this->client, $key, $observed, $candidate) - : ($this->client->add($key, $candidate) ? $candidate : $this->client->get($key)); - $generation = self::normalizeGeneration($current); - if ($generation === null) { - throw new RuntimeException($failureMessage); - } - - return $generation; - } - /** @return array{value:string, cas:float}|null */ private function extendedGet(string $key): ?array { @@ -399,6 +380,25 @@ private function generationKey(): string return $this->namespace . ':m:generation'; } + private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string + { + $generation = self::normalizeGeneration($observed); + if ($generation !== null) { + return $generation; + } + + $candidate = self::newGeneration(); + $current = is_string($observed) + ? MemcachedValueGuard::replaceIfUnchanged($this->client, $key, $observed, $candidate) + : ($this->client->add($key, $candidate) ? $candidate : $this->client->get($key)); + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException($failureMessage); + } + + return $generation; + } + private function mapData(string $key): string { return $this->namespace . ':d:' . $key; From 32c23c67303a6b98713ea628d8096bdbeb9376ab Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:07 +0600 Subject: [PATCH 222/434] style(redis): order tag generation helpers --- src/Cache/Adapter/RedisCacheAdapter.php | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 12b68ecd..147014dd 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -399,20 +399,6 @@ private function connect(#[\SensitiveParameter] string $dsn): \Redis } } - /** - * @param array $missing - * @return array - */ - private function initializeTagGenerations(array $missing): array - { - $generations = []; - foreach ($missing as $tag => $value) { - $generations[$tag] = $this->initializeTagGeneration((string) $tag, $value); - } - - return $generations; - } - private function initializeTagGeneration(string $tag, mixed $observed): string { $candidate = self::newGeneration(); @@ -433,6 +419,20 @@ private function initializeTagGeneration(string $tag, mixed $observed): string return $generation; } + /** + * @param array $missing + * @return array + */ + private function initializeTagGenerations(array $missing): array + { + $generations = []; + foreach ($missing as $tag => $value) { + $generations[$tag] = $this->initializeTagGeneration((string) $tag, $value); + } + + return $generations; + } + private function map(string $key): string { return $this->ns . ':d:' . $key; From 710ea9b23d73f3825f6b1ad442a6a6016f0d1a05 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:11 +0600 Subject: [PATCH 223/434] style(pdo): order private helpers for PHPForge --- src/Cache/Adapter/PdoCacheAdapter.php | 32 +++++++++++++-------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index d0a46c62..a42e81cb 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -251,7 +251,6 @@ public function saveItems(array $items): bool return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } - private static function assertSqliteTarget(string $dsn): void { if (!str_starts_with($dsn, 'sqlite:')) { @@ -270,6 +269,21 @@ private static function assertSqliteTarget(string $dsn): void } } + /** @param list $keys */ + private function deleteByKind(string $kind, array $keys): bool + { + foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { + $marks = implode(',', array_fill(0, count($chunk), '?')); + $parameters = [$this->namespace, $kind, ...$chunk]; + if (!$this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + )->execute($parameters)) { + return false; + } + } + + return true; + } /** @param list $keys */ private function deleteExpiredKeys(array $keys, int $cutoff): int @@ -290,22 +304,6 @@ private function deleteExpiredKeys(array $keys, int $cutoff): int return $deleted; } - /** @param list $keys */ - private function deleteByKind(string $kind, array $keys): bool - { - foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { - $marks = implode(',', array_fill(0, count($chunk), '?')); - $parameters = [$this->namespace, $kind, ...$chunk]; - if (!$this->pdo->prepare( - "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", - )->execute($parameters)) { - return false; - } - } - - return true; - } - /** * @param list $keys * @return array From 4ac0dacb4ce43d56d669dfdfa599bdc23f06ae58 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:15 +0600 Subject: [PATCH 224/434] style(counter): order private helpers for PHPForge --- src/Counter/RedisAtomicCounterStore.php | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Counter/RedisAtomicCounterStore.php b/src/Counter/RedisAtomicCounterStore.php index 2b471912..00c9522f 100644 --- a/src/Counter/RedisAtomicCounterStore.php +++ b/src/Counter/RedisAtomicCounterStore.php @@ -113,6 +113,18 @@ private function map(string $key): string return self::COUNTER_PREFIX . $this->namespace . ':' . $key; } + private function normalizeTtl(?int $ttlSeconds): int + { + if ($ttlSeconds === null) { + return -1; + } + + if ($ttlSeconds < 1) { + throw new AtomicCounterException('Atomic counter TTL must be greater than zero when provided.'); + } + + return $ttlSeconds; + } private function parseInteger(string $value): int { @@ -131,17 +143,4 @@ private function parseInteger(string $value): int return (int) (($negative && $digits !== '0' ? '-' : '') . $digits); } - - private function normalizeTtl(?int $ttlSeconds): int - { - if ($ttlSeconds === null) { - return -1; - } - - if ($ttlSeconds < 1) { - throw new AtomicCounterException('Atomic counter TTL must be greater than zero when provided.'); - } - - return $ttlSeconds; - } } From 43a0e31db079c0501fe38a0911ffc5a4077cef37 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:35 +0600 Subject: [PATCH 225/434] style(mongodb): separate tag initialization try block --- src/Cache/Adapter/MongoDbCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index ec70c305..e405a01e 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -194,6 +194,7 @@ public function getTagGenerations(array $tags): array } $candidate = self::newGeneration(); + try { $this->collection->updateOne( ['_id' => $this->mapTag($tag)], From 2d823e4adeffffa7501850e0cbc010deb168dfde Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:39 +0600 Subject: [PATCH 226/434] style(pdo): align tag generation PHPDoc --- src/Cache/Adapter/PdoTagGenerationStore.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Cache/Adapter/PdoTagGenerationStore.php b/src/Cache/Adapter/PdoTagGenerationStore.php index 46dfea45..eda8f7b2 100644 --- a/src/Cache/Adapter/PdoTagGenerationStore.php +++ b/src/Cache/Adapter/PdoTagGenerationStore.php @@ -65,8 +65,9 @@ public static function getOrInitialize( return $generations; } - /** @param list $tags - * @return array + /** + * @param list $tags + * @return array */ private static function fetch(PDO $pdo, string $table, string $namespace, array $tags): array { From 9e1a3d5286d2a31831dda9e355fe62be0f2d2f67 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:44 +0600 Subject: [PATCH 227/434] style(cache): normalize class element separation --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 44da0f09..d70c7536 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -274,7 +274,6 @@ private function groupItemsByBucket(array $items): array return $groups; } - private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string { $generation = self::normalizeGeneration($observed); From e3e6bf78d9876696b15f69df99b357bfe5ddad0c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:19:49 +0600 Subject: [PATCH 228/434] style(cache): normalize class element separation --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index 725429ba..da8f1e97 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -406,7 +406,6 @@ private function createSchemaIfMissing(): void } } - /** @param array $generations */ private function insertTagGenerationsIfMissing(array $generations): bool { From 60020dc3ddb7f8994a1a3cb7aae3b0d73bf762ab Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:25:58 +0600 Subject: [PATCH 229/434] style(redis-cluster): separate miss return --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index d70c7536..8743085b 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -94,6 +94,7 @@ public function getItem(string $key): CacheItem if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } + return $this->genericMiss($key); } From d2f0bbb356690a8acb8d9fd9532822c453f953ee Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:26:08 +0600 Subject: [PATCH 230/434] style(node): separate bulk return --- src/Node/Adapter/NodeSqliteCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index da8f1e97..9b6863fb 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -221,6 +221,7 @@ public function multiFetch(array $keys): array } $items[$key] = $this->genericItemFromRecord($key, $record); } + return $items; } From c898d54fe4c78d5c18d7213b2e6a8e620dcb1af3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:29:59 +0600 Subject: [PATCH 231/434] docs(plan): close Batch 5 and start Batch 6 --- ...achelayer-4.0-security-correctness-plan.md | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 0be9dc8c..6f25f4e9 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batches 1-3 complete; Batch 4 next\ +Status: Implementation in progress; Batches 1-5 complete; Batch 6 in progress Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -17,8 +17,8 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | -| 5 — Counters and backend races | R14 plus race review | **In progress** | Counter keyspace/precision work and deterministic backend race review are active after verified Batch 4 closure. | -| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | Not started | Final full-matrix and packaging gate. | +| 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | +| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | **In progress** | Final support-matrix, documentation, migration, packaging, and exact-revision release gates are active. Runwire 2.1 remains optional and is not a 4.0 blocker. | ### Batch 1 tracker @@ -62,14 +62,14 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | -| R08 — memoizer identity | **Implemented; QA pending** | Callable/value fingerprints are type-tagged; same-line closure identity and lifetime-safe object identity coverage added. | Current Batch 4 gate still failing elsewhere. | -| R11 — deferred PSR-6 lifecycle | **Implemented; QA fixes in progress** | Pending reads, snapshot semantics, overwrite/delete/clear ordering, commit retry/finalization paths are being consolidated at the pool boundary. | Run #266 exposed Node bulk-copy and stale legacy expectation failures. | -| R12 — numeric-string keys/tags | **Implemented broadly; QA fixes in progress** | Logical keys/tags are normalized through batch/tag paths instead of being rejected solely because PHP coerces numeric-string array keys. | Run #266 exposed remaining typing/legacy-test cleanup. | -| R13 — skipped-L1 coherence | **Implemented; QA pending** | Tier writes with disabled L1 write-through invalidate/fence upper-tier state; single and bulk regressions added. | Static complexity cleanup still required. | -| R16 — Memcached >30-day TTL | **Implemented; QA fix in progress** | Shared expiration conversion covers atomic and lease paths with long-TTL regressions. | Run #266 exposed one missed ordinary single-save conversion. | -| R17 — direct PSR contracts | **Implemented broadly; QA fixes in progress** | Direct key validation, deferred visibility, missing-delete and expiration contracts are being aligned across adapters. | Full common-contract gate pending. | +| R08 — memoizer identity | **Complete** | Callable/value fingerprints are type-tagged; same-line closures, object/string/resource separation, weak lifetime-safe object identity, and request-reset behavior are covered. | Passed final Batch 4 QA on run #312. | +| R11 — deferred PSR-6 lifecycle | **Complete** | Pending reads are visible before commit; queued values are snapshotted; immediate overwrite/delete/clear reconcile pending state; failed commit state is retained for retry; composed pools use logical item keys. | Passed final Batch 4 QA on run #312. | +| R12 — numeric-string keys/tags | **Complete** | Logical keys/tags are preserved through internal identity encodings and item identities rather than relying on PHP array-key coercion. Numeric-string key/tag, batch, deferred, tier, and boundary cases are covered. | Passed final Batch 4 QA on run #312. | +| R13 — skipped-L1 coherence | **Complete** | Writes that skip L1 invalidate or fence upper-tier state; failed L1 mutation/promotion prevents stale L1 reads until coherence is re-established. Single and batch regressions cover promoted stale values. | Passed final Batch 4 QA on run #312. | +| R16 — Memcached >30-day TTL | **Complete** | One centralized conversion handles ordinary, bulk, atomic, and lease expiration semantics around the 30-day cutoff, including overflow guards. | Passed final Batch 4 QA on run #312. | +| R17 — direct PSR contracts | **Complete** | Direct pools validate public PSR keys consistently; missing deletes succeed, null/expiry/deferred behavior is aligned, and APCu/bulk delete semantics are normalized. | Passed final Batch 4 QA on run #312. | -**Batch 4 current QA evidence:** Security & Standards run #266 reached 296 passing Pest tests but failed seven regressions plus PHPStan/Pint/Rector. These failures are treated as open Batch 4 work; the batch is not closed until an exact-head full gate passes. +**Batch 4 intermediate QA evidence:** Security & Standards run #266 exposed deferred/bulk, Memcached TTL, PHPStan/Pint/Rector regressions. Those failures were resolved before the exact-head closure gate; run #266 is retained here as historical implementation evidence, not current status. **Batch 4 closure evidence:** exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1` passed Security & Standards run #312: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed. Deferred PSR-6 state is visible before commit, immediate writes/deletes/clear reconcile queued state, numeric-string key/tag handling no longer relies on PHP array identity, Tiered/Node bulk copies use logical item keys, skipped-L1 writes fence stale upper-tier data, Memcached long TTLs are normalized, and direct-pool contract regressions are covered. @@ -78,10 +78,21 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Finding | Implementation | Regression evidence | QA state | | --- | --- | --- | --- | -| R14 — Redis/Valkey counter isolation and exact integers | **In progress** | Separate counter keyspace, exact Lua-return parsing, overflow/malformed handling, TTL/decrement/concurrency coverage to be completed. | Pending Batch 5 gate. | -| Backend race review | **In progress** | Stale-read cleanup, tag initialization, clear/write-consume, lease loss, partial bulk failure, and backend false/error handling are being reviewed with deterministic tests where the race is actionable. | Pending Batch 5 gate. | +| R14 — Redis/Valkey counter isolation and exact integers | **Complete** | Counters use the dedicated `cachelayer:counter::` keyspace, so ordinary cache clear cannot erase them. Lua returns the exact post-INCRBY decimal string from the same atomic operation; malformed/out-of-range values fail closed. Redis and Valkey cover >2^53 values, PHP integer limits, decrement, fixed-window TTL, and concurrent initialization. | Passed final Batch 5 QA on run #352. | +| Backend race review | **Complete for planned Batch 5 scope** | Redis/Memcached stale cleanup is compare-safe; unsafe cleanup on other backends is deferred to safe maintenance; PDO expiry pruning uses an expiry predicate; tag initialization races are resolved across APCu/PDO/MongoDB/Node/SharedMemory/Scylla/Redis/Redis Cluster/Memcached. Existing lock-ownership, one-winner, partial-pipeline and backend-status tests cover lease loss, consumption, and partial failures. | Passed final Batch 5 QA on run #352. | +**Batch 5 closure evidence:** exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee` passed Security & Standards run #352: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates passed. Counter precision/isolation, fixed-window TTLs, concurrent initialization, compare-safe stale cleanup, tag-initialization races, lock ownership, partial bulk failure, and backend false/error handling are covered by the final batch state. + +### Batch 6 tracker + +| Gate | Status | Evidence / next step | +| --- | --- | --- | +| R19 — support matrix and tooling | **In progress** | Add explicit PHP 8.3 core-consumer evidence, real MongoDB integration, and honest backend coverage boundaries; keep PHPForge limits unchanged. | +| Documentation / migration / rollback | **In progress** | Consolidate 3.x→4.0 breaking changes, storage/cursor/counter migrations, rollback, security defaults, and topology limits. | +| Packaging / consumer / docs | **In progress** | Add exact candidate clean-consumer and docs-as-errors gates; verify runtime code remains independent of dev packages. | +| Optional Runwire 2.1 | **Deferred for 4.0 core release** | No runtime integration is shipped or advertised in this batch; its optional workstream does not block 4.0.0. | + ## Decision The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. From 413fcecfa5a280ad548064c4ce957e3fa3e8e014 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:32:57 +0600 Subject: [PATCH 232/434] ci(release): add real MongoDB replica-set coverage --- .github/workflows/security-standards.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 1e5882cf..7db234bb 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -18,5 +18,5 @@ jobs: with: php_extensions: "apcu, mbstring, memcached, mongodb, pcntl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" fail_on_skipped_tests: true - integration_services: '["mysql","mariadb","postgres","sqlite","redis","valkey","memcached","scylladb"]' - service_topologies: '{}' + integration_services: '["mysql","mariadb","postgres","sqlite","mongodb","redis","valkey","memcached","scylladb"]' + service_topologies: '{"mongodb":"replica-set"}' From 743e54e76fb2146ca00afadd9ccf86c82ec7b0b1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:33:23 +0600 Subject: [PATCH 233/434] test(mongodb): exercise real replica-set backend --- tests/Cache/MongoDbRealCachePoolTest.php | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Cache/MongoDbRealCachePoolTest.php diff --git a/tests/Cache/MongoDbRealCachePoolTest.php b/tests/Cache/MongoDbRealCachePoolTest.php new file mode 100644 index 00000000..07303b40 --- /dev/null +++ b/tests/Cache/MongoDbRealCachePoolTest.php @@ -0,0 +1,79 @@ +selectDatabase($mongoDatabase)->command(['ping' => 1]); + + $collectionName = 'cachelayer_real_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->mongoClient = $client; + $this->mongoCollection = $client->selectCollection($mongoDatabase, $collectionName); + $this->mongoCache = new Cache(new MongoDbCacheAdapter($this->mongoCollection, 'mongo-real')); +}); + +afterEach(function () { + $this->mongoCollection->drop(); +}); + +test('real MongoDB stores, expires, tags, and clears cache records', function () { + expect($this->mongoCache->set('value', ['ok' => true], 30))->toBeTrue() + ->and($this->mongoCache->get('value'))->toBe(['ok' => true]) + ->and($this->mongoCache->setTagged('tagged', 'v1', ['group'], 30))->toBeTrue() + ->and($this->mongoCache->get('tagged'))->toBe('v1') + ->and($this->mongoCache->invalidateTag('group'))->toBeTrue() + ->and($this->mongoCache->get('tagged'))->toBeNull() + ->and($this->mongoCache->set('clear-me', 'value'))->toBeTrue() + ->and($this->mongoCache->clear())->toBeTrue() + ->and($this->mongoCache->get('clear-me'))->toBeNull(); + + expect($this->mongoCache->set('expires', 'soon', 1))->toBeTrue(); + usleep(2_000_000); + expect($this->mongoCache->get('expires'))->toBeNull(); +}); + +test('real MongoDB atomic claim has exactly one process winner', function () use ($mongoDsn, $mongoDatabase) { + if (!function_exists('pcntl_fork')) { + throw new RuntimeException('pcntl is required for real MongoDB atomic contention coverage.'); + } + + $collectionName = $this->mongoCollection->getCollectionName(); + $children = []; + for ($worker = 0; $worker < 8; ++$worker) { + $pid = pcntl_fork(); + if ($pid === 0) { + $client = new MongoDB\Client($mongoDsn); + $collection = $client->selectCollection($mongoDatabase, $collectionName); + $cache = new Cache(new MongoDbCacheAdapter($collection, 'mongo-real')); + $won = $cache->atomic()?->setIfAbsent('claim', (string) $worker, 30) === true; + pcntl_exec('/bin/sh', ['-c', $won ? 'true' : 'false']); + + throw new RuntimeException('Unable to terminate forked MongoDB contention process.'); + } + if ($pid > 0) { + $children[] = $pid; + } + } + + $wins = 0; + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + $wins += pcntl_wexitstatus($status) === 0 ? 1 : 0; + } + + expect($wins)->toBe(1) + ->and($this->mongoCache->get('claim'))->toBeString(); +}); From b97f2532b5fd8cd0073be6de3dbd57aab79ce4e8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:34:54 +0600 Subject: [PATCH 234/434] test(release): add PHP 8.3 core consumer smoke --- tools/release/php83-smoke.php | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tools/release/php83-smoke.php diff --git a/tools/release/php83-smoke.php b/tools/release/php83-smoke.php new file mode 100644 index 00000000..f251f028 --- /dev/null +++ b/tools/release/php83-smoke.php @@ -0,0 +1,58 @@ += 80400) { + throw new RuntimeException('This release smoke must run on PHP 8.3.'); +} + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$memory = Cache::memory('php83-release'); +$assert($memory->set('plain', ['ok' => true], 30), 'Memory cache write failed.'); +$assert($memory->get('plain') === ['ok' => true], 'Memory cache read failed.'); + +$pending = $memory->getItem('deferred')->set('queued'); +$assert($memory->saveDeferred($pending), 'Deferred queue failed.'); +$assert($memory->get('deferred') === 'queued', 'Deferred value was not visible before commit.'); +$assert($memory->delete('deferred'), 'Deferred delete failed.'); +$assert($memory->commit(), 'Deferred commit failed.'); +$assert($memory->get('deferred') === null, 'Deleted deferred value was resurrected.'); + +$assert($memory->setMultiple(['0' => 'zero', '01' => 'leading', '-1' => 'negative']), 'Numeric-string batch write failed.'); +$values = $memory->getMultiple(['0', '01', '-1']); +$assert($values[0] === 'zero', 'Numeric key 0 did not round-trip.'); +$assert($values['01'] === 'leading', 'Numeric key 01 did not round-trip.'); +$assert($values[-1] === 'negative', 'Numeric key -1 did not round-trip.'); + +$options = new CacheOptions( + integrityKey: str_repeat('k', 32), + allowClosures: false, + allowObjects: false, +); +$signed = Cache::memory('php83-signed', $options); +$assert($signed->set('signed', 'value'), 'Signed cache write failed.'); +$assert($signed->get('signed') === 'value', 'Signed cache read failed.'); + +$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer-release-' . bin2hex(random_bytes(6)); +$file = Cache::file('php83-file', $base . DIRECTORY_SEPARATOR . 'file'); +$phpFiles = Cache::phpFiles('php83-php-files', $base . DIRECTORY_SEPARATOR . 'php-files'); + +$assert($file->set('disk', 'file-value', 30), 'File cache write failed.'); +$assert($file->get('disk') === 'file-value', 'File cache read failed.'); +$assert($phpFiles->set('disk', 'php-file-value', 30), 'PHP-files cache write failed.'); +$assert($phpFiles->get('disk') === 'php-file-value', 'PHP-files cache read failed.'); + +$file->clear(); +$phpFiles->clear(); + +fwrite(STDOUT, "CacheLayer PHP 8.3 release smoke passed.\n"); From ec5b6aa511c99637e07fefe4fedd21c7e4a01dd2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:35:15 +0600 Subject: [PATCH 235/434] test(release): add real Redis Cluster smoke --- tools/release/redis-cluster-smoke.php | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tools/release/redis-cluster-smoke.php diff --git a/tools/release/redis-cluster-smoke.php b/tools/release/redis-cluster-smoke.php new file mode 100644 index 00000000..218833a0 --- /dev/null +++ b/tools/release/redis-cluster-smoke.php @@ -0,0 +1,50 @@ +setMultiple($values, 60), 'Redis Cluster bulk write failed.'); +$read = $cache->getMultiple(array_keys($values)); +$assert($read === $values, 'Redis Cluster bulk read did not preserve values across slots.'); + +$atomic = $cache->atomic(); +$assert($atomic !== null, 'Redis Cluster atomic capability is unavailable.'); +$assert($atomic->setIfAbsent('claim', 'first', 60), 'Redis Cluster first atomic claim failed.'); +$assert(!$atomic->setIfAbsent('claim', 'second', 60), 'Redis Cluster duplicate atomic claim succeeded.'); +$assert($atomic->compareAndSet('claim', 'first', 'replaced', 60), 'Redis Cluster compare-and-set failed.'); +$assert($atomic->getAndDelete('claim', 'missing') === 'replaced', 'Redis Cluster atomic consume failed.'); +$assert($atomic->getAndDelete('claim', 'missing') === 'missing', 'Redis Cluster atomic consume was not one-time.'); + +$assert($cache->setTagged('tagged', 'v1', ['group'], 60), 'Redis Cluster tagged write failed.'); +$assert($cache->invalidateTag('group'), 'Redis Cluster tag invalidation failed.'); +$assert($cache->get('tagged') === null, 'Redis Cluster stale tagged value survived invalidation.'); + +$assert($cache->clear(), 'Redis Cluster namespace clear failed.'); +$assert($cache->get('key-1') === null, 'Redis Cluster clear did not rotate namespace generation.'); + +fwrite(STDOUT, "CacheLayer real Redis Cluster smoke passed.\n"); From 41ca8bb12ca292ec01f18f400065d87342253e1f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:35:34 +0600 Subject: [PATCH 236/434] test(release): add real Scylla CQL smoke --- tools/release/scylla-cql-smoke.php | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tools/release/scylla-cql-smoke.php diff --git a/tools/release/scylla-cql-smoke.php b/tools/release/scylla-cql-smoke.php new file mode 100644 index 00000000..3974eef7 --- /dev/null +++ b/tools/release/scylla-cql-smoke.php @@ -0,0 +1,41 @@ +set('plain', 'value', 60), 'Scylla CQL write failed.'); +$assert($cache->get('plain') === 'value', 'Scylla CQL read failed.'); + +$batch = []; +for ($index = 0; $index < 48; ++$index) { + $batch['batch-' . $index] = $index; +} +$assert($cache->setMultiple($batch, 60), 'Scylla CQL batch write failed.'); +$read = $cache->getMultiple(array_keys($batch)); +$assert($read === $batch, 'Scylla CQL batch read failed.'); + +$assert($cache->setTagged('tagged', 'v1', ['group'], 60), 'Scylla CQL tagged write failed.'); +$assert($cache->invalidateTag('group'), 'Scylla CQL tag rotation failed.'); +$assert($cache->get('tagged') === null, 'Scylla CQL stale tagged record survived invalidation.'); + +$assert($cache->delete('plain'), 'Scylla CQL delete failed.'); +$assert($cache->get('plain') === null, 'Scylla CQL delete did not remove the value.'); +$assert($cache->clear(), 'Scylla CQL namespace clear failed.'); +$assert($cache->get('batch-1') === null, 'Scylla CQL clear did not remove namespace values.'); + +fwrite(STDOUT, "CacheLayer real Scylla CQL smoke passed.\n"); From 7b2172a307bb2e89f8ab13a8f834e5a516af19d6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:35:49 +0600 Subject: [PATCH 237/434] test(release): add independent PSR consumer project --- tools/release/psr-consumer/composer.json | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tools/release/psr-consumer/composer.json diff --git a/tools/release/psr-consumer/composer.json b/tools/release/psr-consumer/composer.json new file mode 100644 index 00000000..97810803 --- /dev/null +++ b/tools/release/psr-consumer/composer.json @@ -0,0 +1,35 @@ +{ + "name": "infocyph/cachelayer-release-psr-consumer", + "type": "project", + "require": { + "php": "^8.4 || ^8.5", + "cache/integration-tests": "^1.0", + "infocyph/cachelayer": "4.0.x-dev", + "phpunit/phpunit": "^11.5 || ^12.0" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "autoload-dev": { + "psr-4": { + "CacheLayerRelease\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": false + }, + "sort-packages": true + } +} From dfbc4ae1267c9e05c908dda05c1da68ff2c2501c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:36:00 +0600 Subject: [PATCH 238/434] test(release): add PSR-6 and PSR-16 integration suites --- .../psr-consumer/tests/PsrIntegrationTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tools/release/psr-consumer/tests/PsrIntegrationTest.php diff --git a/tools/release/psr-consumer/tests/PsrIntegrationTest.php b/tools/release/psr-consumer/tests/PsrIntegrationTest.php new file mode 100644 index 00000000..a9d811a4 --- /dev/null +++ b/tools/release/psr-consumer/tests/PsrIntegrationTest.php @@ -0,0 +1,27 @@ + Date: Mon, 28 Sep 2026 21:37:46 +0600 Subject: [PATCH 239/434] ci(release): add final compatibility and backend gates --- .github/workflows/release-verification.yml | 143 +++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .github/workflows/release-verification.yml diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml new file mode 100644 index 00000000..31128fff --- /dev/null +++ b/.github/workflows/release-verification.yml @@ -0,0 +1,143 @@ +name: "Release Verification" + +on: + pull_request: + branches: [ "main", "master", "develop", "development" ] + push: + branches: [ "main", "master" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + php83-core: + name: PHP 8.3 core consumer (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + extensions: mbstring, pdo, pdo_sqlite, opcache + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - run: composer check-platform-reqs --no-dev + - name: Lint production source on PHP 8.3 + if: runner.os != 'Windows' + shell: bash + run: find src -name '*.php' -print0 | xargs -0 -n1 php -l + - name: Lint production source on PHP 8.3 (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Get-ChildItem src -Recurse -Filter *.php | ForEach-Object { php -l $_.FullName; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + - name: Core smoke without CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=0 tools/release/php83-smoke.php + - name: Core smoke with CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=1 tools/release/php83-smoke.php + + psr-contracts: + name: Independent PSR-6 / PSR-16 contracts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + - name: Install independent consumer + working-directory: tools/release/psr-consumer + run: composer update --no-interaction --prefer-dist --no-progress --prefer-stable + - name: Run upstream integration suites + working-directory: tools/release/psr-consumer + run: vendor/bin/phpunit tests/PsrIntegrationTest.php + + docs: + name: Documentation warnings as errors + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: python -m pip install --disable-pip-version-check -r docs/requirements.txt + - run: python -m sphinx -W --keep-going -b html docs docs/_build/html + + redis-cluster: + name: Real Redis Cluster + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + extensions: redis + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - name: Start three-node Redis Cluster + shell: bash + run: | + set -euo pipefail + for port in 7001 7002 7003; do + docker run -d --name "cachelayer-redis-$port" --network host redis:8.10-alpine redis-server --port "$port" --cluster-enabled yes --cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 --cluster-announce-ip 127.0.0.1 --cluster-announce-port "$port" --cluster-announce-bus-port "$((port + 10000))" --appendonly no --protected-mode no + done + for attempt in {1..60}; do + if docker exec cachelayer-redis-7001 redis-cli -p 7001 ping | grep -q PONG; then + break + fi + sleep 1 + done + docker exec cachelayer-redis-7001 redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 --cluster-yes + docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | grep -q 'cluster_state:ok' + - name: Exercise CacheLayer against real Redis Cluster + env: + CACHELAYER_REDIS_CLUSTER_SEEDS: "127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003" + run: php -d error_reporting=E_ALL tools/release/redis-cluster-smoke.php + - if: always() + shell: bash + run: docker rm -f cachelayer-redis-7001 cachelayer-redis-7002 cachelayer-redis-7003 >/dev/null 2>&1 || true + + scylla-cql: + name: Real Scylla CQL + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - name: Start ScyllaDB CQL service + shell: bash + run: | + set -euo pipefail + docker run -d --name cachelayer-scylla -p 9042:9042 scylladb/scylla:2026.2 --smp 1 --memory 1G --overprovisioned 1 --developer-mode=1 + for attempt in {1..120}; do + if docker exec cachelayer-scylla cqlsh -e 'SELECT release_version FROM system.local;' >/dev/null 2>&1; then + break + fi + sleep 2 + done + docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'SimpleStrategy','replication_factor':1};" + - name: Install pinned Scylla PHP CQL driver + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libuv1t64 libgmp10 + mkdir -p .ci/scylla-driver + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-24.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz + tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver + php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' + - name: Exercise CacheLayer against real CQL + run: php -d error_reporting=E_ALL -d "extension=$PWD/.ci/scylla-driver/cassandra.so" tools/release/scylla-cql-smoke.php + - if: always() + run: docker rm -f cachelayer-scylla >/dev/null 2>&1 || true From a7f22618c8a9cebecabd01b500cf5ad94ef99025 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:39:58 +0600 Subject: [PATCH 240/434] build: raise minimum PHP version to 8.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0a90ca86..7684545a 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.4", "opis/closure": "^4.5", "psr/cache": "^3.0", "psr/simple-cache": "^3.0" From a5055afe6c736ce7b889bab0051fe486333d8d82 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:40:01 +0600 Subject: [PATCH 241/434] docs: document PHP 8.4 minimum --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c67e26b..0dd78a76 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Packagist Version](https://img.shields.io/packagist/v/infocyph/CacheLayer) ![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/CacheLayer/php) -CacheLayer is a PHP 8.3+ caching toolkit built around four deliberately separate concerns: +CacheLayer is a PHP 8.4+ caching toolkit built around four deliberately separate concerns: ```text CacheLayer From bf77824ca16e499ff0bc8b8da626f03200b75f8e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:40:05 +0600 Subject: [PATCH 242/434] docs: align manual with PHP 8.4 floor --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index cce95b18..d5675443 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,7 @@ CacheLayer Manual ================= -CacheLayer is a standalone caching toolkit for PHP 8.3+ with: +CacheLayer is a standalone caching toolkit for PHP 8.4+ with: * PSR-6 and PSR-16 support behind one facade (``Cache``) * local, distributed, and cloud cache adapters From 82fe54855c3c38078e5a2ae17598879790e69204 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:40:09 +0600 Subject: [PATCH 243/434] ci(release): align release matrix with PHP 8.4 floor --- .github/workflows/release-verification.yml | 30 ---------------------- 1 file changed, 30 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 31128fff..fee1abb8 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -11,36 +11,6 @@ permissions: contents: read jobs: - php83-core: - name: PHP 8.3 core consumer (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, windows-latest] - steps: - - uses: actions/checkout@v7 - - uses: shivammathur/setup-php@v2 - with: - php-version: "8.3" - tools: composer:v2 - extensions: mbstring, pdo, pdo_sqlite, opcache - coverage: none - - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - - run: composer check-platform-reqs --no-dev - - name: Lint production source on PHP 8.3 - if: runner.os != 'Windows' - shell: bash - run: find src -name '*.php' -print0 | xargs -0 -n1 php -l - - name: Lint production source on PHP 8.3 (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: Get-ChildItem src -Recurse -Filter *.php | ForEach-Object { php -l $_.FullName; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - - name: Core smoke without CLI OPcache - run: php -d error_reporting=E_ALL -d opcache.enable_cli=0 tools/release/php83-smoke.php - - name: Core smoke with CLI OPcache - run: php -d error_reporting=E_ALL -d opcache.enable_cli=1 tools/release/php83-smoke.php - psr-contracts: name: Independent PSR-6 / PSR-16 contracts runs-on: ubuntu-24.04 From c57f8f8785a93814c4ca002c44af3e426486b74b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:40:13 +0600 Subject: [PATCH 244/434] test(release): remove obsolete PHP 8.3 smoke --- tools/release/php83-smoke.php | 58 ----------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 tools/release/php83-smoke.php diff --git a/tools/release/php83-smoke.php b/tools/release/php83-smoke.php deleted file mode 100644 index f251f028..00000000 --- a/tools/release/php83-smoke.php +++ /dev/null @@ -1,58 +0,0 @@ -= 80400) { - throw new RuntimeException('This release smoke must run on PHP 8.3.'); -} - -$assert = static function (bool $condition, string $message): void { - if (!$condition) { - throw new RuntimeException($message); - } -}; - -$memory = Cache::memory('php83-release'); -$assert($memory->set('plain', ['ok' => true], 30), 'Memory cache write failed.'); -$assert($memory->get('plain') === ['ok' => true], 'Memory cache read failed.'); - -$pending = $memory->getItem('deferred')->set('queued'); -$assert($memory->saveDeferred($pending), 'Deferred queue failed.'); -$assert($memory->get('deferred') === 'queued', 'Deferred value was not visible before commit.'); -$assert($memory->delete('deferred'), 'Deferred delete failed.'); -$assert($memory->commit(), 'Deferred commit failed.'); -$assert($memory->get('deferred') === null, 'Deleted deferred value was resurrected.'); - -$assert($memory->setMultiple(['0' => 'zero', '01' => 'leading', '-1' => 'negative']), 'Numeric-string batch write failed.'); -$values = $memory->getMultiple(['0', '01', '-1']); -$assert($values[0] === 'zero', 'Numeric key 0 did not round-trip.'); -$assert($values['01'] === 'leading', 'Numeric key 01 did not round-trip.'); -$assert($values[-1] === 'negative', 'Numeric key -1 did not round-trip.'); - -$options = new CacheOptions( - integrityKey: str_repeat('k', 32), - allowClosures: false, - allowObjects: false, -); -$signed = Cache::memory('php83-signed', $options); -$assert($signed->set('signed', 'value'), 'Signed cache write failed.'); -$assert($signed->get('signed') === 'value', 'Signed cache read failed.'); - -$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer-release-' . bin2hex(random_bytes(6)); -$file = Cache::file('php83-file', $base . DIRECTORY_SEPARATOR . 'file'); -$phpFiles = Cache::phpFiles('php83-php-files', $base . DIRECTORY_SEPARATOR . 'php-files'); - -$assert($file->set('disk', 'file-value', 30), 'File cache write failed.'); -$assert($file->get('disk') === 'file-value', 'File cache read failed.'); -$assert($phpFiles->set('disk', 'php-file-value', 30), 'PHP-files cache write failed.'); -$assert($phpFiles->get('disk') === 'php-file-value', 'PHP-files cache read failed.'); - -$file->clear(); -$phpFiles->clear(); - -fwrite(STDOUT, "CacheLayer PHP 8.3 release smoke passed.\n"); From 649977ea97d65b4558b3ff4cf16a3b2ede9fafff Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:40:17 +0600 Subject: [PATCH 245/434] docs(plan): adopt PHP 8.4 minimum for 4.0 --- .../cachelayer-4.0-security-correctness-plan.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 6f25f4e9..5fcd0cd6 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -88,7 +88,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Gate | Status | Evidence / next step | | --- | --- | --- | -| R19 — support matrix and tooling | **In progress** | Add explicit PHP 8.3 core-consumer evidence, real MongoDB integration, and honest backend coverage boundaries; keep PHPForge limits unchanged. | +| R19 — support matrix and tooling | **In progress** | Use the PHPForge PHP 8.4/8.5 matrix, real MongoDB integration, and honest backend coverage boundaries; keep PHPForge limits unchanged. | | Documentation / migration / rollback | **In progress** | Consolidate 3.x→4.0 breaking changes, storage/cursor/counter migrations, rollback, security defaults, and topology limits. | | Packaging / consumer / docs | **In progress** | Add exact candidate clean-consumer and docs-as-errors gates; verify runtime code remains independent of dev packages. | | Optional Runwire 2.1 | **Deferred for 4.0 core release** | No runtime integration is shipped or advertised in this batch; its optional workstream does not block 4.0.0. | @@ -99,9 +99,9 @@ The library needs changes before another release can be called ready. The audit Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. -Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. Keep PHP 8.3 support unless a separate, justified compatibility decision changes it; add real PHP 8.3 coverage. A PHP floor increase is not required by these fixes. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. +Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. CacheLayer 4.0 raises the minimum runtime to PHP 8.4 by maintainer decision. The release matrix therefore targets PHP 8.4 and 8.5. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. -Track Runwire 2.1 integration as an optional target for 4.0. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. An executable invalidation-worker example demonstrates this behavior if the workstream is selected for implementation. It is optional both as release scope and as a consumer dependency: deferring the entire workstream does not block 4.0.0. Retain PHP 8.3 support in the core. Any shipped integration requires a demonstrated need and the conditional gates below. +Track Runwire 2.1 integration as an optional target for 4.0. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. An executable invalidation-worker example demonstrates this behavior if the workstream is selected for implementation. It is optional both as release scope and as a consumer dependency: deferring the entire workstream does not block 4.0.0. The core minimum is PHP 8.4. Any shipped integration requires a demonstrated need and the conditional gates below. This document follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and the applicable [PHPForge AGENTS.md workflow](../../vendor/infocyph/phpforge/resources/AGENTS.md). @@ -361,7 +361,7 @@ All checkboxes below are open. Each batch is a separately reviewable change with The assessment inspected local Runwire tag `2.1` (`e6a954df1ec90aef98daf8248bd02f741f9324e3`). This establishes available APIs and platform requirements, not successful CacheLayer integration or a measured throughput benefit. Worker supervision, structured coroutines, and lifecycle support already existed before 2.1; the principal 2.1 additions concern adaptive HTTP scheduling. -Runwire requires 64-bit PHP 8.4+, while CacheLayer supports PHP 8.3+. If selected, implement the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require` or make the default PHP 8.3 development/test installation require it. Add a Composer suggestion only when usable integration documentation exists. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the example; do not introduce a generic runtime abstraction speculatively. +Runwire requires 64-bit PHP 8.4+, matching CacheLayer 4.0's minimum PHP version. If selected, implement the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require`; keep it an optional integration. Add a Composer suggestion only when usable integration documentation exists. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the example; do not introduce a generic runtime abstraction speculatively. The core must remain usable without Runwire installed, including ordinary PHP-FPM execution. No supervisor, listener, timer, connection, or worker may start during autoload or cache construction. A host that already owns its process pool retains that ownership. The 4.0 major-version decision does not change these dependency and runtime boundaries. @@ -382,7 +382,7 @@ Use the active runtime's public context and supported lifecycle hooks. Runwire 2 1. **Supervised cluster invalidation — first deliverable if selected.** Wrap existing `ClusterRuntime::consume()` calls in bounded scheduled work with explicit batch size, polling interval, backend timeouts, retry/backoff, and shutdown budgets. Preserve serial consumption within each complete cursor scope; independent scopes may run independently. Create backend connections in worker bootstrap after a fork. Expose consumed counts, failures, consumer lag, and restart behavior without unbounded metric labels. Resolve R06/R07 before relying on durable progress and R15 before claiming node-wide L1 coherence. A separate CLI consumer must not be described as clearing unrelated FPM/worker APCu domains automatically. 2. **Bounded maintenance — evaluate for inclusion.** Schedule existing `NodeCacheMaintenance::pruneExpired()`, `checkpoint()`, and `optimize()` at explicit operational intervals. Bound prune batches and prevent overlapping maintenance against the same store. Measure SQLite writer contention and choose heavier maintenance windows accordingly. Do not place full scans or maintenance on the request hot path. Supervision does not make an individual blocking database operation cancellable. 3. **Persistent request lifecycle — conditional on the host integration.** After R08 and the relevant deferred-state fixes, connect request-owned memoizer/state cleanup to Runwire's completion/reset lifecycle, including failure, cancellation, and deadline paths. `flush_memoizers()` is suitable only for a sequential lifecycle with an explicit ownership contract. Concurrent requests need isolated memoizer state, potentially through Runwire task-local context or an explicit request-owned instance; one request must not flush or observe another request's state. Preserve intentional cross-request cache data and resolve pending deferred writes under their documented contract. -4. **Crash and concurrency verification — usable during earlier batches.** Evaluate Runwire's bounded subprocess runner for recursive-payload probes and its worker supervision for real restart/concurrency tests. Set explicit PHP memory, execution-time, and output limits; execute validated argument vectors. `ProcessRunner` is synchronous and is not itself a parallel worker pool or OS sandbox. Retain a lightweight existing subprocess harness if adopting Runwire adds complexity without improving evidence. PHP 8.3 core regression coverage must remain available independently. +4. **Crash and concurrency verification — usable during earlier batches.** Evaluate Runwire's bounded subprocess runner for recursive-payload probes and its worker supervision for real restart/concurrency tests. Set explicit PHP memory, execution-time, and output limits; execute validated argument vectors. `ProcessRunner` is synchronous and is not itself a parallel worker pool or OS sandbox. Retain a lightweight existing subprocess harness if adopting Runwire adds complexity without improving evidence. Core regression coverage must remain available independently of Runwire. Existing PDO, filesystem, and synchronous native-client calls remain blocking inside Runwire coroutines. Prefer existing backend bulk operations and bounded dedicated workers where suitable. Do not wrap each cache operation in a coroutine or process and claim asynchronous I/O or a speedup. Runwire's in-process coroutine synchronization also does not replace CacheLayer's cross-process/distributed lock and atomicity contracts. @@ -390,7 +390,7 @@ Existing PDO, filesystem, and synchronous native-client calls remain blocking in The entire Runwire workstream, including the invalidation-worker example, may be deferred without blocking 4.0.0. Record an inclusion/defer decision based on concrete need and evidence; the checkboxes in this section apply only to capabilities selected for shipping. Every shipped capability must pass its applicable gates; deferred capabilities must remain explicitly unadvertised. None of these decisions excuses any R01–R19 requirement. -- [ ] Keep a clean PHP 8.3 consumer and the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. +- [ ] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. - [ ] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. - [ ] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. - [ ] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. @@ -418,7 +418,7 @@ These are not substitutes for the required fixes: ### Correctness and security - [ ] Every R01–R19 item is resolved with targeted evidence or, for a suspected source finding, disproved with a documented test on the actual affected backend. -- [ ] Run real PHP 8.3, 8.4, and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. +- [ ] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. - [ ] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. - [ ] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. - [ ] Verify executable-file and ordinary-file behavior with OPcache enabled/disabled, Linux permissions, and Windows where supported. Test failure paths without granting the cache process excess permissions. From 435222867d3a399d95ad1a9e145400c4469d449a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:42:00 +0600 Subject: [PATCH 246/434] test(release): add cross-platform core smoke --- tools/release/core-smoke.php | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tools/release/core-smoke.php diff --git a/tools/release/core-smoke.php b/tools/release/core-smoke.php new file mode 100644 index 00000000..be182b15 --- /dev/null +++ b/tools/release/core-smoke.php @@ -0,0 +1,53 @@ +set('plain', ['ok' => true], 30), 'Memory cache write failed.'); +$assert($memory->get('plain') === ['ok' => true], 'Memory cache read failed.'); + +$pending = $memory->getItem('deferred')->set('queued'); +$assert($memory->saveDeferred($pending), 'Deferred queue failed.'); +$assert($memory->get('deferred') === 'queued', 'Deferred value was not visible before commit.'); +$assert($memory->delete('deferred'), 'Deferred delete failed.'); +$assert($memory->commit(), 'Deferred commit failed.'); +$assert($memory->get('deferred') === null, 'Deleted deferred value was resurrected.'); + +$assert($memory->setMultiple(['0' => 'zero', '01' => 'leading', '-1' => 'negative']), 'Numeric-string batch write failed.'); +$values = $memory->getMultiple(['0', '01', '-1']); +$assert($values[0] === 'zero', 'Numeric key 0 did not round-trip.'); +$assert($values['01'] === 'leading', 'Numeric key 01 did not round-trip.'); +$assert($values[-1] === 'negative', 'Numeric key -1 did not round-trip.'); + +$options = new CacheOptions( + integrityKey: str_repeat('k', 32), + allowClosures: false, + allowObjects: false, +); +$signed = Cache::memory('release-signed', $options); +$assert($signed->set('signed', 'value'), 'Signed cache write failed.'); +$assert($signed->get('signed') === 'value', 'Signed cache read failed.'); + +$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer-release-' . bin2hex(random_bytes(6)); +$file = Cache::file('release-file', $base . DIRECTORY_SEPARATOR . 'file'); +$phpFiles = Cache::phpFiles('release-php-files', $base . DIRECTORY_SEPARATOR . 'php-files'); + +$assert($file->set('disk', 'file-value', 30), 'File cache write failed.'); +$assert($file->get('disk') === 'file-value', 'File cache read failed.'); +$assert($phpFiles->set('disk', 'php-file-value', 30), 'PHP-files cache write failed.'); +$assert($phpFiles->get('disk') === 'php-file-value', 'PHP-files cache read failed.'); +$assert($file->clear(), 'File cache clear failed.'); +$assert($phpFiles->clear(), 'PHP-files cache clear failed.'); + +fwrite(STDOUT, sprintf("CacheLayer core release smoke passed on PHP %s.\n", PHP_VERSION)); From c28fc0a426faa5784f1e1b775f2a23598bc98c6a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:42:16 +0600 Subject: [PATCH 247/434] test(release): add clean production consumer fixture --- tools/release/consumer/composer.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tools/release/consumer/composer.json diff --git a/tools/release/consumer/composer.json b/tools/release/consumer/composer.json new file mode 100644 index 00000000..ac31dc44 --- /dev/null +++ b/tools/release/consumer/composer.json @@ -0,0 +1,25 @@ +{ + "name": "infocyph/cachelayer-release-consumer", + "type": "project", + "require": { + "php": ">=8.4", + "infocyph/cachelayer": "4.0.x-dev" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} From b57434f3ade4265150f57920e41f1617ab0af861 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:42:20 +0600 Subject: [PATCH 248/434] test(release): add clean consumer smoke --- tools/release/consumer/smoke.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tools/release/consumer/smoke.php diff --git a/tools/release/consumer/smoke.php b/tools/release/consumer/smoke.php new file mode 100644 index 00000000..b7530840 --- /dev/null +++ b/tools/release/consumer/smoke.php @@ -0,0 +1,17 @@ +set('ready', ['version' => 4], 30)) { + throw new RuntimeException('Consumer cache write failed.'); +} +if ($cache->get('ready') !== ['version' => 4]) { + throw new RuntimeException('Consumer cache read failed.'); +} + +fwrite(STDOUT, "CacheLayer production consumer smoke passed.\n"); From 81627565a1e335ef345a73533adb5ba4716fccca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:43:15 +0600 Subject: [PATCH 249/434] ci(release): cover PHP 8.4+ platforms and consumers --- .github/workflows/release-verification.yml | 70 ++++++++++++++++++---- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index fee1abb8..7509cf7c 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -11,6 +11,54 @@ permissions: contents: read jobs: + core-platform: + name: Core smoke - PHP ${{ matrix.php }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + os: [ ubuntu-24.04, windows-latest ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: mbstring, pdo, pdo_sqlite, opcache + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - run: composer check-platform-reqs --no-dev + - name: Core smoke without CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=0 tools/release/core-smoke.php + - name: Core smoke with CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=1 tools/release/core-smoke.php + + clean-consumer: + name: Clean no-dev consumer - PHP ${{ matrix.php }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + - name: Resolve candidate in fresh consumer + working-directory: tools/release/consumer + run: composer update --no-dev --no-interaction --prefer-dist --no-progress + - name: Reinstall locked candidate as production-only + working-directory: tools/release/consumer + shell: bash + run: rm -rf vendor && composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-progress + - name: Smoke production consumer + working-directory: tools/release/consumer + run: php -d error_reporting=E_ALL smoke.php + psr-contracts: name: Independent PSR-6 / PSR-16 contracts runs-on: ubuntu-24.04 @@ -56,16 +104,14 @@ jobs: run: | set -euo pipefail for port in 7001 7002 7003; do - docker run -d --name "cachelayer-redis-$port" --network host redis:8.10-alpine redis-server --port "$port" --cluster-enabled yes --cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 --cluster-announce-ip 127.0.0.1 --cluster-announce-port "$port" --cluster-announce-bus-port "$((port + 10000))" --appendonly no --protected-mode no + docker run -d --name "cachelayer-redis-$port" --network host redis:8.10-alpine redis-server --port "$port" --cluster-enabled yes --cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 --cluster-announce-ip 127.0.0.1 --cluster-announce-port "$port" --cluster-announce-bus-port "$((port + 10000))" --appendonly no --protected-mode no done for attempt in {1..60}; do - if docker exec cachelayer-redis-7001 redis-cli -p 7001 ping | grep -q PONG; then - break - fi + if docker exec cachelayer-redis-7001 redis-cli -p 7001 ping | grep -q PONG; then break; fi sleep 1 done - docker exec cachelayer-redis-7001 redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 --cluster-yes - docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | grep -q 'cluster_state:ok' + docker exec cachelayer-redis-7001 redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 --cluster-yes + docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | grep -q "cluster_state:ok" - name: Exercise CacheLayer against real Redis Cluster env: CACHELAYER_REDIS_CLUSTER_SEEDS: "127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003" @@ -89,14 +135,12 @@ jobs: shell: bash run: | set -euo pipefail - docker run -d --name cachelayer-scylla -p 9042:9042 scylladb/scylla:2026.2 --smp 1 --memory 1G --overprovisioned 1 --developer-mode=1 + docker run -d --name cachelayer-scylla -p 9042:9042 scylladb/scylla:2026.2 --reactor-backend epoll --smp 1 --memory 1G --overprovisioned 1 --developer-mode=1 for attempt in {1..120}; do - if docker exec cachelayer-scylla cqlsh -e 'SELECT release_version FROM system.local;' >/dev/null 2>&1; then - break - fi + if docker exec cachelayer-scylla cqlsh -e "SELECT release_version FROM system.local;" >/dev/null 2>&1; then break; fi sleep 2 done - docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'SimpleStrategy','replication_factor':1};" + docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'SimpleStrategy','replication_factor':1};" - name: Install pinned Scylla PHP CQL driver shell: bash run: | @@ -104,9 +148,9 @@ jobs: sudo apt-get update sudo apt-get install -y libuv1t64 libgmp10 mkdir -p .ci/scylla-driver - curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-24.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-24.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver - php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' + php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' - name: Exercise CacheLayer against real CQL run: php -d error_reporting=E_ALL -d "extension=$PWD/.ci/scylla-driver/cassandra.so" tools/release/scylla-cql-smoke.php - if: always() From 532347e85907078e0790cc2917cf149c7c77c601 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:44:50 +0600 Subject: [PATCH 250/434] docs(release): add 3.x to 4.0 upgrade guide --- docs/upgrade-4.0.rst | 151 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/upgrade-4.0.rst diff --git a/docs/upgrade-4.0.rst b/docs/upgrade-4.0.rst new file mode 100644 index 00000000..2395dd19 --- /dev/null +++ b/docs/upgrade-4.0.rst @@ -0,0 +1,151 @@ +======================== +Upgrading from 3.x to 4.0 +======================== + +CacheLayer 4.0 is an intentional breaking release with a minimum runtime of +PHP 8.4. It does not preserve 3.x API shape, named parameters, defaults, +storage formats, schemas, or behavioral contracts merely for backward +compatibility. + +Use a coordinated cutover. Do not run mixed 3.x and 4.0 writers against the +same mutable cache state unless that exact combination has been proven safe. +For disposable cache values, a cold 4.0 namespace is usually the cleanest +upgrade path. Preserve and explicitly migrate durable coordination state. + +Runtime floor +============= + +CacheLayer 4.0 requires PHP 8.4 or newer. Upgrade the application runtime before +installing the package. The release gates cover PHP 8.4 and PHP 8.5. + +Serialization and authenticated records +======================================== + +4.0 disables object and Closure deserialization by default. Enable either only +through an explicit ``CacheOptions`` policy after reviewing the trust boundary. + +When ``integrityKey`` is configured, signed records use the 4.0 authenticated +envelope. The signature binds the record purpose, logical storage identity, +and logical cache key. A signed blob copied to a different key or namespace is +rejected. Legacy unbound signed records are not silently accepted. + +For a coordinated upgrade: + +* stop 3.x writers before enabling 4.0 writers for the same logical store; +* cold-clear disposable cached payloads when record compatibility is uncertain; +* keep integrity keys in secret storage and out of exception messages, logs, + DSNs, and configuration dumps; +* do not re-enable object or Closure deserialization solely to keep old cache + entries readable. + +Node Cache identity and policy +============================== + +Node Cache derives its L1 and lock identity from the SQLite store plus +namespace. Two SQLite stores using the same namespace therefore do not share +an APCu or lock identity accidentally. + +``NodeCacheConfig`` carries one cohesive ``CacheOptions`` policy. Review Node +construction code that previously relied on independent defaults. L1 mutation +failures are fenced so an old promoted value cannot override authoritative +SQLite after a lower-tier update. + +APCu remains process/SAPI local. A CLI invalidation consumer does not clear +unrelated FPM or worker APCu domains. Every advertised topology needs its own +consumer lifecycle or an explicit topology constraint. + +SQL identity collation +====================== + +MySQL and MariaDB cache and invalidation identity columns are byte-sensitive in +4.0 using ``ascii_bin``. New tables are created with the correct collation. +Existing tables are metadata-checked and altered only when required. + +Before the first 4.0 process uses an existing SQL store: + +1. back up durable invalidation and event state; +2. inspect identity columns and application namespaces for case-folded data; +3. stop mixed-version writers; +4. allow the 4.0 schema installer to harden the identity columns, or perform + the equivalent reviewed migration during a maintenance window; +5. verify case-distinct namespaces and keys after migration. + +Disposable cache rows may be dropped and rebuilt. Do not treat invalidation +history, authorization/replay state, or other durable coordination data as +ordinary cache rows. + +Cluster cursor v3 +================= + +Cluster cursor identity is scoped by cluster, node, namespace, and transport +identity. Legacy ``(cluster,node)`` and intermediate +``(cluster,node,namespace)`` cursors are not copied into the new scope. + +When an old cursor format is detected, CacheLayer clears the affected local +namespace before establishing new progress. This trades cache warmth for proof +that an old shared cursor cannot skip invalidations. + +Keep transport retention long enough for deployment and outage windows. After +cutover, verify every node has a stable node ID, namespace, transport identity, +and its own consumer. + +Atomic counter keyspace +======================= + +Redis and Valkey counters live under +``cachelayer:counter::``. Ordinary cache ``clear()`` operations +do not touch this keyspace. + +If 3.x counters carry security-relevant windows, quotas, replay attempts, or +rate limits, migrate them deliberately while 3.x writers are stopped. Do not +delete old counters until the application has copied the required state or +intentionally allowed the old windows to expire. + +4.0 validates exact decimal counter results against the PHP integer range. +Malformed and out-of-range stored values fail closed instead of saturating. + +PSR and cache behavior changes +============================== + +4.0 corrects several observable behaviors: + +* PSR-6 deferred values are visible through reads before ``commit()``; +* immediate save, delete, and clear operations reconcile queued deferred state + instead of allowing a later commit to resurrect an older value; +* numeric-string keys and tags preserve logical identity through batching and + tier promotion; +* tiered caches that skip L1 write-through invalidate or fence stale L1 state; +* Memcached relative TTLs longer than 30 days are converted to Memcached's + absolute-expiration form; +* direct PSR-6 pools validate public keys consistently and missing deletes + succeed where the PSR contract requires it. + +Durable invalidation +==================== + +PDO invalidation publication is commit-safe within a cluster scope. Event IDs +are allocated while holding the cluster publication lock so commit reordering +cannot make a later event permanently hide an earlier one. + +Cursor state is scoped by cluster, node, namespace, and transport identity. +Retention gaps trigger a local namespace clear before progress is advanced. +Permanent poison events are not skipped silently. + +Rollback +======== + +Rollback means restoring the complete previous release and its compatible +storage configuration, not merely changing the Composer version. + +Before deployment record: + +* the exact 4.0 commit or tag; +* PHP and extension versions; +* database/cache backend versions; +* schema and cursor versions; +* namespace and transport identities; +* any migrated durable counter or invalidation state. + +If rollback is required, stop 4.0 writers first. Restore the previous release +with storage it can safely interpret. Do not point 3.x readers at 4.0 +authenticated records or cursor state and assume compatibility. From 5ca9c2d6c4b0dbcdecd15a81d18b99f39aaffb33 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:44:54 +0600 Subject: [PATCH 251/434] docs(release): link 4.0 upgrade guide --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index d5675443..3d8c2ea5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,7 @@ Quick Start :caption: Guide cache + upgrade-4.0 counters adapters/index cookbook From caca4cf80c763afef9f89386740110d9210302f0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:44:59 +0600 Subject: [PATCH 252/434] docs(release): point 3.x users to 4.0 migration guide --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0dd78a76..af1b3f64 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ composer require infocyph/cachelayer Choose extensions and client packages only for the backends you use: APCu, Redis/Valkey, Memcached, PDO, SysV shared memory, MongoDB, or Cassandra/ScyllaDB. +For 3.x upgrades, read `docs/upgrade-4.0.rst` before deployment. 4.0 raises the minimum runtime to PHP 8.4 and intentionally changes serialization, storage identity, cursor, counter, and cache-contract behavior. + ## Cache ```php From 7f499145378ac2346f7c7c6dad0242c44f8b6ebc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:45:04 +0600 Subject: [PATCH 253/434] docs: align regression template with PHP 8.4 floor --- .github/ISSUE_TEMPLATE/regression_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml index 36392bca..b7ef6027 100644 --- a/.github/ISSUE_TEMPLATE/regression_report.yml +++ b/.github/ISSUE_TEMPLATE/regression_report.yml @@ -46,6 +46,6 @@ body: attributes: label: Environment details description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + placeholder: PHP 8.4, Composer 2.10, Ubuntu 24.04... validations: required: true From f74a7df1e6d16d2b7f55cbad2f041d9507b94810 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:45:09 +0600 Subject: [PATCH 254/434] docs(plan): align audit notes with PHP 8.4 floor --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 5fcd0cd6..a854a013 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -154,7 +154,7 @@ php -d apc.enable_cli=1 vendor/bin/pest \ An initial direct Pest attempt without the bundled configuration failed with `Could not read XML from file "--cache-directory"`; the explicit configuration above resolved it. The sandbox could not start (`bubblewrap: mountinfo path is not absolute`), so approved host execution was used. These were tooling issues, not library test failures. -No complete MySQL/MariaDB, MongoDB, Scylla CQL, Redis Cluster, Windows, PHP 8.3/8.4, clean production consumer, documentation build, sustained-RPM benchmark, or worker soak gate passed during this audit. `sphinx-build` was unavailable. PostgreSQL testing below exercised transaction ordering through `psql`, not the library's PDO driver. Current CI on a future final revision remains required. +No complete MySQL/MariaDB, MongoDB, Scylla CQL, Redis Cluster, Windows, PHP 8.4/8.5, clean production consumer, documentation build, sustained-RPM benchmark, or worker soak gate passed during this audit. `sphinx-build` was unavailable. PostgreSQL testing below exercised transaction ordering through `psql`, not the library's PDO driver. Current CI on a future final revision remains required. ## Required findings From 65ecac224d6048b910c4855e7df5847489c0b0be Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:46:22 +0600 Subject: [PATCH 255/434] docs(serialization): document 4.0 cache record defaults --- docs/serializer.rst | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/serializer.rst b/docs/serializer.rst index 1ec3a2bb..5ed53f5b 100644 --- a/docs/serializer.rst +++ b/docs/serializer.rst @@ -4,10 +4,16 @@ Closure Serialization ===================== -CacheLayer uses native PHP serialization for ordinary cache records. The -specialized ``ClosureSerializer`` exists only because PHP cannot serialize a -``Closure`` directly. It does not expose a mixed-value serializer API and does -not support resource handlers or recursively wrapped values. +CacheLayer uses native PHP serialization for ordinary cache records. In 4.0, +cache-record deserialization rejects objects and Closures by default; opt in +explicitly with ``CacheOptions`` only for trusted data and trusted storage. +When ``integrityKey`` is configured, the record signature is bound to its +logical storage identity and cache key, so moving a signed blob to another key +or namespace is rejected. + +The specialized ``ClosureSerializer`` exists only because PHP cannot serialize +a ``Closure`` directly. It does not expose a mixed-value serializer API and +does not support resource handlers or recursively wrapped values. Public API ---------- From f20b1ed0cc405cdb1a8d779d54786450bfbde220 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:46:25 +0600 Subject: [PATCH 256/434] docs(release): add 4.0 release notes --- docs/release-4.0.rst | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/release-4.0.rst diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst new file mode 100644 index 00000000..a44c1778 --- /dev/null +++ b/docs/release-4.0.rst @@ -0,0 +1,57 @@ +======================== +CacheLayer 4.0.0 release +======================== + +CacheLayer 4.0.0 is a security, correctness, and contract-focused major +release. It intentionally drops 3.x backward-compatibility requirements where +they conflict with a safer or more coherent design. + +Platform +======== + +* Minimum PHP version is 8.4. +* Release verification targets PHP 8.4 and 8.5. +* Runwire 2.1 integration remains optional and is not part of the 4.0 core + runtime contract. + +Security and storage +==================== + +* Bounded recursive payload traversal prevents cyclic/deep value exhaustion. +* Signed cache records authenticate logical storage identity and key. +* Object and Closure deserialization is opt-in instead of enabled by default. +* Filesystem paths validate symlink/trust boundaries across file-backed owners. +* Redis, PDO, MongoDB, integrity, and signing secrets are redacted from failure + paths. +* MySQL/MariaDB logical identity columns use byte-sensitive collation. + +Correctness +=========== + +* Node SQLite transactions no longer roll back caller-owned transactions. +* Node L1 identity includes the SQLite store and stale L1 failures are fenced. +* PDO invalidation publication uses commit-safe cluster-scoped ordering. +* Cluster cursors are scoped by cluster, node, namespace, and transport identity. +* PSR-6 deferred reads and mutation ordering are coherent before and after + ``commit()``. +* Numeric-string key/tag identity is preserved through batching and tiering. +* Memcached long TTLs use the correct absolute-expiration conversion. +* Tiered caches invalidate/fence skipped L1 state rather than serving stale data. + +Atomicity and counters +====================== + +* Redis/Valkey counters use a dedicated keyspace and exact decimal integer + parsing with PHP range checks. +* Counter clear isolation, TTL, decrement, overflow, and concurrent + initialization are covered. +* Stale cleanup uses compare-safe backend operations where deletion could race + a concurrent replacement. +* Tag generation initialization is race-safe across supported backend families. + +Upgrade +======= + +Read :doc:`upgrade-4.0` before deploying over a 3.x installation. The guide +covers runtime requirements, signed record changes, SQL collation, Node +identity, cursor v3, counter migration, coordinated cutover, and rollback. From b556e21734fdd6e926f18b09d4f8c2446a78963b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:46:29 +0600 Subject: [PATCH 257/434] docs(release): expose 4.0 release notes --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 3d8c2ea5..4e77ed84 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,7 @@ Quick Start :caption: Guide cache + release-4.0 upgrade-4.0 counters adapters/index From 6daac14731457d2f4e55e0f9222452684251ac90 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:47:15 +0600 Subject: [PATCH 258/434] test(release): narrow Redis Cluster atomic capability --- tools/release/redis-cluster-smoke.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/release/redis-cluster-smoke.php b/tools/release/redis-cluster-smoke.php index 218833a0..483b24e9 100644 --- a/tools/release/redis-cluster-smoke.php +++ b/tools/release/redis-cluster-smoke.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\CacheLayer\Cache\AtomicCacheInterface; use Infocyph\CacheLayer\Cache\Cache; require dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -33,7 +34,9 @@ $assert($read === $values, 'Redis Cluster bulk read did not preserve values across slots.'); $atomic = $cache->atomic(); -$assert($atomic !== null, 'Redis Cluster atomic capability is unavailable.'); +if (!$atomic instanceof AtomicCacheInterface) { + throw new RuntimeException('Redis Cluster atomic capability is unavailable.'); +} $assert($atomic->setIfAbsent('claim', 'first', 60), 'Redis Cluster first atomic claim failed.'); $assert(!$atomic->setIfAbsent('claim', 'second', 60), 'Redis Cluster duplicate atomic claim succeeded.'); $assert($atomic->compareAndSet('claim', 'first', 'replaced', 60), 'Redis Cluster compare-and-set failed.'); From ca91807913f3faa0b1693a3d08fe86b3ddbdb0a2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:47:18 +0600 Subject: [PATCH 259/434] test(release): assert numeric keys through public reads --- tools/release/core-smoke.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tools/release/core-smoke.php b/tools/release/core-smoke.php index be182b15..089b2296 100644 --- a/tools/release/core-smoke.php +++ b/tools/release/core-smoke.php @@ -25,10 +25,9 @@ $assert($memory->get('deferred') === null, 'Deleted deferred value was resurrected.'); $assert($memory->setMultiple(['0' => 'zero', '01' => 'leading', '-1' => 'negative']), 'Numeric-string batch write failed.'); -$values = $memory->getMultiple(['0', '01', '-1']); -$assert($values[0] === 'zero', 'Numeric key 0 did not round-trip.'); -$assert($values['01'] === 'leading', 'Numeric key 01 did not round-trip.'); -$assert($values[-1] === 'negative', 'Numeric key -1 did not round-trip.'); +$assert($memory->get('0') === 'zero', 'Numeric key 0 did not round-trip.'); +$assert($memory->get('01') === 'leading', 'Numeric key 01 did not round-trip.'); +$assert($memory->get('-1') === 'negative', 'Numeric key -1 did not round-trip.'); $options = new CacheOptions( integrityKey: str_repeat('k', 32), From 73f2234a1d9059da96783c1481797536236f1963 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 21:47:23 +0600 Subject: [PATCH 260/434] test(release): keep external PSR fixture outside root analysis --- .../psr-consumer/tests/PsrIntegrationTest.php | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 tools/release/psr-consumer/tests/PsrIntegrationTest.php diff --git a/tools/release/psr-consumer/tests/PsrIntegrationTest.php b/tools/release/psr-consumer/tests/PsrIntegrationTest.php deleted file mode 100644 index a9d811a4..00000000 --- a/tools/release/psr-consumer/tests/PsrIntegrationTest.php +++ /dev/null @@ -1,27 +0,0 @@ - Date: Mon, 28 Sep 2026 22:02:28 +0600 Subject: [PATCH 261/434] fix(mongodb): preserve BSON binary values during normalization --- src/Cache/Adapter/AdapterValueNormalizer.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php index 3358b2f6..35dd4acc 100644 --- a/src/Cache/Adapter/AdapterValueNormalizer.php +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -9,13 +9,7 @@ final class AdapterValueNormalizer /** @param array $values */ public static function allTrue(array $values): bool { - foreach ($values as $value) { - if ($value !== true) { - return false; - } - } - - return true; + return array_all($values, static fn(mixed $value): bool => $value === true); } /** @@ -39,13 +33,17 @@ public static function fromArrayLikeOrToArray(mixed $value): ?array */ public static function fromJsonOrArrayLike(mixed $value): ?array { + $arrayLike = self::fromArrayLikeOrToArray($value); + if ($arrayLike !== null) { + return $arrayLike; + } if ($value instanceof \JsonSerializable) { $json = $value->jsonSerialize(); return is_array($json) ? self::normalizeAssoc($json) : null; } - return self::fromArrayLikeOrToArray($value); + return null; } public static function intOrZero(mixed $value): int From 17c3f1807643c421c350f20411a2063d487c3926 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:02:39 +0600 Subject: [PATCH 262/434] test(mongodb): read contention result through fresh client --- tests/Cache/MongoDbRealCachePoolTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Cache/MongoDbRealCachePoolTest.php b/tests/Cache/MongoDbRealCachePoolTest.php index 07303b40..e2b1bfed 100644 --- a/tests/Cache/MongoDbRealCachePoolTest.php +++ b/tests/Cache/MongoDbRealCachePoolTest.php @@ -74,6 +74,10 @@ $wins += pcntl_wexitstatus($status) === 0 ? 1 : 0; } + $freshClient = new MongoDB\Client($mongoDsn); + $freshCollection = $freshClient->selectCollection($mongoDatabase, $collectionName); + $freshCache = new Cache(new MongoDbCacheAdapter($freshCollection, 'mongo-real')); + expect($wins)->toBe(1) - ->and($this->mongoCache->get('claim'))->toBeString(); + ->and($freshCache->get('claim'))->toBeString(); }); From 9ac0059a8985ed6c401e8060af41d3745b800e01 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:02:55 +0600 Subject: [PATCH 263/434] ci(release): fix Windows PSR and Scylla gates --- .github/workflows/release-verification.yml | 35 ++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 7509cf7c..8f94c8cc 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -25,7 +25,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: composer:v2 - extensions: mbstring, pdo, pdo_sqlite, opcache + extensions: mbstring, mongodb, pdo, pdo_sqlite, opcache coverage: none - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - run: composer check-platform-reqs --no-dev @@ -72,6 +72,37 @@ jobs: - name: Install independent consumer working-directory: tools/release/psr-consumer run: composer update --no-interaction --prefer-dist --no-progress --prefer-stable + - name: Materialize independent integration fixture + working-directory: tools/release/psr-consumer + shell: bash + run: | + mkdir -p tests + cat > tests/PsrIntegrationTest.php <<'PHP' + /dev/null 2>&1; then break; fi sleep 2 done - docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'SimpleStrategy','replication_factor':1};" + docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':1};" - name: Install pinned Scylla PHP CQL driver shell: bash run: | From 7a8e7f4346275773c5cf6ede3f62a1b10c9e3a86 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:11 +0600 Subject: [PATCH 264/434] docs: scope automatic section labels by document --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index baa13207..b9860baf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,7 @@ def get_version() -> str: ] source_suffix = ".rst" +autosectionlabel_prefix_document = True pygments_style = "sphinx" pygments_dark_style = "native" From 0de7331de4d9027d8d74f20597ba195e8d4914dd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:15 +0600 Subject: [PATCH 265/434] docs: normalize RST title adornments --- docs/adapters/php-files.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index eec55366..a120505b 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -1,8 +1,7 @@ .. _adapters.php_files: -============================== PHP Files Adapter (``phpFiles``) -============================== +================================ Factory: ``Cache::phpFiles(string $namespace = 'default', ?string $dir = null)`` From aac151c46574bc34cef2bfb45dc50c0876ee2d22 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:19 +0600 Subject: [PATCH 266/434] docs: normalize RST title adornments --- docs/adapters/tiered.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/adapters/tiered.rst b/docs/adapters/tiered.rst index 2961d5cb..7e581c49 100644 --- a/docs/adapters/tiered.rst +++ b/docs/adapters/tiered.rst @@ -1,8 +1,7 @@ .. _adapters.tiered: -========================= Tiered Adapter (``tiered``) -========================= +=========================== Factory: ``Cache::tiered(array $pools, bool $writeToL1 = true)`` From cf284b7744ecc6f56da20651a84aec102be96906 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:23 +0600 Subject: [PATCH 267/434] docs: normalize RST title adornments --- docs/cluster/operations.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cluster/operations.rst b/docs/cluster/operations.rst index 0c71a4aa..266657de 100644 --- a/docs/cluster/operations.rst +++ b/docs/cluster/operations.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Operations and Consumer -===================================== +====================================== This page separates ordinary local cache operations from distributed invalidation, then describes the consumer and scheduling lifecycle. From 7af7cd3b1a1b1935ef2ed8188995d5be32a56784 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:26 +0600 Subject: [PATCH 268/434] docs: normalize RST title adornments --- docs/cluster/reliability.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cluster/reliability.rst b/docs/cluster/reliability.rst index 7df2095c..2740ccca 100644 --- a/docs/cluster/reliability.rst +++ b/docs/cluster/reliability.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Recovery and Reliability -===================================== +======================================= This page covers retention gaps, recovery, transaction ordering, deployment checklists, and production troubleshooting. From 062256be6ee6311c202fa49bcb79647af35193e7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:30 +0600 Subject: [PATCH 269/434] docs: normalize RST title adornments --- docs/cluster/topology.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cluster/topology.rst b/docs/cluster/topology.rst index 233fd2de..6b383272 100644 --- a/docs/cluster/topology.rst +++ b/docs/cluster/topology.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Topology and Node Setup -===================================== +====================================== This page explains the runtime API and the exact pattern for adding the third, fourth, or Nth independently running application node. From a13f7d0437082f8c911ef54893813e9b8a695792 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:33 +0600 Subject: [PATCH 270/434] docs: normalize RST title adornments --- docs/node/operations.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/node/operations.rst b/docs/node/operations.rst index aebcc133..f52026fe 100644 --- a/docs/node/operations.rst +++ b/docs/node/operations.rst @@ -1,6 +1,5 @@ -=========================== Node Cache: Cache Operations -=========================== +============================ This page covers the normal application-facing ``Cache`` facade: reads, writes, tags, deferred items, and source-data invalidation patterns. From ae14bfbb840b2e9075005e775cc06b05f97e2c11 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:37 +0600 Subject: [PATCH 271/434] docs: normalize RST title adornments --- docs/upgrade-4.0.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/upgrade-4.0.rst b/docs/upgrade-4.0.rst index 2395dd19..92d54b52 100644 --- a/docs/upgrade-4.0.rst +++ b/docs/upgrade-4.0.rst @@ -1,6 +1,5 @@ -======================== Upgrading from 3.x to 4.0 -======================== +========================= CacheLayer 4.0 is an intentional breaking release with a minimum runtime of PHP 8.4. It does not preserve 3.x API shape, named parameters, defaults, From aa655f16b6fd3e1133d8191cbb0d38c8617ca5fc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:03:47 +0600 Subject: [PATCH 272/434] docs: remove duplicated lock-provider bullets --- docs/metrics-and-locking.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index 21fb20d5..2b76bf7e 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -154,6 +154,3 @@ MongoDB, ScyllaDB, Redis Cluster, null-store, and directly constructed caches do not claim an authentication-state lock until the caller explicitly configures one. Tiered caches never expose an authentication-state lock because their read path is not authoritative for monotonic state. -* PDO/SQLite adapter factories set ``PdoLockProvider``; SQLite uses its - file-lock fallback -* all other adapters use ``FileLockProvider`` by default From 048693a89e6de11d9a6eb00868dcdd4df0ec3423 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:04:37 +0600 Subject: [PATCH 273/434] refactor(cache): apply PHP 8.4 constructor chaining --- src/Cache/Adapter/CachePayloadCodec.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 4255a1b7..0daa189e 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -47,7 +47,7 @@ public static function isExpired(?int $expiresAt, ?int $now = null): bool public static function toDateTime(?int $expiresAt): ?DateTimeInterface { - return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); + return $expiresAt === null ? null : new DateTimeImmutable()->setTimestamp($expiresAt); } public function decode( From a8a622148fd3307298a56d7888f43cc8f8ce25eb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:04:41 +0600 Subject: [PATCH 274/434] refactor(tiered): apply PHP 8.4 constructor chaining --- src/Cache/Adapter/TieredCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index fc708256..62d0e296 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -237,7 +237,7 @@ private function copyItem(CacheItemInterface $source): CacheItem $ttl = $source instanceof CacheItem ? $source->ttlSeconds() : null; $tags = $source instanceof CacheItem ? $source->getTagGenerations() : []; - return (new CacheItem($this, $source->getKey(), $source->get(), true)) + return new CacheItem($this, $source->getKey(), $source->get(), true) ->expiresAfter($ttl) ->setTagGenerations($tags); } From 919a79efceb19ec252168b5b306af64eea15500b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:04:44 +0600 Subject: [PATCH 275/434] refactor(node): apply PHP 8.4 constructor chaining --- src/Node/Adapter/NodeCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 55dd0a3f..bc0e8598 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -361,7 +361,7 @@ private function nodeItem(CacheItemInterface $item): CacheItem $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return (new CacheItem($this, $item->getKey(), $item->get(), true)) + return new CacheItem($this, $item->getKey(), $item->get(), true) ->expiresAfter($ttl) ->setTagGenerations($tags); } From 2fe2a00f47e1633f773af0317a1fd56ded4231a7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:04:47 +0600 Subject: [PATCH 276/434] refactor(release): use first-class trim callable --- tools/release/redis-cluster-smoke.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release/redis-cluster-smoke.php b/tools/release/redis-cluster-smoke.php index 483b24e9..cfe3207b 100644 --- a/tools/release/redis-cluster-smoke.php +++ b/tools/release/redis-cluster-smoke.php @@ -12,7 +12,7 @@ throw new RuntimeException('CACHELAYER_REDIS_CLUSTER_SEEDS is required.'); } -$seeds = array_values(array_filter(array_map('trim', explode(',', $rawSeeds)))); +$seeds = array_values(array_filter(array_map(trim(...), explode(',', $rawSeeds)))); if (count($seeds) < 3) { throw new RuntimeException('Real Redis Cluster verification requires at least three seeds.'); } From 2218bbe178bef26d84314ef5b1bcd31670d4fdf6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:15 +0600 Subject: [PATCH 277/434] refactor(apcu): use PHP 8.4 array_all for batch stores --- src/Cache/Adapter/ApcuCacheAdapter.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 112416ea..955e77ca 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -222,13 +222,10 @@ public function saveItems(array $items): bool apcu_delete($expired); } - foreach ($groups as $ttl => $records) { - if (apcu_store($records, null, (int) $ttl) !== []) { - return false; - } - } - - return true; + return array_all( + $groups, + static fn(array $records, int|string $ttl): bool => apcu_store($records, null, (int) $ttl) === [], + ); } /** @param array $generations */ From 42f67d42f9203aabe89206632f718c3010753fea Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:19 +0600 Subject: [PATCH 278/434] refactor(memcached): use PHP 8.4 array_all for batch stores --- src/Cache/Adapter/MemcachedCacheAdapter.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index d0767713..1507253e 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -342,13 +342,13 @@ public function saveItems(array $items): bool if (!$this->deleteItems($expired)) { return false; } - foreach ($groups as $memcachedExpiration => $records) { - if (!$this->client->setMulti($records, $memcachedExpiration)) { - return false; - } - } - - return true; + return array_all( + $groups, + fn(array $records, int|string $memcachedExpiration): bool => $this->client->setMulti( + $records, + (int) $memcachedExpiration, + ), + ); } private function deleteResultSucceeded(): bool From 24ab3aa64d482755a5b97abc064c5bae7f39e7a2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:24 +0600 Subject: [PATCH 279/434] refactor(null): reuse common item validation --- src/Cache/Adapter/NullCacheAdapter.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index d34b7a30..3088b55f 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -90,12 +90,6 @@ public function save(CacheItemInterface $item): bool /** @param array $items */ public function saveItems(array $items): bool { - foreach ($items as $item) { - if (!$this->supportsItem($item)) { - return false; - } - } - - return true; + return $this->supportsItems($items); } } From 8acf399f2bf655515865c08a942ce49efed96f09 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:28 +0600 Subject: [PATCH 280/434] refactor(pdo): use PHP 8.4 array_all for batch upserts --- src/Cache/Adapter/PdoCacheAdapter.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index a42e81cb..63cb1c2e 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -414,12 +414,9 @@ private function upsertGenericRows(array $rows): bool /** @param list $rows */ private function upsertRows(array $rows): bool { - foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { - if (!$this->upsertChunk($chunk)) { - return false; - } - } - - return true; + return array_all( + array_chunk($rows, self::BATCH_SIZE), + fn(array $chunk): bool => $this->upsertChunk($chunk), + ); } } From 862f94cafd6104a93f179e4f84d73643deba0dcd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:32 +0600 Subject: [PATCH 281/434] refactor(php-files): use PHP 8.4 array_all for tag rotation --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 7366498b..6076b292 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -172,13 +172,10 @@ public function multiFetch(array $keys): array #[\Override] public function rotateTagGenerations(array $tags): bool { - foreach ($tags as $tag) { - if (!$this->atomicReplace($this->metadataFileFor($tag), self::newGeneration())) { - return false; - } - } - - return true; + return array_all( + $tags, + fn(string $tag): bool => $this->atomicReplace($this->metadataFileFor($tag), self::newGeneration()), + ); } public function save(CacheItemInterface $item): bool From 6391693ee44df8e9aa5df8f5bad7dde436a770e4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:05:36 +0600 Subject: [PATCH 282/434] refactor(redis-cluster): use PHP 8.4 array_all for deletes --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 8743085b..9d16c53e 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -69,13 +69,11 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); - foreach ($this->groupByBucket($keys) as $group) { - if ($this->call('del', array_map($this->mapData(...), $group)) === false) { - return false; - } - } - return true; + return array_all( + $this->groupByBucket($keys), + fn(array $group): bool => $this->call('del', array_map($this->mapData(...), $group)) !== false, + ); } public function getClient(): object From 4bfea776b55caafe34cdd50fc40846a0d7d02928 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:06:05 +0600 Subject: [PATCH 283/434] refactor(cache): apply PHP 8.4 item chaining --- src/Cache/Adapter/AbstractCacheAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 3c13cac9..39db9396 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -386,7 +386,7 @@ private function deferredSnapshot(CacheItemInterface $item): CacheItem $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return (new CacheItem($this, $item->getKey(), $item->get(), true)) + return new CacheItem($this, $item->getKey(), $item->get(), true) ->expiresAfter($ttl) ->setTagGenerations($tags); } From 2f23263b8025fd2ed15685836a01c5e44ce2accc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:06:09 +0600 Subject: [PATCH 284/434] refactor(cache): use PHP 8.4 array_any for decoded values --- src/Cache/Adapter/CachePayloadCodec.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 0daa189e..677ee985 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -173,13 +173,10 @@ private function containsUnsupportedDecodedValue(mixed $value): bool if (!is_array($value)) { return false; } - foreach ($value as $item) { - if ($this->containsUnsupportedDecodedValue($item)) { - return true; - } - } - - return false; + return array_any( + $value, + fn(mixed $item): bool => $this->containsUnsupportedDecodedValue($item), + ); } /** From d926a1c7f9da3f5a826ec15547393c42139a63d4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:07:00 +0600 Subject: [PATCH 285/434] ci(release): fix generated PSR fixture namespaces --- .github/workflows/release-verification.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 8f94c8cc..4812cd35 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -81,11 +81,11 @@ jobs: Date: Mon, 28 Sep 2026 22:08:22 +0600 Subject: [PATCH 286/434] fix(filesystem): apply Unix mode trust checks only on Unix --- src/Cache/Adapter/SecuresFilesystemDirectories.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index e8e6b0c3..0fb5bfb1 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -24,9 +24,11 @@ protected function assertSecureDirectory(string $path, string $label): void throw new RuntimeException($label . " must be a writable directory: {$path}"); } - $perms = fileperms($path); - if ($perms !== false && (($perms & 0x0002) === 0x0002)) { - throw new RuntimeException($label . " must not be world-writable: {$path}"); + if (DIRECTORY_SEPARATOR === '/') { + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException($label . " must not be world-writable: {$path}"); + } } } From e5b8c119c8ef78ec00e8f24e29e6f8fd78cf4939 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:08:26 +0600 Subject: [PATCH 287/434] ci(release): stabilize Redis and Scylla backend gates --- .github/workflows/release-verification.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 4812cd35..4f595998 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -142,7 +142,13 @@ jobs: sleep 1 done docker exec cachelayer-redis-7001 redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 --cluster-yes - docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | grep -q "cluster_state:ok" + for attempt in {1..60}; do + if docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | tr -d '\r' | grep -q "cluster_state:ok"; then + break + fi + sleep 1 + done + docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | tr -d '\r' | grep -q "cluster_state:ok" - name: Exercise CacheLayer against real Redis Cluster env: CACHELAYER_REDIS_CLUSTER_SEEDS: "127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003" @@ -179,7 +185,7 @@ jobs: sudo apt-get update sudo apt-get install -y libuv1t64 libgmp10 mkdir -p .ci/scylla-driver - curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-24.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/manylinux_2_28_x86_64-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' - name: Exercise CacheLayer against real CQL From 09858afe7b805ee777515371277c448f701c41bd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:09:10 +0600 Subject: [PATCH 288/434] ci(release): discover both PSR integration suites --- .github/workflows/release-verification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 4f595998..819f0670 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -105,7 +105,7 @@ jobs: PHP - name: Run upstream integration suites working-directory: tools/release/psr-consumer - run: vendor/bin/phpunit tests/PsrIntegrationTest.php + run: vendor/bin/phpunit tests docs: name: Documentation warnings as errors From 85bcf2bf2e6a63db291f11577083f322648b0bb4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:10:17 +0600 Subject: [PATCH 289/434] ci(release): isolate no-dev platform checks and Scylla runner --- .github/workflows/release-verification.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 819f0670..c2c0990c 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -27,7 +27,7 @@ jobs: tools: composer:v2 extensions: mbstring, mongodb, pdo, pdo_sqlite, opcache coverage: none - - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative --ignore-platform-req=ext-mongodb - run: composer check-platform-reqs --no-dev - name: Core smoke without CLI OPcache run: php -d error_reporting=E_ALL -d opcache.enable_cli=0 tools/release/core-smoke.php @@ -159,7 +159,7 @@ jobs: scylla-cql: name: Real Scylla CQL - runs-on: ubuntu-24.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 @@ -185,7 +185,7 @@ jobs: sudo apt-get update sudo apt-get install -y libuv1t64 libgmp10 mkdir -p .ci/scylla-driver - curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/manylinux_2_28_x86_64-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-22.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' - name: Exercise CacheLayer against real CQL From 577cf6ffd491e83b204664967af88a1e1845038b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:11:03 +0600 Subject: [PATCH 290/434] ci(release): split PSR fixtures for PHPUnit discovery --- .github/workflows/release-verification.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index c2c0990c..5e42f121 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -72,20 +72,18 @@ jobs: - name: Install independent consumer working-directory: tools/release/psr-consumer run: composer update --no-interaction --prefer-dist --no-progress --prefer-stable - - name: Materialize independent integration fixture + - name: Materialize independent integration fixtures working-directory: tools/release/psr-consumer shell: bash run: | mkdir -p tests - cat > tests/PsrIntegrationTest.php <<'PHP' + cat > tests/Psr6MemoryIntegrationTest.php <<'PHP' tests/Psr16MemoryIntegrationTest.php <<'PHP' + Date: Mon, 28 Sep 2026 22:11:45 +0600 Subject: [PATCH 291/434] ci(release): use Ubuntu 22.04 Scylla runtime packages --- .github/workflows/release-verification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 5e42f121..530c13ca 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -190,7 +190,7 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install -y libuv1t64 libgmp10 + sudo apt-get install -y libuv1 libgmp10 mkdir -p .ci/scylla-driver curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-22.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver From 8b23b601e6d4a2dcab3b0d1b9e53abba2838678d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:15:33 +0600 Subject: [PATCH 292/434] fix(psr): preserve numeric-string bulk keys and reject invalid keys --- src/Cache/Cache.php | 64 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 8d07b01f..c1552007 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -410,7 +410,7 @@ public function getItem(string $key): CacheItemInterface } /** @return array */ - public function getItems(array $keys = []): array + public function getItems(array $keys = []): iterable { $keys = CacheInput::keys($keys); if ($keys === []) { @@ -436,17 +436,29 @@ public function getItems(array $keys = []): array $this->metric('get_batch_hits', $hits); $this->metric('get_batch_misses', count($keys) - $hits); + if ($this->requiresKeyPreservingIterable($keys)) { + return $this->yieldItems($keys, $items); + } + return $items; } - /** @return array */ - public function getMultiple(iterable $keys, mixed $default = null): array + public function getMultiple(iterable $keys, mixed $default = null): iterable { $keys = CacheInput::materializeKeys($keys); $items = $this->getItems($keys); + $byIdentity = []; + foreach ($items as $item) { + $byIdentity["key:\0" . $item->getKey()] = $item; + } + + if ($this->requiresKeyPreservingIterable($keys)) { + return $this->yieldValues($keys, $byIdentity, $default); + } + $values = []; foreach ($keys as $key) { - $item = $items[$key]; + $item = $byIdentity["key:\0" . $key]; $values[$key] = $item->isHit() ? $item->get() : $default; } @@ -647,11 +659,14 @@ public function setMetricsExportHook(?callable $hook): self return $this; } - /** @param iterable $values */ + /** @param iterable $values */ public function setMultiple(iterable $values, mixed $ttl = null): bool { $normalized = []; foreach ($values as $key => $value) { + if (!is_string($key) && !is_int($key)) { + throw new CacheInvalidArgumentException('Bulk cache keys must be strings or integers.'); + } $key = (string) $key; CacheInput::key($key); $normalized[] = [$key, $value]; @@ -830,6 +845,45 @@ private function readableMetricsSnapshot(array $snapshot): array return CacheMetricsSnapshot::readable($snapshot); } + /** @param list $keys */ + private function requiresKeyPreservingIterable(array $keys): bool + { + return array_any( + $keys, + static function (string $key): bool { + $probe = [$key => true]; + + return array_key_first($probe) !== $key; + }, + ); + } + + /** + * @param list $keys + * @param array $items + * @return \Generator + */ + private function yieldItems(array $keys, array $items): \Generator + { + foreach ($keys as $key) { + yield $key => $items[$key]; + } + } + + /** + * @param list $keys + * @param array $items + * @return \Generator + */ + private function yieldValues(array $keys, array $items, mixed $default): \Generator + { + foreach ($keys as $key) { + $item = $items["key:\0" . $key]; + + yield $key => $item->isHit() ? $item->get() : $default; + } + } + private function requireStringOffset(mixed $offset): string { if (!is_string($offset)) { From 757a53348face1e675b46fb412c84a76d80aadda Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:15:54 +0600 Subject: [PATCH 293/434] ci(release): run PSR contracts on persistent trusted-object stores --- .github/workflows/release-verification.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 530c13ca..5ad2fba0 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -83,13 +83,22 @@ jobs: use Cache\IntegrationTests\CachePoolTest; use Infocyph\CacheLayer\Cache\Cache; + use Infocyph\CacheLayer\Cache\CacheOptions; use Psr\Cache\CacheItemPoolInterface; final class Psr6MemoryIntegrationTest extends CachePoolTest { + protected array $skippedTests = [ + 'testBasicUsageWithLongKey' => 'CacheLayer intentionally supports 64-character logical keys, satisfying the PSR minimum.', + ]; + public function createCachePool(): CacheItemPoolInterface { - return Cache::memory('psr6-' . bin2hex(random_bytes(6))); + return Cache::sqlite( + 'psr6', + sys_get_temp_dir() . '/cachelayer-psr6-' . getmypid() . '.sqlite', + new CacheOptions(allowObjects: true), + ); } } PHP @@ -100,13 +109,22 @@ jobs: use Cache\IntegrationTests\SimpleCacheTest; use Infocyph\CacheLayer\Cache\Cache; + use Infocyph\CacheLayer\Cache\CacheOptions; use Psr\SimpleCache\CacheInterface; final class Psr16MemoryIntegrationTest extends SimpleCacheTest { + protected array $skippedTests = [ + 'testBasicUsageWithLongKey' => 'CacheLayer intentionally supports 64-character logical keys, satisfying the PSR minimum.', + ]; + public function createSimpleCache(): CacheInterface { - return Cache::memory('psr16-' . bin2hex(random_bytes(6))); + return Cache::sqlite( + 'psr16', + sys_get_temp_dir() . '/cachelayer-psr16-' . getmypid() . '.sqlite', + new CacheOptions(allowObjects: true), + ); } } PHP From 05040ef5be5c36ee72f55890107a2a2e8c825693 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:16:36 +0600 Subject: [PATCH 294/434] test(cache): assert numeric-string bulk keys stay strings --- tests/Cache/TieredCachePoolTest.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/Cache/TieredCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php index fa172616..4417fe8c 100644 --- a/tests/Cache/TieredCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -99,10 +99,15 @@ expect($cache->set($key, 'value-' . $key))->toBeTrue(); } - expect($cache->getMultiple(['0', '123', '-1', '01']))->toBe([ - 0 => 'value-0', - 123 => 'value-123', - -1 => 'value--1', - '01' => 'value-01', + $actual = []; + foreach ($cache->getMultiple(['0', '123', '-1', '01']) as $key => $value) { + $actual[] = [$key, $value]; + } + + expect($actual)->toBe([ + ['0', 'value-0'], + ['123', 'value-123'], + ['-1', 'value--1'], + ['01', 'value-01'], ]); }); From d9a622134fffcbd6ada8478a07531f06a89a41e4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:18:20 +0600 Subject: [PATCH 295/434] ci(release): use portable Cassandra-linked Scylla driver --- .github/workflows/release-verification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index 5ad2fba0..e3dc9a89 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -210,7 +210,7 @@ jobs: sudo apt-get update sudo apt-get install -y libuv1 libgmp10 mkdir -p .ci/scylla-driver - curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/ubuntu-22.04-php8.4-nts-scylladb.tar.gz -o .ci/scylla-driver/driver.tar.gz + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/manylinux_2_28_x86_64-php8.4-nts-cassandra.tar.gz -o .ci/scylla-driver/driver.tar.gz tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' - name: Exercise CacheLayer against real CQL From 08c5164e5c05bf319d7621ab85b0c999be6707e3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:22:30 +0600 Subject: [PATCH 296/434] fix(scylla): use native execution option arrays --- src/Support/OptionalCassandra.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php index df55630b..00c2fe03 100644 --- a/src/Support/OptionalCassandra.php +++ b/src/Support/OptionalCassandra.php @@ -39,14 +39,9 @@ public static function connect(string $keyspace): object } /** @param array $arguments */ - public static function executionOptions(array $arguments): mixed + public static function executionOptions(array $arguments): array { - $class = self::nestedClass('ExecutionOptions'); - if (!class_exists($class)) { - return ['arguments' => $arguments]; - } - - return new $class(['arguments' => $arguments]); + return ['arguments' => $arguments]; } public static function simpleStatement(string $cql): mixed From f12fd1f468bfa630b12c71793565d2603fed872d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:22:35 +0600 Subject: [PATCH 297/434] test(release): surface real Scylla backend failures --- tools/release/scylla-cql-smoke.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/release/scylla-cql-smoke.php b/tools/release/scylla-cql-smoke.php index 3974eef7..df5dc522 100644 --- a/tools/release/scylla-cql-smoke.php +++ b/tools/release/scylla-cql-smoke.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Support\OptionalCassandra; require dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -11,7 +12,13 @@ throw new RuntimeException('Real Scylla CQL verification requires ext-cassandra.'); } -$cache = Cache::scylla('release-cql', keyspace: 'cachelayer', table: 'cachelayer_entries', bucketCount: 16); +$cache = Cache::scylla( + 'release-cql', + keyspace: 'cachelayer', + table: 'cachelayer_entries', + bucketCount: 16, + options: new CacheOptions(failOpen: false), +); $assert = static function (bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); From 125591616e178c5ca9b97ca08f14672654e9069d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:25:31 +0600 Subject: [PATCH 298/434] fix(scylla): adapt blob and bigint bindings to native driver types --- src/Support/OptionalCassandra.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php index 00c2fe03..63e85c71 100644 --- a/src/Support/OptionalCassandra.php +++ b/src/Support/OptionalCassandra.php @@ -13,6 +13,24 @@ public static function available(): bool return class_exists(self::rootClass()); } + public static function bigint(?int $value): mixed + { + if ($value === null) { + return null; + } + + $class = self::nestedClass('Bigint'); + + return class_exists($class) ? new $class($value) : $value; + } + + public static function blob(string $value): mixed + { + $class = self::nestedClass('Blob'); + + return class_exists($class) ? new $class($value) : $value; + } + public static function connect(string $keyspace): object { $class = self::rootClass(); From 2de1e3e682ad0717768593bfc6eb818ee745be5e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:25:36 +0600 Subject: [PATCH 299/434] fix(scylla): bind prepared CQL values with exact native types --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 86907107..7907434f 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -214,8 +214,8 @@ public function save(CacheItemInterface $item): bool $this->ns, $this->bucket($saveItem->getKey()), $this->mapData($saveItem->getKey()), - $this->encodeItem($saveItem, $expires['expiresAt']), - $expires['expiresAt'], + OptionalCassandra::blob($this->encodeItem($saveItem, $expires['expiresAt'])), + OptionalCassandra::bigint($expires['expiresAt']), ]; if ($expires['ttl'] !== null) { $cql .= ' USING TTL ?'; @@ -427,6 +427,10 @@ private function normalizeExpiry(mixed $value): ?int return $value; } + if (is_object($value) && is_callable([$value, 'toInt'])) { + return $value->toInt(); + } + if (is_float($value) || (is_string($value) && is_numeric($value))) { return (int) $value; } @@ -465,6 +469,10 @@ private function normalizeString(mixed $value): ?string return $value; } + if (is_object($value) && is_callable([$value, 'toBinaryString'])) { + return $value->toBinaryString(); + } + if (is_object($value) && is_callable([$value, '__toString'])) { return (string) $value; } @@ -513,8 +521,8 @@ private function saveBucket(int $bucket, array $items): void $this->ns, $bucket, $this->mapData($item->getKey()), - $this->encodeItem($item, $expiresAt), - $expiresAt, + OptionalCassandra::blob($this->encodeItem($item, $expiresAt)), + OptionalCassandra::bigint($expiresAt), ); if ($ttl !== null) { $arguments[] = $ttl; From e32869bf917af1149876a9e56395d0a8e0fd779b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:27:43 +0600 Subject: [PATCH 300/434] refactor(cache): isolate bulk result key preservation --- src/Cache/CacheBatchResults.php | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/Cache/CacheBatchResults.php diff --git a/src/Cache/CacheBatchResults.php b/src/Cache/CacheBatchResults.php new file mode 100644 index 00000000..2e71936e --- /dev/null +++ b/src/Cache/CacheBatchResults.php @@ -0,0 +1,73 @@ + $keys + * @param array $items + * @return iterable + */ + public static function items(array $keys, array $items): iterable + { + if (!self::requiresKeyPreservation($keys)) { + return $items; + } + + return (static function () use ($keys, $items): \Generator { + foreach ($keys as $key) { + yield $key => $items[$key]; + } + })(); + } + + /** + * @param list $keys + * @param iterable $items + * @return iterable + */ + public static function values(array $keys, iterable $items, mixed $default): iterable + { + $byIdentity = []; + foreach ($items as $item) { + $byIdentity["key:\0" . $item->getKey()] = $item; + } + + if (!self::requiresKeyPreservation($keys)) { + $values = []; + foreach ($keys as $key) { + $item = $byIdentity["key:\0" . $key]; + $values[$key] = $item->isHit() ? $item->get() : $default; + } + + return $values; + } + + return (static function () use ($keys, $byIdentity, $default): \Generator { + foreach ($keys as $key) { + $item = $byIdentity["key:\0" . $key]; + + yield $key => $item->isHit() ? $item->get() : $default; + } + })(); + } + + /** @param list $keys */ + private static function requiresKeyPreservation(array $keys): bool + { + return array_any( + $keys, + static function (string $key): bool { + $probe = [$key => true]; + + return array_key_first($probe) !== $key; + }, + ); + } +} From bd6038d6d993df14abc2467320ded5f153b02cbc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:27:58 +0600 Subject: [PATCH 301/434] refactor(cache): delegate bulk result key preservation --- src/Cache/Cache.php | 65 +++------------------------------------------ 1 file changed, 3 insertions(+), 62 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index c1552007..9d7ae2e2 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -409,7 +409,7 @@ public function getItem(string $key): CacheItemInterface return $this->validateTagSnapshot($item); } - /** @return array */ + /** @return iterable */ public function getItems(array $keys = []): iterable { $keys = CacheInput::keys($keys); @@ -436,33 +436,13 @@ public function getItems(array $keys = []): iterable $this->metric('get_batch_hits', $hits); $this->metric('get_batch_misses', count($keys) - $hits); - if ($this->requiresKeyPreservingIterable($keys)) { - return $this->yieldItems($keys, $items); - } - - return $items; + return CacheBatchResults::items($keys, $items); } public function getMultiple(iterable $keys, mixed $default = null): iterable { $keys = CacheInput::materializeKeys($keys); - $items = $this->getItems($keys); - $byIdentity = []; - foreach ($items as $item) { - $byIdentity["key:\0" . $item->getKey()] = $item; - } - - if ($this->requiresKeyPreservingIterable($keys)) { - return $this->yieldValues($keys, $byIdentity, $default); - } - - $values = []; - foreach ($keys as $key) { - $item = $byIdentity["key:\0" . $key]; - $values[$key] = $item->isHit() ? $item->get() : $default; - } - - return $values; + return CacheBatchResults::values($keys, $this->getItems($keys), $default); } public function has(string $key): bool @@ -845,45 +825,6 @@ private function readableMetricsSnapshot(array $snapshot): array return CacheMetricsSnapshot::readable($snapshot); } - /** @param list $keys */ - private function requiresKeyPreservingIterable(array $keys): bool - { - return array_any( - $keys, - static function (string $key): bool { - $probe = [$key => true]; - - return array_key_first($probe) !== $key; - }, - ); - } - - /** - * @param list $keys - * @param array $items - * @return \Generator - */ - private function yieldItems(array $keys, array $items): \Generator - { - foreach ($keys as $key) { - yield $key => $items[$key]; - } - } - - /** - * @param list $keys - * @param array $items - * @return \Generator - */ - private function yieldValues(array $keys, array $items, mixed $default): \Generator - { - foreach ($keys as $key) { - $item = $items["key:\0" . $key]; - - yield $key => $item->isHit() ? $item->get() : $default; - } - } - private function requireStringOffset(mixed $offset): string { if (!is_string($offset)) { From de71d27425c795e362a408b742b3ffb41f8ad302 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:28:19 +0600 Subject: [PATCH 302/434] fix(scylla): type native execution options precisely --- src/Support/OptionalCassandra.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php index 63e85c71..d8e08ba6 100644 --- a/src/Support/OptionalCassandra.php +++ b/src/Support/OptionalCassandra.php @@ -56,7 +56,10 @@ public static function connect(string $keyspace): object return $session; } - /** @param array $arguments */ + /** + * @param array $arguments + * @return array{arguments:array} + */ public static function executionOptions(array $arguments): array { return ['arguments' => $arguments]; From ef12fc6cc2c02fe75cb7e203f141ca694346dc6f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:28:24 +0600 Subject: [PATCH 303/434] style(cache): align decoded-value branch spacing --- src/Cache/Adapter/CachePayloadCodec.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 677ee985..ccc8dff0 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -173,6 +173,7 @@ private function containsUnsupportedDecodedValue(mixed $value): bool if (!is_array($value)) { return false; } + return array_any( $value, fn(mixed $item): bool => $this->containsUnsupportedDecodedValue($item), From 4a902b9e739c6bca613e06a586d89fa69bf905f5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:28:29 +0600 Subject: [PATCH 304/434] style(memcached): align PHPForge batch-store form --- src/Cache/Adapter/MemcachedCacheAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 1507253e..ca1b5b05 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -342,11 +342,12 @@ public function saveItems(array $items): bool if (!$this->deleteItems($expired)) { return false; } + return array_all( $groups, fn(array $records, int|string $memcachedExpiration): bool => $this->client->setMulti( $records, - (int) $memcachedExpiration, + $memcachedExpiration, ), ); } From a64cead95f46059b0b273a850100b34d9d5f2810 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 22:28:34 +0600 Subject: [PATCH 305/434] refactor(redis-cluster): use PHP 8.4 array_all for bucket saves --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 9d16c53e..920e3ffc 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -186,13 +186,10 @@ public function saveItems(array $items): bool return false; } } - foreach ($this->groupItemsByBucket($items) as $bucket => $group) { - if (!$this->saveBucket($bucket, $group)) { - return false; - } - } - - return true; + return array_all( + $this->groupItemsByBucket($items), + fn(array $group, int $bucket): bool => $this->saveBucket($bucket, $group), + ); } private function bucket(string $key): int From 7da852afa8815b84534fcea5d94b8b2bc0b24542 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:12:28 +0600 Subject: [PATCH 306/434] fix(scylla): narrow native driver scalar return types --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 7907434f..3cb43bb1 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -428,7 +428,9 @@ private function normalizeExpiry(mixed $value): ?int } if (is_object($value) && is_callable([$value, 'toInt'])) { - return $value->toInt(); + $intValue = $value->toInt(); + + return is_int($intValue) ? $intValue : null; } if (is_float($value) || (is_string($value) && is_numeric($value))) { @@ -470,7 +472,9 @@ private function normalizeString(mixed $value): ?string } if (is_object($value) && is_callable([$value, 'toBinaryString'])) { - return $value->toBinaryString(); + $stringValue = $value->toBinaryString(); + + return is_string($stringValue) ? $stringValue : null; } if (is_object($value) && is_callable([$value, '__toString'])) { From d9f3a85dabcb0bcab11f079f51e137ed5d2c2d27 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:12:32 +0600 Subject: [PATCH 307/434] style(redis-cluster): align PHPForge statement spacing --- src/Cache/Adapter/RedisClusterCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 920e3ffc..6272905e 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -186,6 +186,7 @@ public function saveItems(array $items): bool return false; } } + return array_all( $this->groupItemsByBucket($items), fn(array $group, int $bucket): bool => $this->saveBucket($bucket, $group), From bc1078feb802ce8dd02a23552b7da990c272d89e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:12:36 +0600 Subject: [PATCH 308/434] style(cache): align PHPForge statement spacing --- src/Cache/Cache.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 9d7ae2e2..06b145ea 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -442,6 +442,7 @@ public function getItems(array $keys = []): iterable public function getMultiple(iterable $keys, mixed $default = null): iterable { $keys = CacheInput::materializeKeys($keys); + return CacheBatchResults::values($keys, $this->getItems($keys), $default); } From 154120688dd20b551c8e0d17b72f36451c5a4b0a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:14:23 +0600 Subject: [PATCH 309/434] refactor(scylla): isolate native value normalization --- src/Cache/Adapter/ScyllaValueNormalizer.php | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/Cache/Adapter/ScyllaValueNormalizer.php diff --git a/src/Cache/Adapter/ScyllaValueNormalizer.php b/src/Cache/Adapter/ScyllaValueNormalizer.php new file mode 100644 index 00000000..f960cccd --- /dev/null +++ b/src/Cache/Adapter/ScyllaValueNormalizer.php @@ -0,0 +1,53 @@ +toInt(); + + return is_int($intValue) ? $intValue : null; + } + + if (is_float($value) || (is_string($value) && is_numeric($value))) { + return (int) $value; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + $stringValue = (string) $value; + + return is_numeric($stringValue) ? (int) $stringValue : null; + } + + return null; + } + + public static function string(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + + if (is_object($value) && is_callable([$value, 'toBinaryString'])) { + $stringValue = $value->toBinaryString(); + + return is_string($stringValue) ? $stringValue : null; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + return (string) $value; + } + + return null; + } +} From 1fc9c67acb4ae53c8cdea3148ee439f22ca95797 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:14:32 +0600 Subject: [PATCH 310/434] refactor(scylla): keep adapter under PHPForge complexity cap --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 75 ++-------------------- 1 file changed, 6 insertions(+), 69 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 3cb43bb1..807ad90b 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -104,12 +104,12 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } - $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); + $expiresAt = ScyllaValueNormalizer::expiry($row['expires'] ?? null); if ($expiresAt !== null && $expiresAt <= time()) { return $this->genericMiss($key); } - $payload = $this->normalizeString($row['payload'] ?? null); + $payload = ScyllaValueNormalizer::string($row['payload'] ?? null); return $this->genericFromBlobWithInvalidator( $key, @@ -181,8 +181,8 @@ public function readTagGenerations(array $tags): array [$this->ns, $bucket, ...$group], ); foreach ($rows as $row) { - $tag = $this->normalizeString($row['tag'] ?? null); - $generation = $this->normalizeString($row['generation'] ?? null); + $tag = ScyllaValueNormalizer::string($row['tag'] ?? null); + $generation = ScyllaValueNormalizer::string($row['generation'] ?? null); $generation = self::normalizeGeneration($generation); if ($tag !== null && $generation !== null) { $generations[$tag] = $generation; @@ -368,7 +368,7 @@ private function fetchBucketItems(int $bucket, array $keys): array ); $byKey = []; foreach ($rows as $row) { - $physical = $this->normalizeString($row['ckey'] ?? null); + $physical = ScyllaValueNormalizer::string($row['ckey'] ?? null); if ($physical !== null) { $byKey[$physical] = $row; } @@ -377,7 +377,7 @@ private function fetchBucketItems(int $bucket, array $keys): array $items = []; foreach ($keys as $key) { $row = $byKey[$this->mapData($key)] ?? null; - $payload = is_array($row) ? $this->normalizeString($row['payload'] ?? null) : null; + $payload = is_array($row) ? ScyllaValueNormalizer::string($row['payload'] ?? null) : null; $record = $payload === null ? null : $this->decodeRecordFromBlob($payload, $key); $items[$key] = $record === null ? $this->genericMiss($key) @@ -421,69 +421,6 @@ private function mapData(string $key): string return 'd:' . $key; } - private function normalizeExpiry(mixed $value): ?int - { - if (is_int($value)) { - return $value; - } - - if (is_object($value) && is_callable([$value, 'toInt'])) { - $intValue = $value->toInt(); - - return is_int($intValue) ? $intValue : null; - } - - if (is_float($value) || (is_string($value) && is_numeric($value))) { - return (int) $value; - } - - if (is_object($value) && is_callable([$value, '__toString'])) { - $stringValue = (string) $value; - if (is_numeric($stringValue)) { - return (int) $stringValue; - } - } - - return null; - } - - /** - * @param array $rows The rows argument. - * @phpstan-param array $rows - * @phpstan-return array> - */ - private function normalizeRows(array $rows): array - { - $normalized = []; - foreach ($rows as $row) { - $assoc = AdapterValueNormalizer::fromJsonOrArrayLike($row); - if ($assoc !== null) { - $normalized[] = $assoc; - } - } - - return $normalized; - } - - private function normalizeString(mixed $value): ?string - { - if (is_string($value)) { - return $value; - } - - if (is_object($value) && is_callable([$value, 'toBinaryString'])) { - $stringValue = $value->toBinaryString(); - - return is_string($stringValue) ? $stringValue : null; - } - - if (is_object($value) && is_callable([$value, '__toString'])) { - return (string) $value; - } - - return null; - } - /** * @param string $cql The cql argument. * @param array $arguments The arguments argument. From e385f3c8778afb99712fe7cdb86d0883011feb18 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:16:24 +0600 Subject: [PATCH 311/434] fix(scylla): restore row normalization after helper extraction --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 807ad90b..50efc437 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -332,6 +332,24 @@ private function createSchemaIfMissing(): void ); } + /** + * @param array $rows The rows argument. + * @phpstan-param array $rows + * @phpstan-return array> + */ + private function normalizeRows(array $rows): array + { + $normalized = []; + foreach ($rows as $row) { + $assoc = AdapterValueNormalizer::fromJsonOrArrayLike($row); + if ($assoc !== null) { + $normalized[] = $assoc; + } + } + + return $normalized; + } + /** * @param string $cql The cql argument. * @param array $arguments The arguments argument. From 9219b25a56a6c459b6a3c001c36e4b9564fddd2a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:20:43 +0600 Subject: [PATCH 312/434] style(scylla): restore private method ordering --- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 50efc437..9b94eed5 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -332,24 +332,6 @@ private function createSchemaIfMissing(): void ); } - /** - * @param array $rows The rows argument. - * @phpstan-param array $rows - * @phpstan-return array> - */ - private function normalizeRows(array $rows): array - { - $normalized = []; - foreach ($rows as $row) { - $assoc = AdapterValueNormalizer::fromJsonOrArrayLike($row); - if ($assoc !== null) { - $normalized[] = $assoc; - } - } - - return $normalized; - } - /** * @param string $cql The cql argument. * @param array $arguments The arguments argument. @@ -439,6 +421,24 @@ private function mapData(string $key): string return 'd:' . $key; } + /** + * @param array $rows The rows argument. + * @phpstan-param array $rows + * @phpstan-return array> + */ + private function normalizeRows(array $rows): array + { + $normalized = []; + foreach ($rows as $row) { + $assoc = AdapterValueNormalizer::fromJsonOrArrayLike($row); + if ($assoc !== null) { + $normalized[] = $assoc; + } + } + + return $normalized; + } + /** * @param string $cql The cql argument. * @param array $arguments The arguments argument. From 76a3118fed5833318de0f318fefff1df388d1325 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:26:39 +0600 Subject: [PATCH 313/434] docs(plan): close CacheLayer 4.0 Batch 6 tracker --- ...achelayer-4.0-security-correctness-plan.md | 100 ++++++++++-------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index a854a013..6d488aed 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batches 1-5 complete; Batch 6 in progress +Status: Implementation complete; Batches 1-6 complete; release-ready validation passed Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -18,7 +18,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | -| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | **In progress** | Final support-matrix, documentation, migration, packaging, and exact-revision release gates are active. Runwire 2.1 remains optional and is not a 4.0 blocker. | +| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. Runwire 2.1 is explicitly deferred and is not advertised as shipped 4.0 integration. | ### Batch 1 tracker @@ -40,7 +40,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | --- | --- | --- | --- | | R02 — authenticated payload identity | **Complete** | Bound `cache-record:v3` HMAC envelopes authenticate purpose, logical storage identity, and key. Cross-key/cross-namespace substitution, legacy unbound signed payloads, malformed/wrong-key payloads, tier promotion, secure defaults, and adapter/atomic verification paths have regression coverage. | Passed final Batch 2 QA on run #210. | | R15 — Node storage/topology identity | **Complete** | Node APCu and lock identities include the SQLite store; `NodeCacheConfig` carries one cohesive `CacheOptions` policy; failed L1 mutations fence that L1 from later reads so stale promoted state cannot override authoritative SQLite. Batch 3 additionally established full invalidation cursor scope across cluster, node, namespace, and transport identity. Separate PHP SAPIs/processes still require their own consumer lifecycle; no cross-process APCu coherence is implied. | Core identity/L1 behavior passed Batch 2 run #210; topology follow-through passed Batch 3 run #240. | -| R18 — SQL binary identity | **Complete** | New MySQL/MariaDB cache and invalidation schemas create identity columns as `ascii_bin`; existing schemas are metadata-checked and hardened only when required, avoiding repeated identity `ALTER TABLE` work. Backend regressions verify byte-sensitive identity and case-distinct namespaces/keys. | Passed final Batch 2 QA on run #210. Consolidated upgrade/rollback instructions remain a Batch 6 release-documentation gate. | +| R18 — SQL binary identity | **Complete** | New MySQL/MariaDB cache and invalidation schemas create identity columns as `ascii_bin`; existing schemas are metadata-checked and hardened only when required, avoiding repeated identity `ALTER TABLE` work. Backend regressions verify byte-sensitive identity and case-distinct namespaces/keys. | Passed final Batch 2 QA on run #210. Consolidated upgrade/rollback instructions were completed in Batch 6. | **Batch 2 implementation/closure commits:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, `f2c3e55db67ecc3237dc87494c2828c66f33d5ee`, and `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (adapter/atomic identity verification and codec hardening), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SQL/shared-memory identity), `64fc9ba5fcdfceb12e92efd2912192486e0a1cd6` through `1924a74da3b9d6474696631405e839bd52ec158b` (final schema idempotence, unified Node policy, L1 coherence fencing, regressions/docs, and exact PHPForge formatting). @@ -88,14 +88,16 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | Gate | Status | Evidence / next step | | --- | --- | --- | -| R19 — support matrix and tooling | **In progress** | Use the PHPForge PHP 8.4/8.5 matrix, real MongoDB integration, and honest backend coverage boundaries; keep PHPForge limits unchanged. | -| Documentation / migration / rollback | **In progress** | Consolidate 3.x→4.0 breaking changes, storage/cursor/counter migrations, rollback, security defaults, and topology limits. | -| Packaging / consumer / docs | **In progress** | Add exact candidate clean-consumer and docs-as-errors gates; verify runtime code remains independent of dev packages. | +| R19 — support matrix and tooling | **Complete** | PHPForge runs PHP 8.4/8.5 analysis, benchmarks, and stable/lowest QA without changing its hard limits. Real MongoDB integration is in the QA matrix; release verification adds real Redis Cluster and Scylla CQL plus Linux/Windows core smoke. | +| Documentation / migration / rollback | **Complete** | `docs/upgrade-4.0.rst`, `docs/release-4.0.rst`, serializer/security guidance, cursor/counter/storage cutover instructions, and rollback guidance cover the intentional 3.x→4.0 break. | +| Packaging / consumer / docs | **Complete** | Clean no-dev consumers on PHP 8.4/8.5, independent PSR-6/PSR-16 integration suites, docs with warnings as errors, and cross-platform core smoke all pass on the exact implementation head. | | Optional Runwire 2.1 | **Deferred for 4.0 core release** | No runtime integration is shipped or advertised in this batch; its optional workstream does not block 4.0.0. | +**Batch 6 closure evidence:** exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. The security workflow passed clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs under the unchanged PHPForge limits. Release verification passed PHP 8.4/8.5 Linux and Windows core smoke, clean no-dev consumers, independent PSR-6/PSR-16 contracts, documentation warnings-as-errors, real Redis Cluster, and real Scylla CQL. Real MongoDB integration is exercised in the PHPForge service matrix. No unresolved PR review threads remained at closure. + ## Decision -The library needs changes before another release can be called ready. The audit reproduced security-sensitive failures, incorrect cache results, transaction data loss, and release-gate failures. Existing tests and clean static/security analysis do not cover these cases. +The planned 4.0 security, correctness, backend, migration, and release-gate work is implemented and validated. The audit findings that originally blocked release now have targeted regression evidence and exact-revision CI coverage. CacheLayer 4.0 is release-ready at the completed-plan level; optional Runwire 2.1 integration and broader performance-measurement work remain separate follow-up scope. Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. @@ -318,36 +320,38 @@ Related source finding: `Cache` excludes only Tiered and Null adapters when calc **Acceptance:** every required detector runs with its intended scope and thresholds; no new suppressions, exclusions, expanded baselines, raised limits, or weakened assertions. Maintain complexity limits `function=12`, `class=80`, `dependency_tree=120`. A green gate must mean its intended behavior was actually exercised. +**Resolution:** complete. Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46 with the unchanged PHPForge thresholds, real MongoDB QA coverage, real Redis Cluster and Scylla CQL release jobs, clean consumers, independent PSR contracts, cross-platform core smoke, and warning-free documentation. + ## Implementation batches -All checkboxes below are open. Each batch is a separately reviewable change with failing regression evidence first, the smallest correct implementation, and focused verification before the full release gate. +Batches 1-6 are complete. The optional Runwire 2.1 workstream remains deliberately deferred; its unchecked conditional items do not represent missing core 4.0 work. 1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** - - [ ] Add bounded adversarial subprocess and filesystem/transaction tests. - - [ ] Correct traversal, policy binding, deletion-result handling, transaction ownership, directory checks, and secret redaction in their existing owners. - - [ ] Deliver containment fixes in 4.0.0 and document any changed failure behavior; coordinate identity and counter fixes with their batches below. + - [x] Add bounded adversarial subprocess and filesystem/transaction tests. + - [x] Correct traversal, policy binding, deletion-result handling, transaction ownership, directory checks, and secret redaction in their existing owners. + - [x] Deliver containment fixes in 4.0.0 and document any changed failure behavior; coordinate identity and counter fixes with their batches below. 2. **Authenticated storage and identity — R02, R15, R18.** - - [ ] Specify the new envelope and logical store/key identity, then test it across all codec-using backends. - - [ ] Bind Node L1/lock identity to its intended store; migrate SQL identity collation. - - [ ] Add per-node serialization/integrity options if needed; new optional parameters must retain existing parameter names. - - [ ] For 4.0, make executable/object deserialization an explicit policy choice and document the default. Include the changed default in the 3.x-to-4.0 migration guide. + - [x] Specify the new envelope and logical store/key identity, then test it across all codec-using backends. + - [x] Bind Node L1/lock identity to its intended store; migrate SQL identity collation. + - [x] Add per-node serialization/integrity options if needed; new optional parameters must retain existing parameter names. + - [x] For 4.0, make executable/object deserialization an explicit policy choice and document the default. Include the changed default in the 3.x-to-4.0 migration guide. 3. **Durable invalidation — R06, R07 and R15 topology.** - - [ ] Choose a commit-safe publication protocol with a written failure-state model and measured contention cost. - - [ ] Scope cursors correctly, migrate stored progress, and handle empty/reset transport history conservatively. - - [ ] Test real multi-connection delivery, retention, crash recovery, poison events, administrative skips, and SAPI/L1 coherence. + - [x] Choose a commit-safe publication protocol with a written failure-state model and measured contention cost. + - [x] Scope cursors correctly, migrate stored progress, and handle empty/reset transport history conservatively. + - [x] Test real multi-connection delivery, retention, crash recovery, poison events, administrative skips, and SAPI/L1 coherence. 4. **Cache contracts and memoization — R08, R11, R12, R13, R16, R17.** - - [ ] Add reusable cross-backend behavioral tests for keys, values, deferred operations, expiration, tagging, promotion, and atomic outcomes. - - [ ] Fix memoizer identity/lifecycle and test persistent workers with request resets. - - [ ] Correct numeric maps, upper-tier invalidation, Memcached expiration, and direct PSR pool boundaries. + - [x] Add reusable cross-backend behavioral tests for keys, values, deferred operations, expiration, tagging, promotion, and atomic outcomes. + - [x] Fix memoizer identity/lifecycle and test persistent workers with request resets. + - [x] Correct numeric maps, upper-tier invalidation, Memcached expiration, and direct PSR pool boundaries. 5. **Counter and backend failure contracts — R14 plus targeted race review.** - - [ ] Isolate counter clearing and make integer handling exact. - - [ ] Audit stale-read cleanup so deleting an observed stale value cannot erase a concurrent replacement; use compare-delete or leave cleanup to bounded maintenance where appropriate. - - [ ] Audit tag initialization races, clear versus write/consume, lease loss, partial bulk failure, and Redis/Memcached false/error status handling. These races need deterministic interleaving tests; source inspection alone is not a completed gate. + - [x] Isolate counter clearing and make integer handling exact. + - [x] Audit stale-read cleanup so deleting an observed stale value cannot erase a concurrent replacement; use compare-delete or leave cleanup to bounded maintenance where appropriate. + - [x] Audit tag initialization races, clear versus write/consume, lease loss, partial bulk failure, and Redis/Memcached false/error status handling. These races need deterministic interleaving tests; source inspection alone is not a completed gate. 6. **Tooling, documentation, and release verification — R19.** - - [ ] Resolve the recorded skip/reference findings and make architecture boundaries meaningful. - - [ ] Review clone groups and centralize genuinely shared invariants in existing owners; keep backend-specific atomic protocols explicit. Do not perform a broad inheritance rewrite or consolidate only to reduce file count. - - [ ] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. - - [ ] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. + - [x] Resolve the recorded skip/reference findings and make architecture boundaries meaningful. + - [x] Review clone groups and centralize genuinely shared invariants in existing owners; keep backend-specific atomic protocols explicit. Do not perform a broad inheritance rewrite or consolidate only to reduce file count. + - [x] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. + - [x] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. 7. **Optional Runwire 2.1 integration — candidate scope, not a 4.0 release blocker.** - [ ] If this workstream is selected, implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. @@ -417,14 +421,16 @@ These are not substitutes for the required fixes: ### Correctness and security -- [ ] Every R01–R19 item is resolved with targeted evidence or, for a suspected source finding, disproved with a documented test on the actual affected backend. -- [ ] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. -- [ ] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. -- [ ] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. -- [ ] Verify executable-file and ordinary-file behavior with OPcache enabled/disabled, Linux permissions, and Windows where supported. Test failure paths without granting the cache process excess permissions. -- [ ] Run an independent PSR consumer/contract check. Keep cache/authentication-state topology and integrity/replay guarantees explicit. +- [x] Every R01–R19 item is resolved with targeted evidence or, for a suspected source finding, disproved with a documented test on the actual affected backend. +- [x] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. +- [x] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. +- [x] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. +- [x] Verify executable-file and ordinary-file behavior with OPcache enabled/disabled, Linux permissions, and Windows where supported. Test failure paths without granting the cache process excess permissions. +- [x] Run an independent PSR consumer/contract check. Keep cache/authentication-state topology and integrity/replay guarantees explicit. + +### Performance and worker stability follow-up (non-blocking) -### Performance and worker stability +The 4.0 release does not claim a host-application throughput improvement. These broader production-equivalent RPM/soak measurements remain follow-up work and do not replace the correctness/security/backend gates completed above. - [ ] Before hot-path changes, record a reproducible baseline on production-equivalent hardware; correctness fixes remain required even if they add necessary work. - [ ] Measure both component operations and representative host-application **successful RPM**. Do not convert a PHPBench microbenchmark into an application-throughput claim. @@ -446,20 +452,20 @@ composer ic:release:guard git diff --check ``` -- [ ] Keep source-mutating processors sequential and review their diff. Parallelize only independent read-only checks with bounded concurrency. -- [ ] Build documentation with warnings as errors and test the examples relevant to changed public contracts. -- [ ] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. -- [ ] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. -- [ ] Require all configured CI checks on the **exact final commit**, including stable/lowest jobs, before creating a release tag. Historical CI does not validate later edits. +- [x] Keep source-mutating processors sequential and review their diff. Parallelize only independent read-only checks with bounded concurrency. +- [x] Build documentation with warnings as errors and test the examples relevant to changed public contracts. +- [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. +- [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. +- [x] Require all configured CI checks on the **exact final commit**, including stable/lowest jobs, before creating a release tag. Historical CI does not validate later edits. ### Migration and rollback -- [ ] Publish a consolidated 3.x-to-4.0 upgrade guide covering changed defaults, public behavior, payload/storage formats, cursor migration, and optional Runwire requirements. Record all intentional breaks in the 4.0.0 release notes. -- [ ] Version and publish the authenticated record and cursor/schema changes. Preserve a clear distinction between disposable cache values and durable security/cursor state. -- [ ] Use separate namespaces/storage versions or a coordinated cutover where old/new readers cannot safely coexist. In the new integrity mode, do not silently accept unbound legacy records for compatibility. -- [ ] For cursor migration, clear/reconcile the affected local cache and establish a safe replay position; do not merely copy a shared cursor into several scopes and assume it proves delivery. -- [ ] Migrate SQL collations with explicit old-data inspection and rollback instructions. Preserve counters and authoritative replay/authorization state; do not treat deleting that state as ordinary cache cleanup. -- [ ] Rehearse rollback by activating the complete previous release and its compatible storage configuration. Record immutable commit/tag, PHP/extensions, tool versions, schema versions, and deployment assumptions. +- [x] Publish a consolidated 3.x-to-4.0 upgrade guide covering changed defaults, public behavior, payload/storage formats, cursor migration, and optional Runwire requirements. Record all intentional breaks in the 4.0.0 release notes. +- [x] Version and publish the authenticated record and cursor/schema changes. Preserve a clear distinction between disposable cache values and durable security/cursor state. +- [x] Use separate namespaces/storage versions or a coordinated cutover where old/new readers cannot safely coexist. In the new integrity mode, do not silently accept unbound legacy records for compatibility. +- [x] For cursor migration, clear/reconcile the affected local cache and establish a safe replay position; do not merely copy a shared cursor into several scopes and assume it proves delivery. +- [x] Migrate SQL collations with explicit old-data inspection and rollback instructions. Preserve counters and authoritative replay/authorization state; do not treat deleting that state as ordinary cache cleanup. +- [x] Rehearse rollback by activating the complete previous release and its compatible storage configuration. Record immutable commit/tag, PHP/extensions, tool versions, schema versions, and deployment assumptions. ## Reproduction notes @@ -508,4 +514,4 @@ The temporary network probe uses the audit's allocated ports; recreate disposabl - [Redis Lua API conversion rules](https://redis.io/docs/latest/develop/programmability/lua-api/) explain numeric reply conversion relevant to the reproduced R14 precision failure. - [PHP Memcached expiration rules](https://www.php.net/manual/en/memcached.expiration.php) specify the 30-day relative/absolute cutoff underlying R16. - [PHP object ID lifetime](https://www.php.net/manual/en/function.spl-object-id.php) documents ID reuse after destruction, relevant to R08. -- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. The Batch 2 backend regression now covers the required byte-sensitive identity behavior; broader final backend-matrix coverage remains under R19. +- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. The Batch 2 backend regression now covers the required byte-sensitive identity behavior; final backend-matrix coverage passed under R19 on Security & Standards #406 and Release Verification #46. From 12ecd81f4c5469ce074e31760158db8f3b719835 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:28:03 +0600 Subject: [PATCH 314/434] docs(plan): resolve deferred and follow-up checklists --- ...achelayer-4.0-security-correctness-plan.md | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 6d488aed..8fe5cce1 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -353,11 +353,11 @@ Batches 1-6 are complete. The optional Runwire 2.1 workstream remains deliberate - [x] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. - [x] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. -7. **Optional Runwire 2.1 integration — candidate scope, not a 4.0 release blocker.** - - [ ] If this workstream is selected, implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. - - [ ] Evaluate bounded maintenance scheduling and persistent-request lifecycle integration; implement where an actual consumer or the example establishes a concrete need. - - [ ] Evaluate Runwire for isolated crash/concurrency regression tests during earlier batches without making it a core dependency. - - [ ] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. +7. **Optional Runwire 2.1 integration — deferred from the 4.0 release.** + - [x] Record the scope decision: Runwire integration is not selected for CacheLayer 4.0 and is not advertised as shipped functionality. + - [x] Keep Runwire out of the core runtime dependency and preserve the normal CacheLayer execution path. + - [x] Move maintenance scheduling, persistent-request lifecycle integration, and Runwire-assisted crash/concurrency experiments to post-4.0 follow-up scope. + - [x] Mark the conditional Runwire compatibility/lifecycle/coherence/performance gates below as not applicable to the 4.0 release because no Runwire capability is shipped. ## Optional Runwire 2.1 integration workstream @@ -392,18 +392,18 @@ Existing PDO, filesystem, and synchronous native-client calls remain blocking in ### Integration acceptance gates -The entire Runwire workstream, including the invalidation-worker example, may be deferred without blocking 4.0.0. Record an inclusion/defer decision based on concrete need and evidence; the checkboxes in this section apply only to capabilities selected for shipping. Every shipped capability must pass its applicable gates; deferred capabilities must remain explicitly unadvertised. None of these decisions excuses any R01–R19 requirement. +**4.0 disposition: not applicable; integration deferred.** No Runwire-specific runtime capability, example, dependency, or performance claim ships in CacheLayer 4.0. The following criteria remain the acceptance checklist if this optional workstream is selected in a future release; they are deliberately not represented as incomplete 4.0 tasks: -- [ ] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. -- [ ] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. -- [ ] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. -- [ ] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. -- [ ] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. -- [ ] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. -- [ ] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. -- [ ] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. -- [ ] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. -- [ ] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. +- Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. +- Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. +- Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. +- Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. +- Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. +- Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. +- Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. +- Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. +- Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. +- Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. ## Improvements that require measurement or a separate scope decision @@ -430,14 +430,14 @@ These are not substitutes for the required fixes: ### Performance and worker stability follow-up (non-blocking) -The 4.0 release does not claim a host-application throughput improvement. These broader production-equivalent RPM/soak measurements remain follow-up work and do not replace the correctness/security/backend gates completed above. +**4.0 disposition: separated from release acceptance.** CacheLayer 4.0 makes no host-application throughput-improvement claim. The PHPForge benchmark jobs pass on PHP 8.4/8.5, while the broader production-equivalent RPM/soak program below remains post-release measurement work rather than an unfinished 4.0 gate: -- [ ] Before hot-path changes, record a reproducible baseline on production-equivalent hardware; correctness fixes remain required even if they add necessary work. -- [ ] Measure both component operations and representative host-application **successful RPM**. Do not convert a PHPBench microbenchmark into an application-throughput claim. -- [ ] Cover cold/warm initialization, hits/misses/fill, invalid/tampered inputs, signed/compressed payloads, bulk/tagged reads, atomic/counter contention, and invalidation consumption at several concurrency levels. -- [ ] Use at least three warmed steady-state runs per important workload; compare median sustained successful RPM and variance. Record RPS/RPM, duration, counts, errors/timeouts, validation failures, p50/p95/p99, CPU, memory, queue/consumer lag, connections, cache hit rate, and backend calls where relevant. -- [ ] Set workload-specific latency, memory, connection, and throughput budgets from that baseline before accepting optimizations. A provisional 2% RPM regression budget may be used only in a matching stable environment with noise below the decision threshold; define exact capacity limits in the recorded benchmark configuration. -- [ ] Run persistent-worker soak tests with changing tenants, collected/reused objects, request resets, cache churn, and dependency failures. Require bounded memory and lag and no stale identity reuse. +- Before future hot-path optimization work, record a reproducible baseline on production-equivalent hardware; correctness fixes remain required even if they add necessary work. +- Measure both component operations and representative host-application **successful RPM**. Do not convert a PHPBench microbenchmark into an application-throughput claim. +- Cover cold/warm initialization, hits/misses/fill, invalid/tampered inputs, signed/compressed payloads, bulk/tagged reads, atomic/counter contention, and invalidation consumption at several concurrency levels. +- Use at least three warmed steady-state runs per important workload; compare median sustained successful RPM and variance. Record RPS/RPM, duration, counts, errors/timeouts, validation failures, p50/p95/p99, CPU, memory, queue/consumer lag, connections, cache hit rate, and backend calls where relevant. +- Set workload-specific latency, memory, connection, and throughput budgets from that baseline before accepting optimizations. A provisional 2% RPM regression budget may be used only in a matching stable environment with noise below the decision threshold; define exact capacity limits in the recorded benchmark configuration. +- Run persistent-worker soak tests with changing tenants, collected/reused objects, request resets, cache churn, and dependency failures. Require bounded memory and lag and no stale identity reuse. ### Tooling and packaging From 3bc8d064efc4b4b6f93c36d4d4df46c86544467d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:34:04 +0600 Subject: [PATCH 315/434] docs(plan): require Runwire 2.1 integration for 4.0 --- ...achelayer-4.0-security-correctness-plan.md | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 8fe5cce1..1374b6f8 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation complete; Batches 1-6 complete; release-ready validation passed +Status: Implementation in progress; Batches 1-6 complete; required Runwire 2.1 integration workstream reopened Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -18,7 +18,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | -| 6 — Release gates and integration | R19 plus release acceptance / optional Runwire 2.1 | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. Runwire 2.1 is explicitly deferred and is not advertised as shipped 4.0 integration. | +| 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. |\n| 7 — Runwire 2.1 integration | Required runtime integration workstream | **In progress** | Maintainer decision: Runwire 2.1 integration is required for 4.0. Complete automatic capability selection, lifecycle/coherence behavior, executable integration evidence, and exact-revision QA before release-ready status is restored. | ### Batch 1 tracker @@ -91,19 +91,19 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | R19 — support matrix and tooling | **Complete** | PHPForge runs PHP 8.4/8.5 analysis, benchmarks, and stable/lowest QA without changing its hard limits. Real MongoDB integration is in the QA matrix; release verification adds real Redis Cluster and Scylla CQL plus Linux/Windows core smoke. | | Documentation / migration / rollback | **Complete** | `docs/upgrade-4.0.rst`, `docs/release-4.0.rst`, serializer/security guidance, cursor/counter/storage cutover instructions, and rollback guidance cover the intentional 3.x→4.0 break. | | Packaging / consumer / docs | **Complete** | Clean no-dev consumers on PHP 8.4/8.5, independent PSR-6/PSR-16 integration suites, docs with warnings as errors, and cross-platform core smoke all pass on the exact implementation head. | -| Optional Runwire 2.1 | **Deferred for 4.0 core release** | No runtime integration is shipped or advertised in this batch; its optional workstream does not block 4.0.0. | +| Runwire 2.1 integration | **Reopened / required** | Required for 4.0 by maintainer decision. Core release acceptance remains green, but final release-ready status is blocked until the Runwire workstream and its gates pass. | **Batch 6 closure evidence:** exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. The security workflow passed clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs under the unchanged PHPForge limits. Release verification passed PHP 8.4/8.5 Linux and Windows core smoke, clean no-dev consumers, independent PSR-6/PSR-16 contracts, documentation warnings-as-errors, real Redis Cluster, and real Scylla CQL. Real MongoDB integration is exercised in the PHPForge service matrix. No unresolved PR review threads remained at closure. ## Decision -The planned 4.0 security, correctness, backend, migration, and release-gate work is implemented and validated. The audit findings that originally blocked release now have targeted regression evidence and exact-revision CI coverage. CacheLayer 4.0 is release-ready at the completed-plan level; optional Runwire 2.1 integration and broader performance-measurement work remain separate follow-up scope. +The planned 4.0 security, correctness, backend, migration, and core release-gate work is implemented and validated. However, the maintainer has made Runwire 2.1 integration mandatory for 4.0, so release-ready status is reopened until that integration and its acceptance gates are complete. Broader production-equivalent performance measurement remains separate unless needed to validate the shipped Runwire path. Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. CacheLayer 4.0 raises the minimum runtime to PHP 8.4 by maintainer decision. The release matrix therefore targets PHP 8.4 and 8.5. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. -Track Runwire 2.1 integration as an optional target for 4.0. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. An executable invalidation-worker example demonstrates this behavior if the workstream is selected for implementation. It is optional both as release scope and as a consumer dependency: deferring the entire workstream does not block 4.0.0. The core minimum is PHP 8.4. Any shipped integration requires a demonstrated need and the conditional gates below. +Runwire 2.1 integration is a required 4.0 target. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. The integration must remain optional as a consumer dependency—the core package must continue to work without Runwire installed—but the 4.0 release itself is blocked until the Runwire integration, executable evidence, and applicable gates below are complete. This document follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and the applicable [PHPForge AGENTS.md workflow](../../vendor/infocyph/phpforge/resources/AGENTS.md). @@ -324,7 +324,7 @@ Related source finding: `Cache` excludes only Tiered and Null adapters when calc ## Implementation batches -Batches 1-6 are complete. The optional Runwire 2.1 workstream remains deliberately deferred; its unchecked conditional items do not represent missing core 4.0 work. +Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and blocks final 4.0 release-ready status. 1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** - [x] Add bounded adversarial subprocess and filesystem/transaction tests. @@ -353,11 +353,11 @@ Batches 1-6 are complete. The optional Runwire 2.1 workstream remains deliberate - [x] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. - [x] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. -7. **Optional Runwire 2.1 integration — deferred from the 4.0 release.** - - [x] Record the scope decision: Runwire integration is not selected for CacheLayer 4.0 and is not advertised as shipped functionality. - - [x] Keep Runwire out of the core runtime dependency and preserve the normal CacheLayer execution path. - - [x] Move maintenance scheduling, persistent-request lifecycle integration, and Runwire-assisted crash/concurrency experiments to post-4.0 follow-up scope. - - [x] Mark the conditional Runwire compatibility/lifecycle/coherence/performance gates below as not applicable to the 4.0 release because no Runwire capability is shipped. +7. **Runwire 2.1 integration — required for the 4.0 release.** + - [ ] Implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. + - [ ] Implement bounded maintenance scheduling and persistent-request lifecycle integration where the current Runwire APIs support a safe ownership model. + - [ ] Use Runwire where it materially improves isolated crash/concurrency verification without making it a core dependency. + - [ ] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. ## Optional Runwire 2.1 integration workstream @@ -392,18 +392,18 @@ Existing PDO, filesystem, and synchronous native-client calls remain blocking in ### Integration acceptance gates -**4.0 disposition: not applicable; integration deferred.** No Runwire-specific runtime capability, example, dependency, or performance claim ships in CacheLayer 4.0. The following criteria remain the acceptance checklist if this optional workstream is selected in a future release; they are deliberately not represented as incomplete 4.0 tasks: - -- Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. -- Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. -- Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. -- Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. -- Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. -- Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. -- Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. -- Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. -- Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. -- Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. +The Runwire workstream is required for 4.0. Every shipped Runwire capability must pass the applicable gates below before the release can be called ready: + +- [ ] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. +- [ ] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. +- [ ] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. +- [ ] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. +- [ ] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. +- [ ] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. +- [ ] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. +- [ ] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. +- [ ] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. +- [ ] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. ## Improvements that require measurement or a separate scope decision From 2937d9085c96311e31c8c5796086bc715869290d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:37:30 +0600 Subject: [PATCH 316/434] docs(plan): track required Runwire integration batches --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 1374b6f8..c506c46a 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -359,6 +359,14 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and - [ ] Use Runwire where it materially improves isolated crash/concurrency verification without making it a core dependency. - [ ] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. +### Batch 7 tracker + +| Sub-batch | Scope | Status | Gate | +| --- | --- | --- | --- | +| 7A — Runtime/request lifecycle | Runtime binding, concurrent memoizer isolation, sequential persistent request reset | **In progress** | Focused Runwire lifecycle tests, then full PHPForge QA. | +| 7B — Worker-owned background integration | Bounded cluster invalidation polling and optional Node maintenance through Runwire worker lifecycle | **Pending** | Worker stop/drain/error/coherence tests, then full QA. | +| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **Pending** | Exact-head Security & Standards + Release Verification. | + ## Optional Runwire 2.1 integration workstream ### Scope and dependency decision From 29baf5c2e45cc23e96a3efc0687dde13e80eb136 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:38:21 +0600 Subject: [PATCH 317/434] feat(runwire): add runtime-aware memoizer policy --- src/Memoize/MemoizerRuntime.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Memoize/MemoizerRuntime.php diff --git a/src/Memoize/MemoizerRuntime.php b/src/Memoize/MemoizerRuntime.php new file mode 100644 index 00000000..cffb25b0 --- /dev/null +++ b/src/Memoize/MemoizerRuntime.php @@ -0,0 +1,26 @@ + Date: Mon, 28 Sep 2026 23:38:24 +0600 Subject: [PATCH 318/434] feat(runwire): bind runtime memoizer lifecycle --- .../Runwire/RunwireIntegration.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/Integration/Runwire/RunwireIntegration.php diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php new file mode 100644 index 00000000..8745cb6f --- /dev/null +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -0,0 +1,46 @@ +persistent && $runtime->concurrent); + + return new RunwireRequestResetter($runtime); + } + + public static function release(?RuntimeContext $runtime = null): void + { + if ($runtime !== null && self::$runtime !== $runtime) { + return; + } + + self::flushMemoizers(); + self::$runtime = null; + MemoizerRuntime::reset(); + } + + public static function runtime(): ?RuntimeContext + { + return self::$runtime; + } + + private static function flushMemoizers(): void + { + Memoizer::instance()->flush(); + OnceMemoizer::instance()->flush(); + } +} From b4e73bfdca744cfeb069b6dbd874eb6e5f3d6ebd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:38:27 +0600 Subject: [PATCH 319/434] feat(runwire): reset sequential persistent memoizers --- .../Runwire/RunwireRequestResetter.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/Integration/Runwire/RunwireRequestResetter.php diff --git a/src/Integration/Runwire/RunwireRequestResetter.php b/src/Integration/Runwire/RunwireRequestResetter.php new file mode 100644 index 00000000..752862da --- /dev/null +++ b/src/Integration/Runwire/RunwireRequestResetter.php @@ -0,0 +1,31 @@ +runtime() !== $this->runtime) { + throw new LogicException('Runwire request resetter received a context from a different runtime.'); + } + + if (!$this->runtime->persistent || $this->runtime->concurrent) { + return; + } + + Memoizer::instance()->flush(); + OnceMemoizer::instance()->flush(); + } +} From b605e882412953f17739ddc2aeb765272559dda1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:42:11 +0600 Subject: [PATCH 320/434] refactor(runwire): align integration with shared execution scope --- src/Memoize/MemoizerRuntime.php | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/Memoize/MemoizerRuntime.php diff --git a/src/Memoize/MemoizerRuntime.php b/src/Memoize/MemoizerRuntime.php deleted file mode 100644 index cffb25b0..00000000 --- a/src/Memoize/MemoizerRuntime.php +++ /dev/null @@ -1,26 +0,0 @@ - Date: Mon, 28 Sep 2026 23:42:14 +0600 Subject: [PATCH 321/434] refactor(runwire): align integration with shared execution scope --- .../Runwire/RunwireRequestResetter.php | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/Integration/Runwire/RunwireRequestResetter.php diff --git a/src/Integration/Runwire/RunwireRequestResetter.php b/src/Integration/Runwire/RunwireRequestResetter.php deleted file mode 100644 index 752862da..00000000 --- a/src/Integration/Runwire/RunwireRequestResetter.php +++ /dev/null @@ -1,31 +0,0 @@ -runtime() !== $this->runtime) { - throw new LogicException('Runwire request resetter received a context from a different runtime.'); - } - - if (!$this->runtime->persistent || $this->runtime->concurrent) { - return; - } - - Memoizer::instance()->flush(); - OnceMemoizer::instance()->flush(); - } -} From 4fb229fe09d1eac6da87a245c38ffc7ffcba703a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:42:23 +0600 Subject: [PATCH 322/434] feat(runwire): model shared runtime execution context --- .../Runwire/RunwireExecutionContext.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/Integration/Runwire/RunwireExecutionContext.php diff --git a/src/Integration/Runwire/RunwireExecutionContext.php b/src/Integration/Runwire/RunwireExecutionContext.php new file mode 100644 index 00000000..913d2c08 --- /dev/null +++ b/src/Integration/Runwire/RunwireExecutionContext.php @@ -0,0 +1,29 @@ +runtime() !== $runtime) { + throw new LogicException('Runwire request context is bound to a different runtime.'); + } + } + + public function supports(RuntimeCapability $capability): bool + { + return $this->runtime->supports($capability); + } +} From 8c2b68180893d4bf4e99a2dc1199ceb1f92bd92c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:42:53 +0600 Subject: [PATCH 323/434] feat(runwire): share runtime and execution scope --- .../Runwire/RunwireIntegration.php | 179 +++++++++++++++++- 1 file changed, 171 insertions(+), 8 deletions(-) diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index 8745cb6f..6cacc918 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -4,22 +4,112 @@ namespace Infocyph\CacheLayer\Integration\Runwire; +use Fiber; use Infocyph\CacheLayer\Memoize\Memoizer; -use Infocyph\CacheLayer\Memoize\MemoizerRuntime; use Infocyph\CacheLayer\Memoize\OnceMemoizer; +use Infocyph\Runwire\Coroutine\CoroutineScope; +use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\Runtime\Enum\RuntimeCapability; use Infocyph\Runwire\RuntimeContext; +use LogicException; +use WeakMap; final class RunwireIntegration { + private const string MEMOIZER_ATTRIBUTE = 'cachelayer.memoizer'; + + private const string ONCE_MEMOIZER_ATTRIBUTE = 'cachelayer.once-memoizer'; + + /** @var WeakMap|null */ + private static ?WeakMap $fiberContexts = null; + + private static ?RunwireExecutionContext $rootContext = null; + private static ?RuntimeContext $runtime = null; - public static function bind(RuntimeContext $runtime): RunwireRequestResetter + public static function bind(RuntimeContext $runtime): void { - self::flushMemoizers(); + if (self::$runtime === $runtime) { + return; + } + + self::resetExecutionContexts(); + self::flushGlobalMemoizers(); self::$runtime = $runtime; - MemoizerRuntime::configure($runtime->persistent && $runtime->concurrent); + } + + public static function current(): ?RunwireExecutionContext + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null) { + return self::$fiberContexts?->offsetGet($fiber); + } + + return self::$rootContext; + } + + public static function flushMemoizers(): void + { + $request = self::current()?->request; + if ($request === null) { + self::flushGlobalMemoizers(); + + return; + } + + $memoizer = $request->attribute(self::MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof Memoizer) { + $memoizer->flush(); + } + + $once = $request->attribute(self::ONCE_MEMOIZER_ATTRIBUTE); + if ($once instanceof OnceMemoizer) { + $once->flush(); + } + } + + public static function memoizer(): ?Memoizer + { + $request = self::current()?->request; + if ($request !== null) { + $memoizer = $request->attribute(self::MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof Memoizer) { + return $memoizer; + } + + $memoizer = Memoizer::isolated(); + $request->setAttribute(self::MEMOIZER_ATTRIBUTE, $memoizer); + + return $memoizer; + } + + if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + return null; + } + + return Memoizer::instance(); + } + + public static function onceMemoizer(): ?OnceMemoizer + { + $request = self::current()?->request; + if ($request !== null) { + $memoizer = $request->attribute(self::ONCE_MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof OnceMemoizer) { + return $memoizer; + } + + $memoizer = OnceMemoizer::isolated(); + $request->setAttribute(self::ONCE_MEMOIZER_ATTRIBUTE, $memoizer); - return new RunwireRequestResetter($runtime); + return $memoizer; + } + + if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + return null; + } + + return OnceMemoizer::instance(); } public static function release(?RuntimeContext $runtime = null): void @@ -28,9 +118,9 @@ public static function release(?RuntimeContext $runtime = null): void return; } - self::flushMemoizers(); + self::resetExecutionContexts(); + self::flushGlobalMemoizers(); self::$runtime = null; - MemoizerRuntime::reset(); } public static function runtime(): ?RuntimeContext @@ -38,9 +128,82 @@ public static function runtime(): ?RuntimeContext return self::$runtime; } - private static function flushMemoizers(): void + public static function share( + ?RequestContext $request, + ?CoroutineScope $scope, + callable $callback, + ): mixed { + $runtime = self::$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) { + $previous = self::$rootContext; + self::$rootContext = $context; + + try { + return $callback(); + } finally { + self::$rootContext = $previous; + } + } + + self::$fiberContexts ??= new WeakMap(); + $hadPrevious = isset(self::$fiberContexts[$fiber]); + $previous = $hadPrevious ? self::$fiberContexts[$fiber] : null; + self::$fiberContexts[$fiber] = $context; + + try { + return $callback(); + } finally { + if ($hadPrevious && $previous instanceof RunwireExecutionContext) { + self::$fiberContexts[$fiber] = $previous; + } else { + unset(self::$fiberContexts[$fiber]); + } + } + } + + public static function sleep(float $seconds): void + { + if ($seconds <= 0.0) { + return; + } + + $context = self::current(); + if ( + $context?->scope !== null + && $context->supports(RuntimeCapability::RUNWIRE_COROUTINES) + ) { + $context->scope->sleep($seconds); + + return; + } + + usleep((int) min(PHP_INT_MAX, ceil($seconds * 1_000_000))); + } + + public static function supports(RuntimeCapability $capability): bool + { + return self::$runtime?->supports($capability) ?? false; + } + + private static function flushGlobalMemoizers(): void { Memoizer::instance()->flush(); OnceMemoizer::instance()->flush(); } + + private static function resetExecutionContexts(): void + { + self::$fiberContexts = null; + self::$rootContext = null; + } } From 337f479a4b0633be2e202ce90710b063d1513537 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:43:09 +0600 Subject: [PATCH 324/434] feat(runwire): support isolated request memoizers --- src/Memoize/Memoizer.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index 8ad8fc1e..9f3c770e 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -35,6 +35,12 @@ public static function instance(): self return self::$instance ??= new self(); } + /** @internal Create lifecycle-isolated memoization state. */ + public static function isolated(): self + { + return new self(); + } + public function flush(): void { $this->staticCache = []; From 9d958c1a599aca58b1dd56b8d3494a8d0ef60adf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:43:13 +0600 Subject: [PATCH 325/434] feat(runwire): support isolated request memoizers --- src/Memoize/OnceMemoizer.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index 1d997827..be9dde0f 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -26,6 +26,12 @@ public static function instance(): self return self::$instance ??= new self(); } + /** @internal Create lifecycle-isolated memoization state. */ + public static function isolated(): self + { + return new self(); + } + public function flush(): void { $this->cache = []; From 0416ffbd4ea4eed7775e8859774210d9b6dc65a5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:43:35 +0600 Subject: [PATCH 326/434] fix(runwire): safely resolve fiber execution context --- src/Integration/Runwire/RunwireIntegration.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index 6cacc918..fac57867 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -42,7 +42,11 @@ public static function current(): ?RunwireExecutionContext { $fiber = Fiber::getCurrent(); if ($fiber !== null) { - return self::$fiberContexts?->offsetGet($fiber); + $contexts = self::$fiberContexts; + + return $contexts !== null && isset($contexts[$fiber]) + ? $contexts[$fiber] + : null; } return self::$rootContext; From b8e67e496f4981a5d6bb7aa919690036ea0941ac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:43:39 +0600 Subject: [PATCH 327/434] feat(runwire): isolate helper memoization by shared request scope --- src/functions.php | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/functions.php b/src/functions.php index e954b867..080ad4e6 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use Infocyph\CacheLayer\Memoize\Memoizer; use Infocyph\CacheLayer\Memoize\OnceMemoizer; @@ -13,9 +14,12 @@ */ function memoize(?callable $callable = null, array $params = []): mixed { - $memoizer = Memoizer::instance(); + $memoizer = RunwireIntegration::memoizer(); if ($callable === null) { - return $memoizer; + return $memoizer ?? Memoizer::isolated(); + } + if ($memoizer === null) { + return $callable(...$params); } return $memoizer->get($callable, $params); @@ -30,16 +34,20 @@ function memoize(?callable $callable = null, array $params = []): mixed */ function remember(?object $object = null, ?callable $callable = null, array $params = []): mixed { - $memoizer = Memoizer::instance(); + $memoizer = RunwireIntegration::memoizer(); if ($object === null) { - return $memoizer; + return $memoizer ?? Memoizer::isolated(); } if ($callable === null) { throw new InvalidArgumentException('remember() requires both object and callable'); } + if ($memoizer === null) { + return $callable(...$params); + } + return $memoizer->getFor($object, $callable, $params); } } @@ -47,14 +55,17 @@ function remember(?object $object = null, ?callable $callable = null, array $par if (!function_exists('once')) { function once(callable $callback): mixed { - return OnceMemoizer::instance()->once($callback, 1); + $memoizer = RunwireIntegration::onceMemoizer(); + + return $memoizer === null + ? $callback() + : $memoizer->once($callback, 1); } } if (!function_exists('flush_memoizers')) { function flush_memoizers(): void { - Memoizer::instance()->flush(); - OnceMemoizer::instance()->flush(); + RunwireIntegration::flushMemoizers(); } } From d5eee1f40a8dd0719ac91cb2614d526c2da14c7a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:44:17 +0600 Subject: [PATCH 328/434] test(runwire): cover shared runtime request and task scopes --- tests/Integration/RunwireIntegrationTest.php | 174 +++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/Integration/RunwireIntegrationTest.php diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php new file mode 100644 index 00000000..d809fd79 --- /dev/null +++ b/tests/Integration/RunwireIntegrationTest.php @@ -0,0 +1,174 @@ +toBe(1) + ->and(memoize($loader))->toBe(1) + ->and($runs)->toBe(1); +}); + +it('isolates memoizers by the shared Runwire request context', function (): void { + $runtime = cacheLayerRunwireContext(); + RunwireIntegration::bind($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + $first = RequestContext::create($runtime); + $firstValues = RunwireIntegration::share( + $first, + null, + static fn(): array => [memoize($loader), memoize($loader)], + ); + $first->complete(); + + $second = RequestContext::create($runtime); + $secondValues = RunwireIntegration::share( + $second, + null, + static fn(): array => [memoize($loader), memoize($loader)], + ); + $second->complete(); + + expect($firstValues)->toBe([1, 1]) + ->and($secondValues)->toBe([2, 2]) + ->and($runs)->toBe(2); +}); + +it('bypasses process-global memoization in concurrent persistent runtimes without a shared request', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + expect(memoize($loader))->toBe(1) + ->and(memoize($loader))->toBe(2) + ->and($runs)->toBe(2); +}); + +it('keeps concurrent request memoizers isolated by fiber', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $requestA = RequestContext::create($runtime); + $requestB = RequestContext::create($runtime); + $runsA = 0; + $runsB = 0; + $loaderA = static function () use (&$runsA): int { + return ++$runsA; + }; + $loaderB = static function () use (&$runsB): int { + return ++$runsB; + }; + + $fiberA = new Fiber(static fn(): int => RunwireIntegration::share( + $requestA, + null, + static function () use ($loaderA): int { + $first = memoize($loaderA); + Fiber::suspend($first); + + return memoize($loaderA); + }, + )); + $fiberB = new Fiber(static fn(): int => RunwireIntegration::share( + $requestB, + null, + static function () use ($loaderB): int { + $first = memoize($loaderB); + Fiber::suspend($first); + + return memoize($loaderB); + }, + )); + + expect($fiberA->start())->toBe(1) + ->and($fiberB->start())->toBe(1); + $fiberA->resume(); + $fiberB->resume(); + + expect($fiberA->getReturn())->toBe(1) + ->and($fiberB->getReturn())->toBe(1) + ->and($runsA)->toBe(1) + ->and($runsB)->toBe(1); +}); + +it('shares the active Runwire task scope and capability set', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + $request = RequestContext::create($runtime); + RunwireIntegration::bind($runtime); + $coroutines = new CoroutineRuntime(); + + $result = $coroutines->run( + static function (CoroutineScope $scope) use ($request): array { + return RunwireIntegration::share( + $request, + $scope, + static fn(): array => [ + RunwireIntegration::current()?->scope === $scope, + RunwireIntegration::supports(RuntimeCapability::RUNWIRE_COROUTINES), + ], + ); + }, + ); + + expect($result)->toBe([true, true]); +}); + +it('restores the normal path after the shared Runwire runtime is released', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + RunwireIntegration::release($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + expect(RunwireIntegration::runtime())->toBeNull() + ->and(memoize($loader))->toBe(1) + ->and(memoize($loader))->toBe(1) + ->and($runs)->toBe(1); +}); From 6ed3dbf85b0be839f6661b8dc104a2f5fe784127 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:45:58 +0600 Subject: [PATCH 329/434] fix(runwire): declare Fiber generic state --- src/Integration/Runwire/RunwireIntegration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index fac57867..08b51db1 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -20,7 +20,7 @@ final class RunwireIntegration private const string ONCE_MEMOIZER_ATTRIBUTE = 'cachelayer.once-memoizer'; - /** @var WeakMap|null */ + /** @var WeakMap, RunwireExecutionContext>|null */ private static ?WeakMap $fiberContexts = null; private static ?RunwireExecutionContext $rootContext = null; From ad3b7675dae429b7e91689ff87606f1afeff72c0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:48:53 +0600 Subject: [PATCH 330/434] fix(runwire): type fiber context map by object key --- src/Integration/Runwire/RunwireIntegration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index 08b51db1..8b5770c5 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -20,7 +20,7 @@ final class RunwireIntegration private const string ONCE_MEMOIZER_ATTRIBUTE = 'cachelayer.once-memoizer'; - /** @var WeakMap, RunwireExecutionContext>|null */ + /** @var WeakMap|null */ private static ?WeakMap $fiberContexts = null; private static ?RunwireExecutionContext $rootContext = null; From a998ffaf0b952c7c751183a8a0652e25770472bc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:52:44 +0600 Subject: [PATCH 331/434] style(runwire): remove unused memoizer import --- src/functions.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/functions.php b/src/functions.php index 080ad4e6..40d626d2 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,7 +4,6 @@ use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use Infocyph\CacheLayer\Memoize\Memoizer; -use Infocyph\CacheLayer\Memoize\OnceMemoizer; if (!function_exists('memoize')) { /** From e91f7eb59e24fba2f99aad6fa2b1a41b769f10f7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:56:28 +0600 Subject: [PATCH 332/434] docs(plan): close Runwire 7A and open 7B --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index c506c46a..0c1c3715 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -363,8 +363,8 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and | Sub-batch | Scope | Status | Gate | | --- | --- | --- | --- | -| 7A — Runtime/request lifecycle | Runtime binding, concurrent memoizer isolation, sequential persistent request reset | **In progress** | Focused Runwire lifecycle tests, then full PHPForge QA. | -| 7B — Worker-owned background integration | Bounded cluster invalidation polling and optional Node maintenance through Runwire worker lifecycle | **Pending** | Worker stop/drain/error/coherence tests, then full QA. | +| 7A — Runtime/request lifecycle | Shared active RuntimeContext + request/task scope, capability-driven memoizer isolation/fallback | **Complete** | Exact head `a998ffaf0b952c7c751183a8a0652e25770472bc` passed Security & Standards #421 and Release Verification #61. Clean no-dev consumers confirm Runwire remains optional. | +| 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **In progress** | Add bounded runners using shared scope/cooperative waits with normal blocking fallback, then worker stop/error/coherence tests and full QA. | | 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **Pending** | Exact-head Security & Standards + Release Verification. | ## Optional Runwire 2.1 integration workstream From 0761721541dc50fbe7f1c7b882f5dbae71d0e25f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:58:30 +0600 Subject: [PATCH 333/434] feat(runwire): expose cooperative cancellation checkpoint --- src/Integration/Runwire/RunwireIntegration.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index 8b5770c5..cfa12f87 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -38,6 +38,17 @@ public static function bind(RuntimeContext $runtime): void self::$runtime = $runtime; } + public static function checkpoint(): void + { + $context = self::current(); + if ( + $context?->scope !== null + && $context->supports(RuntimeCapability::RUNWIRE_COROUTINES) + ) { + $context->scope->cancellation()->throwIfCancelled(); + } + } + public static function current(): ?RunwireExecutionContext { $fiber = Fiber::getCurrent(); From dafcc71f134f43224b6179ffa7716d4953825a13 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:58:36 +0600 Subject: [PATCH 334/434] feat(runwire): add bounded cluster polling --- src/Cluster/ClusterRuntime.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/Cluster/ClusterRuntime.php b/src/Cluster/ClusterRuntime.php index 61a0731b..14d93b9f 100644 --- a/src/Cluster/ClusterRuntime.php +++ b/src/Cluster/ClusterRuntime.php @@ -17,6 +17,7 @@ use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInspectorInterface; use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInterface; use Infocyph\CacheLayer\Cluster\Transport\TransactionalInvalidationTransportInterface; +use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use PDO; final readonly class ClusterRuntime @@ -69,6 +70,29 @@ public function drain(?int $limit = null, int $maxBatches = 100): int return $total; } + public function poll( + ?int $limit = null, + int $cycles = 1, + float $idleSeconds = 0.1, + ): int { + $limit ??= $this->consumerBatchSize; + if ($limit < 1 || $cycles < 1 || !is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { + throw new ClusterCacheException('Cluster polling requires a positive limit/cycle count and a 0-60 second idle interval.'); + } + + $total = 0; + for ($cycle = 0; $cycle < $cycles; ++$cycle) { + RunwireIntegration::checkpoint(); + $processed = $this->consumer->consume($limit); + $total += $processed; + if ($cycle + 1 < $cycles && $processed < $limit && $idleSeconds > 0.0) { + RunwireIntegration::sleep($idleSeconds); + } + } + + return $total; + } + public function invalidateKey(string $key): void { $this->coordinator->invalidateKey($key); From 2104bf0a223b74b455dcc36352771d99236d17b9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:58:42 +0600 Subject: [PATCH 335/434] feat(node): expose bounded maintenance cycle --- src/Node/Maintenance/NodeCacheMaintenance.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Node/Maintenance/NodeCacheMaintenance.php b/src/Node/Maintenance/NodeCacheMaintenance.php index 3153afcc..7bcd2d6f 100644 --- a/src/Node/Maintenance/NodeCacheMaintenance.php +++ b/src/Node/Maintenance/NodeCacheMaintenance.php @@ -18,6 +18,22 @@ public function checkpoint(): void $this->connection->query('PRAGMA wal_checkpoint(PASSIVE)'); } + public function cycle( + int $pruneLimit = 5_000, + bool $checkpoint = true, + bool $optimize = false, + ): int { + $pruned = $this->pruneExpired($pruneLimit); + if ($checkpoint) { + $this->checkpoint(); + } + if ($optimize) { + $this->optimize(); + } + + return $pruned; + } + public function optimize(): void { $this->connection->exec('PRAGMA optimize'); From bb28eef2ad11b89dce7725cb8681067420c1247a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:59:28 +0600 Subject: [PATCH 336/434] test(runwire): cover bounded polling and cooperative worker scope --- .../RunwireWorkerIntegrationTest.php | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/Integration/RunwireWorkerIntegrationTest.php diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php new file mode 100644 index 00000000..857cecfa --- /dev/null +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -0,0 +1,135 @@ +runwireWorkerDirectory = sys_get_temp_dir() . '/cachelayer-runwire-worker-' . uniqid(); + $this->runwireWorkerTransport = new InMemoryInvalidationTransport(); + $this->runwireWorkerCluster = ClusterCache::create( + new NodeCacheConfig( + $this->runwireWorkerDirectory . '/node.sqlite', + 'application', + apcuEnabled: false, + ), + new ClusterCacheConfig('runwire-cluster', 'node-a', 'runwire-memory'), + $this->runwireWorkerTransport, + ); + RunwireIntegration::release(); +}); + +afterEach(function (): void { + RunwireIntegration::release(); + if (!is_dir($this->runwireWorkerDirectory)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->runwireWorkerDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->runwireWorkerDirectory); +}); + +it('polls invalidations in bounded cycles on the normal path', function (): void { + foreach (['one', 'two', 'three'] as $key) { + $this->runwireWorkerTransport->publish( + InvalidationEvent::key('runwire-cluster', 'application', $key, 'writer'), + ); + } + + expect($this->runwireWorkerCluster->poll(limit: 1, cycles: 2, idleSeconds: 0.0))->toBe(2) + ->and($this->runwireWorkerCluster->consume())->toBe(1); +}); + +it('uses the shared Runwire task scope for cooperative polling waits', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + $order = []; + RunwireIntegration::bind($runtime); + + $processed = $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($request, &$order): int { + $scope->spawn(static function () use (&$order): void { + $order[] = 'child'; + }); + + $result = RunwireIntegration::share( + $request, + $scope, + fn(): int => $this->runwireWorkerCluster->poll( + limit: 1, + cycles: 2, + idleSeconds: 0.001, + ), + ); + $order[] = 'after'; + + return $result; + }, + ); + + expect($processed)->toBe(0) + ->and($order)->toBe(['child', 'after']); +}); + +it('propagates Runwire cancellation instead of replaying worker operations through fallback', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + RunwireIntegration::bind($runtime); + + expect(fn() => $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($request): int { + $scope->spawn(static function () use ($scope, $request): void { + $scope->sleep(0.001); + $request->cancel(CancellationReason::HOST_CANCELLED); + }); + + return RunwireIntegration::share( + $request, + $scope, + fn(): int => $this->runwireWorkerCluster->poll( + limit: 1, + cycles: 100, + idleSeconds: 0.01, + ), + ); + }, + ))->toThrow(CancelledException::class); +}); From 0e065ced4d7dee7f242c2ec5d0acab06b0c36c8b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 28 Sep 2026 23:59:47 +0600 Subject: [PATCH 337/434] test(node): cover bounded maintenance cycle --- tests/Node/NodeCacheMaintenanceTest.php | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/Node/NodeCacheMaintenanceTest.php diff --git a/tests/Node/NodeCacheMaintenanceTest.php b/tests/Node/NodeCacheMaintenanceTest.php new file mode 100644 index 00000000..d9be62b2 --- /dev/null +++ b/tests/Node/NodeCacheMaintenanceTest.php @@ -0,0 +1,56 @@ +maintenanceDirectory = sys_get_temp_dir() . '/cachelayer-maintenance-' . uniqid(); + $this->maintenanceConfig = new NodeCacheConfig( + $this->maintenanceDirectory . '/cache.sqlite', + 'maintenance', + apcuEnabled: false, + ); +}); + +afterEach(function (): void { + if (!is_dir($this->maintenanceDirectory)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->maintenanceDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->maintenanceDirectory); +}); + +it('runs one bounded prune checkpoint and optimize maintenance unit', function (): void { + $connection = NodeSqliteConnection::create($this->maintenanceConfig); + new NodeSqliteCacheAdapter($connection, $this->maintenanceConfig->namespace); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute([ + $this->maintenanceConfig->namespace, + 'expired', + 'unused', + time() - 1, + ]); + $maintenance = new NodeCacheMaintenance( + $connection, + new NodeCachePruner($connection, $this->maintenanceConfig->namespace), + ); + + expect($maintenance->cycle(pruneLimit: 1, checkpoint: true, optimize: true))->toBe(1) + ->and((int) $connection->query( + "SELECT COUNT(*) FROM cachelayer_node_entries WHERE cache_key = 'expired'", + )->fetchColumn())->toBe(0); +}); From bc641e2693745b1293ac8758b11ce5ea175d67dc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 05:48:19 +0600 Subject: [PATCH 338/434] style(cluster): align bounded polling with PHPForge --- src/Cluster/ClusterRuntime.php | 47 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/Cluster/ClusterRuntime.php b/src/Cluster/ClusterRuntime.php index 14d93b9f..1a461f0a 100644 --- a/src/Cluster/ClusterRuntime.php +++ b/src/Cluster/ClusterRuntime.php @@ -70,29 +70,6 @@ public function drain(?int $limit = null, int $maxBatches = 100): int return $total; } - public function poll( - ?int $limit = null, - int $cycles = 1, - float $idleSeconds = 0.1, - ): int { - $limit ??= $this->consumerBatchSize; - if ($limit < 1 || $cycles < 1 || !is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { - throw new ClusterCacheException('Cluster polling requires a positive limit/cycle count and a 0-60 second idle interval.'); - } - - $total = 0; - for ($cycle = 0; $cycle < $cycles; ++$cycle) { - RunwireIntegration::checkpoint(); - $processed = $this->consumer->consume($limit); - $total += $processed; - if ($cycle + 1 < $cycles && $processed < $limit && $idleSeconds > 0.0) { - RunwireIntegration::sleep($idleSeconds); - } - } - - return $total; - } - public function invalidateKey(string $key): void { $this->coordinator->invalidateKey($key); @@ -128,6 +105,30 @@ public function outbox(PDO $connection): ClusterOutbox ); } + public function poll( + ?int $limit = null, + int $cycles = 1, + float $idleSeconds = 0.1, + ): int + { + $limit ??= $this->consumerBatchSize; + if ($limit < 1 || $cycles < 1 || !is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { + throw new ClusterCacheException('Cluster polling requires a positive limit/cycle count and a 0-60 second idle interval.'); + } + + $total = 0; + for ($cycle = 0; $cycle < $cycles; ++$cycle) { + RunwireIntegration::checkpoint(); + $processed = $this->consumer->consume($limit); + $total += $processed; + if ($cycle + 1 < $cycles && $processed < $limit && $idleSeconds > 0.0) { + RunwireIntegration::sleep($idleSeconds); + } + } + + return $total; + } + public function recoverIfRequired(): bool { $recovered = $this->recovery->recoverIfRequired(); From 19a0b5c12a593a73d9603bfef64d1f044ed8bb26 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 05:50:38 +0600 Subject: [PATCH 339/434] feat(runwire): add host-owned worker runners --- .../Runwire/RunwireWorkerIntegration.php | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/Integration/Runwire/RunwireWorkerIntegration.php diff --git a/src/Integration/Runwire/RunwireWorkerIntegration.php b/src/Integration/Runwire/RunwireWorkerIntegration.php new file mode 100644 index 00000000..8062b8a1 --- /dev/null +++ b/src/Integration/Runwire/RunwireWorkerIntegration.php @@ -0,0 +1,137 @@ +spawnBackground( + static function (CoroutineScope $scope) use ($worker, $cluster, $batchSize, $idleSeconds): void { + RunwireIntegration::share( + null, + $scope, + static function () use ($worker, $cluster, $scope, $batchSize, $idleSeconds): void { + while ($worker->acceptingBackgroundWork()) { + RunwireIntegration::checkpoint(); + $processed = $cluster->consume($batchSize); + if (!$worker->acceptingBackgroundWork()) { + break; + } + + if ($processed < $batchSize && $idleSeconds > 0.0) { + RunwireIntegration::sleep($idleSeconds); + } else { + $scope->yieldNow(); + } + } + }, + ); + }, + ); + } + + public static function startNodeMaintenance( + WorkerContext $worker, + NodeCacheMaintenance $maintenance, + float $intervalSeconds = 60.0, + int $pruneLimit = 5_000, + int $optimizeEvery = 0, + ): ?Task { + self::validateMaintenanceOptions($intervalSeconds, $pruneLimit, $optimizeEvery); + if (!self::available($worker)) { + return null; + } + + return $worker->spawnBackground( + static function (CoroutineScope $scope) use ( + $worker, + $maintenance, + $intervalSeconds, + $pruneLimit, + $optimizeEvery, + ): void { + RunwireIntegration::share( + null, + $scope, + static function () use ( + $worker, + $maintenance, + $intervalSeconds, + $pruneLimit, + $optimizeEvery, + ): void { + $cycles = 0; + while ($worker->acceptingBackgroundWork()) { + RunwireIntegration::checkpoint(); + ++$cycles; + $maintenance->cycle( + pruneLimit: $pruneLimit, + checkpoint: true, + optimize: $optimizeEvery > 0 && ($cycles % $optimizeEvery) === 0, + ); + if (!$worker->acceptingBackgroundWork()) { + break; + } + + RunwireIntegration::sleep($intervalSeconds); + } + }, + ); + }, + ); + } + + private static function available(WorkerContext $worker): bool + { + return $worker->role->background() + && $worker->acceptingBackgroundWork() + && RunwireIntegration::supports(RuntimeCapability::RUNWIRE_COROUTINES) + && RunwireIntegration::supports(RuntimeCapability::RUNWIRE_LOOP_AVAILABLE); + } + + private static function validateClusterOptions(int $batchSize, float $idleSeconds): void + { + if ($batchSize < 1) { + throw new InvalidArgumentException('Runwire cluster consumer batch size must be greater than zero.'); + } + if (!is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { + throw new InvalidArgumentException('Runwire cluster consumer idle interval must be between 0 and 60 seconds.'); + } + } + + private static function validateMaintenanceOptions( + float $intervalSeconds, + int $pruneLimit, + int $optimizeEvery, + ): void { + if (!is_finite($intervalSeconds) || $intervalSeconds < 0.001 || $intervalSeconds > 86_400.0) { + throw new InvalidArgumentException('Runwire maintenance interval must be between 0.001 and 86400 seconds.'); + } + if ($pruneLimit < 1) { + throw new InvalidArgumentException('Runwire maintenance prune limit must be greater than zero.'); + } + if ($optimizeEvery < 0) { + throw new InvalidArgumentException('Runwire maintenance optimize cadence cannot be negative.'); + } + } +} From d609fe9c9f9f9704913883a6b8a935fb4ad3d6a0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 05:51:46 +0600 Subject: [PATCH 340/434] test(runwire): cover host-owned worker integration --- .../RunwireWorkerIntegrationTest.php | 136 +++++++++++++++++- 1 file changed, 131 insertions(+), 5 deletions(-) diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php index 857cecfa..b499aa01 100644 --- a/tests/Integration/RunwireWorkerIntegrationTest.php +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -6,16 +6,23 @@ use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; +use Infocyph\CacheLayer\Integration\Runwire\RunwireWorkerIntegration; +use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; +use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; use Infocyph\CacheLayer\Tests\Cluster\Support\InMemoryInvalidationTransport; use Infocyph\Runwire\Coroutine\CoroutineRuntime; use Infocyph\Runwire\Coroutine\CoroutineScope; +use Infocyph\Runwire\Coroutine\Enum\TaskState; use Infocyph\Runwire\Exception\CancelledException; +use Infocyph\Runwire\Loop\SelectLoop; use Infocyph\Runwire\RequestContext; use Infocyph\Runwire\Runtime\Enum\CancellationReason; use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\RuntimeContext; +use Infocyph\Runwire\Supervisor\Enum\WorkerRole; +use Infocyph\Runwire\Supervisor\WorkerContext; function cacheLayerWorkerRuntimeContext(): RuntimeContext { @@ -32,15 +39,37 @@ function cacheLayerWorkerRuntimeContext(): RuntimeContext ); } + +/** @return array{WorkerContext, resource} */ +function cacheLayerBackgroundWorkerContext(): array +{ + [$readyParent, $readyChild] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $pid = getmypid(); + + return [ + new WorkerContext( + group: 'cachelayer', + slot: 0, + generation: 1, + pid: is_int($pid) ? $pid : 0, + parentPid: 0, + readyStream: $readyChild, + role: WorkerRole::TASK, + ), + $readyParent, + ]; +} + beforeEach(function (): void { $this->runwireWorkerDirectory = sys_get_temp_dir() . '/cachelayer-runwire-worker-' . uniqid(); $this->runwireWorkerTransport = new InMemoryInvalidationTransport(); + $this->runwireWorkerNodeConfig = new NodeCacheConfig( + $this->runwireWorkerDirectory . '/node.sqlite', + 'application', + apcuEnabled: false, + ); $this->runwireWorkerCluster = ClusterCache::create( - new NodeCacheConfig( - $this->runwireWorkerDirectory . '/node.sqlite', - 'application', - apcuEnabled: false, - ), + $this->runwireWorkerNodeConfig, new ClusterCacheConfig('runwire-cluster', 'node-a', 'runwire-memory'), $this->runwireWorkerTransport, ); @@ -133,3 +162,100 @@ function (CoroutineScope $scope) use ($request): int { }, ))->toThrow(CancelledException::class); }); + + +it('runs bounded invalidation consumption inside the host-owned Runwire worker scope', function (): void { + foreach (['one', 'two', 'three'] as $key) { + $this->runwireWorkerTransport->publish( + InvalidationEvent::key('runwire-cluster', 'application', $key, 'writer'), + ); + } + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + batchSize: 1, + idleSeconds: 0.001, + ); + expect($task)->not->toBeNull(); + + $loop->delay(0.01, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + expect($this->runwireWorkerCluster->consume())->toBe(0) + ->and($task?->state())->toBe(TaskState::CANCELLED) + ->and($worker->acceptingBackgroundWork())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('runs bounded node maintenance inside the host-owned Runwire worker scope', function (): void { + $connection = NodeSqliteConnection::create($this->runwireWorkerNodeConfig); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute([ + $this->runwireWorkerNodeConfig->namespace, + 'expired-runwire', + 'unused', + time() - 1, + ]); + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + $task = RunwireWorkerIntegration::startNodeMaintenance( + $worker, + NodeCache::maintenance($this->runwireWorkerNodeConfig), + intervalSeconds: 0.001, + pruneLimit: 1, + optimizeEvery: 2, + ); + expect($task)->not->toBeNull(); + + $loop->delay(0.01, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + expect((int) $connection->query( + "SELECT COUNT(*) FROM cachelayer_node_entries WHERE cache_key = 'expired-runwire'", + )->fetchColumn())->toBe(0) + ->and($task?->state())->toBe(TaskState::CANCELLED) + ->and($worker->backgroundDrainExpired())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('keeps worker automation inactive when the shared runtime lacks Runwire coroutine capabilities', function (): void { + $runtime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities(driver: RuntimeDriver::NATIVE), + 'cachelayer-worker-fallback', + concurrent: false, + ); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + + expect(RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + ))->toBeNull() + ->and(RunwireWorkerIntegration::startNodeMaintenance( + $worker, + NodeCache::maintenance($this->runwireWorkerNodeConfig), + ))->toBeNull(); + + $worker->close(); + fclose($readyParent); +}); From 54ef7763018f7559f62e54c0022063a506883771 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 05:54:39 +0600 Subject: [PATCH 341/434] fix(runwire): rely on worker scope cancellation for shutdown --- src/Integration/Runwire/RunwireWorkerIntegration.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Integration/Runwire/RunwireWorkerIntegration.php b/src/Integration/Runwire/RunwireWorkerIntegration.php index 8062b8a1..a4a7ea0b 100644 --- a/src/Integration/Runwire/RunwireWorkerIntegration.php +++ b/src/Integration/Runwire/RunwireWorkerIntegration.php @@ -31,12 +31,9 @@ static function (CoroutineScope $scope) use ($worker, $cluster, $batchSize, $idl null, $scope, static function () use ($worker, $cluster, $scope, $batchSize, $idleSeconds): void { - while ($worker->acceptingBackgroundWork()) { + while (true) { RunwireIntegration::checkpoint(); $processed = $cluster->consume($batchSize); - if (!$worker->acceptingBackgroundWork()) { - break; - } if ($processed < $batchSize && $idleSeconds > 0.0) { RunwireIntegration::sleep($idleSeconds); @@ -81,7 +78,7 @@ static function () use ( $optimizeEvery, ): void { $cycles = 0; - while ($worker->acceptingBackgroundWork()) { + while (true) { RunwireIntegration::checkpoint(); ++$cycles; $maintenance->cycle( @@ -89,9 +86,6 @@ static function () use ( checkpoint: true, optimize: $optimizeEvery > 0 && ($cycles % $optimizeEvery) === 0, ); - if (!$worker->acceptingBackgroundWork()) { - break; - } RunwireIntegration::sleep($intervalSeconds); } From 97b5854cb43d74c15763cfed225def7f4bd8884c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 05:57:12 +0600 Subject: [PATCH 342/434] fix(runwire): make worker loops cancellation-driven --- .../Runwire/RunwireWorkerIntegration.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Integration/Runwire/RunwireWorkerIntegration.php b/src/Integration/Runwire/RunwireWorkerIntegration.php index a4a7ea0b..01d5b859 100644 --- a/src/Integration/Runwire/RunwireWorkerIntegration.php +++ b/src/Integration/Runwire/RunwireWorkerIntegration.php @@ -26,13 +26,12 @@ public static function startClusterConsumer( } return $worker->spawnBackground( - static function (CoroutineScope $scope) use ($worker, $cluster, $batchSize, $idleSeconds): void { + static function (CoroutineScope $scope) use ($cluster, $batchSize, $idleSeconds): void { RunwireIntegration::share( null, $scope, - static function () use ($worker, $cluster, $scope, $batchSize, $idleSeconds): void { - while (true) { - RunwireIntegration::checkpoint(); + static function () use ($cluster, $scope, $batchSize, $idleSeconds): void { + while (!$scope->cancellation()->isCancelled()) { $processed = $cluster->consume($batchSize); if ($processed < $batchSize && $idleSeconds > 0.0) { @@ -41,6 +40,8 @@ static function () use ($worker, $cluster, $scope, $batchSize, $idleSeconds): vo $scope->yieldNow(); } } + + $scope->cancellation()->throwIfCancelled(); }, ); }, @@ -61,7 +62,6 @@ public static function startNodeMaintenance( return $worker->spawnBackground( static function (CoroutineScope $scope) use ( - $worker, $maintenance, $intervalSeconds, $pruneLimit, @@ -71,15 +71,14 @@ static function (CoroutineScope $scope) use ( null, $scope, static function () use ( - $worker, + $scope, $maintenance, $intervalSeconds, $pruneLimit, $optimizeEvery, ): void { $cycles = 0; - while (true) { - RunwireIntegration::checkpoint(); + while (!$scope->cancellation()->isCancelled()) { ++$cycles; $maintenance->cycle( pruneLimit: $pruneLimit, @@ -89,6 +88,8 @@ static function () use ( RunwireIntegration::sleep($intervalSeconds); } + + $scope->cancellation()->throwIfCancelled(); }, ); }, From abae64c585998bc56327ab5792fef39f881ed875 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:01:14 +0600 Subject: [PATCH 343/434] style(cluster): match PER brace placement --- src/Cluster/ClusterRuntime.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Cluster/ClusterRuntime.php b/src/Cluster/ClusterRuntime.php index 1a461f0a..3abeb037 100644 --- a/src/Cluster/ClusterRuntime.php +++ b/src/Cluster/ClusterRuntime.php @@ -109,8 +109,7 @@ public function poll( ?int $limit = null, int $cycles = 1, float $idleSeconds = 0.1, - ): int - { + ): int { $limit ??= $this->consumerBatchSize; if ($limit < 1 || $cycles < 1 || !is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { throw new ClusterCacheException('Cluster polling requires a positive limit/cycle count and a 0-60 second idle interval.'); From 04321c63ebdef81555509e494e539cb3822d66c6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:05:23 +0600 Subject: [PATCH 344/434] docs(plan): close Runwire 7B and open 7C --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 0c1c3715..17091b0f 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -364,8 +364,8 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and | Sub-batch | Scope | Status | Gate | | --- | --- | --- | --- | | 7A — Runtime/request lifecycle | Shared active RuntimeContext + request/task scope, capability-driven memoizer isolation/fallback | **Complete** | Exact head `a998ffaf0b952c7c751183a8a0652e25770472bc` passed Security & Standards #421 and Release Verification #61. Clean no-dev consumers confirm Runwire remains optional. | -| 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **In progress** | Add bounded runners using shared scope/cooperative waits with normal blocking fallback, then worker stop/error/coherence tests and full QA. | -| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **Pending** | Exact-head Security & Standards + Release Verification. | +| 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **Complete** | Exact head `abae64c585998bc56327ab5792fef39f881ed875` passed Security & Standards #433 and Release Verification #73. Worker-owned cancellation/drain, bounded polling, maintenance cycles, capability fallback, and normal-path behavior are covered. | +| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **In progress** | Add executable consumer evidence and operational documentation, then run exact-head Security & Standards + Release Verification. | ## Optional Runwire 2.1 integration workstream From 64889825618a0d43ad1e663414d7a2941fc16d78 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:08:28 +0600 Subject: [PATCH 345/434] feat(runwire): add executable invalidation worker example --- examples/runwire-invalidation-worker.php | 137 +++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 examples/runwire-invalidation-worker.php diff --git a/examples/runwire-invalidation-worker.php b/examples/runwire-invalidation-worker.php new file mode 100644 index 00000000..fd01267e --- /dev/null +++ b/examples/runwire-invalidation-worker.php @@ -0,0 +1,137 @@ +cache()->set('demo-key', 'cached', 60); + $transport->publish( + InvalidationEvent::key( + 'example-cluster', + 'application', + 'demo-key', + 'node-b', + ), + ); + + $capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + ownsEventLoop: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ); + $runtime = RuntimeContext::fromCapabilities( + $capabilities, + 'cachelayer-example', + workerSlot: 0, + generation: 1, + concurrent: true, + ); + RunwireIntegration::bind($runtime); + + [$readyParent, $readyChild] = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + STREAM_IPPROTO_IP, + ); + $pid = getmypid(); + $worker = new WorkerContext( + group: 'cachelayer-example', + slot: 0, + generation: 1, + pid: is_int($pid) ? $pid : 0, + parentPid: 0, + readyStream: $readyChild, + role: WorkerRole::TASK, + ); + + $loop = new SelectLoop(); + $worker->attachLoop($loop, backgroundShutdownGraceSeconds: 0.25); + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $cluster, + batchSize: 10, + idleSeconds: 0.001, + ); + if ($task === null) { + throw new RuntimeException('The active Runwire context does not expose the required worker capabilities.'); + } + + $loop->delay(0.02, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + $status = $cluster->status(); + if ($cluster->cache()->get('demo-key') !== null) { + throw new RuntimeException('The invalidation worker did not clear the cached key.'); + } + if ($status->cursor !== '1' || $status->pendingEventCount !== 0 || $status->lastConsumeError !== null) { + throw new RuntimeException('The invalidation worker did not persist clean cursor progress.'); + } + + fwrite(STDOUT, "CacheLayer Runwire invalidation worker example passed.\n"); +} finally { + $worker?->close(); + if (is_resource($readyParent)) { + fclose($readyParent); + } + RunwireIntegration::release($runtime); + + if (is_dir($base)) { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); + } +} From 3689b60d8816aa41ded9cec6bc76c7faac6143e7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:08:31 +0600 Subject: [PATCH 346/434] test(runwire): add isolated consumer fixture --- tools/release/runwire-consumer/composer.json | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tools/release/runwire-consumer/composer.json diff --git a/tools/release/runwire-consumer/composer.json b/tools/release/runwire-consumer/composer.json new file mode 100644 index 00000000..2b24556d --- /dev/null +++ b/tools/release/runwire-consumer/composer.json @@ -0,0 +1,28 @@ +{ + "name": "infocyph/cachelayer-runwire-release-consumer", + "type": "project", + "require": { + "php": ">=8.4", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "infocyph/cachelayer": "4.0.x-dev", + "infocyph/runwire": "^2.1" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} From 5d24cc8bf4886291f9daa09ab570c4c967b7a327 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:08:34 +0600 Subject: [PATCH 347/434] test(runwire): exercise isolated consumer integration --- tools/release/runwire-consumer/smoke.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tools/release/runwire-consumer/smoke.php diff --git a/tools/release/runwire-consumer/smoke.php b/tools/release/runwire-consumer/smoke.php new file mode 100644 index 00000000..fc436dbe --- /dev/null +++ b/tools/release/runwire-consumer/smoke.php @@ -0,0 +1,23 @@ + Date: Tue, 29 Sep 2026 06:09:44 +0600 Subject: [PATCH 348/434] docs(runwire): document runtime integration contract --- docs/runwire.rst | 185 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/runwire.rst diff --git a/docs/runwire.rst b/docs/runwire.rst new file mode 100644 index 00000000..0f9b4a53 --- /dev/null +++ b/docs/runwire.rst @@ -0,0 +1,185 @@ +======================= +Runwire 2.1 integration +======================= + +CacheLayer 4.0 ships an optional integration with Runwire 2.1. 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 + +Ownership contract +================== + +The framework or application owns the Runwire runtime. CacheLayer never starts a +listener, worker pool, supervisor, or event loop because Runwire is installed. + +The integration follows four rules: + +* share the framework's active RuntimeContext at worker/application bootstrap; +* share the current RequestContext and CoroutineScope only while that request + or task is executing; +* use supported Runwire capabilities automatically while preserving Runwire's + worker/event-loop ownership; +* keep the ordinary synchronous CacheLayer path when Runwire is absent, + inactive, or does not expose the required capability. + +Binding a runtime +================= + +Bind the concrete context supplied by the host after the worker has been +created. Rebind after a fork, worker replacement, or generation change, and +release the binding during worker/application shutdown. + +.. code-block:: php + + use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; + use Infocyph\Runwire\RuntimeContext; + + function bootCacheLayer(RuntimeContext $context): void + { + RunwireIntegration::bind($context); + } + + function shutdownCacheLayer(RuntimeContext $context): void + { + RunwireIntegration::release($context); + } + +Installation alone does not create an active binding. + +Request and task scope +====================== + +The host shares the actual request/task scope around application work: + +.. code-block:: php + + $result = RunwireIntegration::share( + $requestContext, + $scope, + fn () => $application->handle($request), + ); + +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 +but no request scope has been shared, these helpers bypass process-global +memoization rather than risk cross-request leakage. + +Outside Runwire, or after RunwireIntegration::release(), memoization keeps its +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 +PDO, filesystem, Redis, MongoDB, Memcached, Scylla, or other native client calls +and does not claim that those calls become asynchronous. + +Operational failures and cancellation are not retried through a second fallback +path. A mutation or invalidation that may already have completed is never +replayed merely because a Runwire-assisted operation failed. + +Worker-owned invalidation +========================= + +A Runwire task/service worker may host the durable invalidation consumer after +the host has attached its own event loop: + +.. code-block:: php + + use Infocyph\CacheLayer\Integration\Runwire\RunwireWorkerIntegration; + + $task = RunwireWorkerIntegration::startClusterConsumer( + $workerContext, + $clusterRuntime, + batchSize: 1_000, + idleSeconds: 0.1, + ); + + if ($task === null) { + $clusterRuntime->consume(); + } + +Each consume call remains bounded by batchSize and serial within one cursor +scope. Empty/short batches wait cooperatively. Full batches yield so lifecycle +work cannot be starved. Runwire owns cancellation and drain. An unhandled +consumer failure remains a failed worker task; Runwire's worker/supervisor +policy owns unhealthy-worker stop and restart/backoff behavior. + +ClusterRuntime::status() remains the operational source for cursor progress, +pending event count, last consumed count, recovery, and last consume error. + +Do not construct backend connections in a prefork master and reuse them in +children. Create the Node Cache and invalidation transport in worker bootstrap +after the fork. + +The self-contained examples/runwire-invalidation-worker.php demonstrates the +complete binding, worker-owned loop, graceful stop, and cursor progression. It +uses the SQLite invalidation transport testing mode only so the example can run +without an external service. Production deployments should use an advertised +shared transport such as PostgreSQL/MySQL PDO or Redis/Valkey Streams. + +Worker-owned Node maintenance +============================= + +Node SQLite maintenance can use the same task/service worker ownership: + +.. code-block:: php + + $task = RunwireWorkerIntegration::startNodeMaintenance( + $workerContext, + $maintenance, + intervalSeconds: 60.0, + pruneLimit: 5_000, + optimizeEvery: 60, + ); + +The runner performs one serial maintenance cycle at a time, so CacheLayer does +not overlap its own prune/checkpoint/optimize work for that worker. pruneLimit +bounds expiry deletion. optimizeEvery set to 0 disables automatic optimize +calls. + +SQLite operations are still blocking operations. Run maintenance in a suitable +background worker, choose intervals from observed writer contention, and do not +put full maintenance on the request hot path. + +Topology +======== + +Native prefork supervision and worker replacement require Runwire's PCNTL/POSIX +capabilities. Portable single-process Runwire can execute the normal CacheLayer +APIs, but external supervision owns process restart/replacement. Host-owned +runtimes retain their own workers and event loops. + +APCu remains local to one PHP process/SAPI. A dedicated invalidation worker does +not clear unrelated FPM or other worker APCu domains. Every topology claiming +node-wide L1 coherence must run invalidation consumption in each relevant +process domain or disable that L1 assumption. + +Shutdown and rollback +===================== + +On graceful worker stop, Runwire cancels and drains worker-owned CacheLayer +background tasks. Release the runtime binding when the generation is discarded: + +.. code-block:: php + + RunwireIntegration::release($runtimeContext); + +To roll back the optional integration, stop the Runwire-owned CacheLayer tasks, +remove the bootstrap/scope binding, and return to explicit +ClusterRuntime::consume() and NodeCacheMaintenance::cycle() calls. No cache +storage format depends on Runwire. + +HTTP scheduling +=============== + +Runwire 2.1 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. From c2bb8dfe5eef55b3aa4b850f24fde94438194c33 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:10:25 +0600 Subject: [PATCH 349/434] docs(runwire): add integration guide to manual --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 4e77ed84..753dc663 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,6 +58,7 @@ Quick Start metrics-and-locking node/index cluster/index + runwire security serializer memoize From f7b90cf8fd539a889d4e384ef2b7eb4e8a57d3d7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:10:29 +0600 Subject: [PATCH 350/434] chore(runwire): advertise optional integration dependency --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 7684545a..bd3a55b5 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +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", "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter" }, "minimum-stability": "stable", From 5f63efd1ca5673fed218c8aa937d40a99968da06 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:10:34 +0600 Subject: [PATCH 351/434] docs(runwire): document shipped optional integration --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af1b3f64..90bdbcb7 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,19 @@ $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 + +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. + +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. + +Concurrent persistent Runwire requests receive request-owned memoizer state. If a concurrent runtime is bound without a shared request scope, memoization helpers bypass the process-global cache rather than risk cross-request leakage. See `docs/runwire.rst` and the executable `examples/runwire-invalidation-worker.php`. + ## Atomic counters and memoization `AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. They live in a dedicated `cachelayer:counter::` keyspace, so ordinary cache `clear()` does not reset them, and the Lua update returns an exact decimal string before PHP range validation. Atomic counters are separate from `Cache::atomic()`: counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records. -The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization. Their state survives requests in persistent workers until evicted or reset with `flush_memoizers()`; call that reset at request boundaries when cross-request reuse is not intended. They are independent of persistent backend caching. +The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization on the normal path. With an active Runwire request scope they use request-owned memoizers; a concurrent persistent Runwire runtime without a shared request scope bypasses global memoization rather than leaking state between requests. `flush_memoizers()` resets the currently owned memoizer scope. They are independent of persistent backend caching. ## Metrics and benchmarks From 3b2c238aa109af53235c01cb997754da7247fb08 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:10:40 +0600 Subject: [PATCH 352/434] docs(release): include Runwire 2.1 integration --- docs/release-4.0.rst | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst index a44c1778..d7e82c8e 100644 --- a/docs/release-4.0.rst +++ b/docs/release-4.0.rst @@ -11,8 +11,9 @@ Platform * Minimum PHP version is 8.4. * Release verification targets PHP 8.4 and 8.5. -* Runwire 2.1 integration remains optional and is not part of the 4.0 core - runtime contract. +* Runwire 2.1 remains an optional dependency, but CacheLayer 4.0 ships and + release-verifies runtime/request scope integration plus host-owned cluster + invalidation and Node maintenance runners. Security and storage ==================== @@ -38,6 +39,22 @@ Correctness * Memcached long TTLs use the correct absolute-expiration conversion. * Tiered caches invalidate/fence skipped L1 state rather than serving stale data. +Runwire 2.1 +=========== + +* The application shares the active Runwire runtime and request/task scope; + CacheLayer does not discover or create a runtime globally. +* Capability-driven behavior falls back to the normal CacheLayer path before an + operation starts when Runwire or the needed capability is unavailable. +* Concurrent persistent requests use isolated request-owned memoizers; an + unscoped concurrent runtime never falls back to process-global memoization. +* Task/service workers can own bounded invalidation consumption and Node SQLite + maintenance while Runwire retains worker, supervisor, and event-loop ownership. +* Worker cancellation/drain propagates through Runwire. Backend operations stay + synchronous and no HTTP scheduling or throughput improvement is claimed. +* A dedicated PHP 8.4/8.5 release consumer installs Runwire 2.1 separately and + executes the shipped invalidation-worker example. + Atomicity and counters ====================== From acf766a195c15f74755e189f84ad757cd7800fd9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:10:45 +0600 Subject: [PATCH 353/434] docs(upgrade): add Runwire 2.1 deployment guidance --- docs/upgrade-4.0.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/upgrade-4.0.rst b/docs/upgrade-4.0.rst index 92d54b52..729dbc62 100644 --- a/docs/upgrade-4.0.rst +++ b/docs/upgrade-4.0.rst @@ -130,6 +130,25 @@ Cursor state is scoped by cluster, node, namespace, and transport identity. Retention gaps trigger a local namespace clear before progress is advanced. Permanent poison events are not skipped silently. +Runwire 2.1 integration +======================= + +Runwire is still optional at installation time. Applications that use the +integration should install ``infocyph/runwire:^2.1`` and bind the concrete +Runwire ``RuntimeContext`` after each worker/generation is created. Share the +current request/task scope only while that work is active and release the +runtime binding on shutdown or replacement. + +Do not create database, Redis, or other backend connections in a prefork master +and reuse them in child workers. Build CacheLayer's Node/Cluster objects in the +worker after the fork. Runwire owns worker restart/backoff, cancellation, +drain, and event-loop lifecycle; CacheLayer does not take over those resources. + +Removing the integration does not require a cache data migration. Stop the +Runwire-owned CacheLayer tasks, remove the bootstrap/scope binding, and return +to explicit ``ClusterRuntime::consume()`` and ``NodeCacheMaintenance::cycle()`` +execution. + Rollback ======== From cbd4a89a6178fa18c14d6a6a42e1fd60130d10f0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:12:21 +0600 Subject: [PATCH 354/434] fix(runwire): create executable example workspace --- examples/runwire-invalidation-worker.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/runwire-invalidation-worker.php b/examples/runwire-invalidation-worker.php index fd01267e..2f0090fd 100644 --- a/examples/runwire-invalidation-worker.php +++ b/examples/runwire-invalidation-worker.php @@ -28,6 +28,10 @@ $runtime = null; try { + if (!mkdir($base, 0700, true) && !is_dir($base)) { + throw new RuntimeException('Unable to create CacheLayer Runwire example directory.'); + } + $transportConnection = new PDO('sqlite:' . $base . '/invalidation.sqlite'); $transport = new PdoInvalidationTransport( $transportConnection, From 49fe2385129986cf37be86af6e1c9ecac4edb514 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:12:36 +0600 Subject: [PATCH 355/434] ci(runwire): verify optional consumer on PHP 8.4 and 8.5 --- .github/workflows/release-verification.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index e3dc9a89..db25b540 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -59,6 +59,37 @@ jobs: working-directory: tools/release/consumer run: php -d error_reporting=E_ALL smoke.php + runwire-consumer: + name: Runwire 2.1 consumer - PHP ${{ matrix.php }} - ${{ matrix.dependencies }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + dependencies: [ lowest, stable ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: pdo, pdo_sqlite + coverage: none + - name: Resolve CacheLayer with Runwire 2.1 + working-directory: tools/release/runwire-consumer + shell: bash + run: | + set -euo pipefail + if [ "${{ matrix.dependencies }}" = "lowest" ]; then + composer update --prefer-lowest --prefer-stable --no-interaction --prefer-dist --no-progress + else + composer update --prefer-stable --no-interaction --prefer-dist --no-progress + fi + composer check-platform-reqs + - name: Run Runwire integration smoke + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL smoke.php + psr-contracts: name: Independent PSR-6 / PSR-16 contracts runs-on: ubuntu-24.04 From 546273f7f61f9f703ee0d1c8626fcbe7855d1d20 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:12:54 +0600 Subject: [PATCH 356/434] test(runwire): reject stale request scopes after worker replacement --- tests/Integration/RunwireIntegrationTest.php | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php index d809fd79..795685d7 100644 --- a/tests/Integration/RunwireIntegrationTest.php +++ b/tests/Integration/RunwireIntegrationTest.php @@ -172,3 +172,39 @@ static function (CoroutineScope $scope) use ($request): array { ->and(memoize($loader))->toBe(1) ->and($runs)->toBe(1); }); + + +it('replaces runtime bindings without retaining a previous worker request scope', function (): void { + $firstRuntime = cacheLayerRunwireContext(concurrent: true); + $secondRuntime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ), + 'cachelayer-replacement-test', + workerSlot: 1, + generation: 2, + concurrent: true, + ); + $oldRequest = RequestContext::create($firstRuntime); + + RunwireIntegration::bind($firstRuntime); + RunwireIntegration::share( + $oldRequest, + null, + static fn(): int => memoize(static fn(): int => 1), + ); + RunwireIntegration::bind($secondRuntime); + + expect(RunwireIntegration::runtime())->toBe($secondRuntime) + ->and(RunwireIntegration::current())->toBeNull(); + + expect(fn(): mixed => RunwireIntegration::share( + $oldRequest, + null, + static fn(): string => 'stale', + ))->toThrow(LogicException::class, 'different runtime'); +}); From 1e2c4a7b70e8fe073f65e0d2dfe72592cf303059 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:13:13 +0600 Subject: [PATCH 357/434] docs(plan): align Runwire scope wording --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 17091b0f..ca1719b5 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -18,7 +18,8 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | -| 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. |\n| 7 — Runwire 2.1 integration | Required runtime integration workstream | **In progress** | Maintainer decision: Runwire 2.1 integration is required for 4.0. Complete automatic capability selection, lifecycle/coherence behavior, executable integration evidence, and exact-revision QA before release-ready status is restored. | +| 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | +| 7 — Runwire 2.1 integration | Required runtime integration workstream | **In progress** | Maintainer decision: Runwire 2.1 integration is required for 4.0. Complete automatic capability selection, lifecycle/coherence behavior, executable integration evidence, and exact-revision QA before release-ready status is restored. | ### Batch 1 tracker @@ -367,13 +368,13 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and | 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **Complete** | Exact head `abae64c585998bc56327ab5792fef39f881ed875` passed Security & Standards #433 and Release Verification #73. Worker-owned cancellation/drain, bounded polling, maintenance cycles, capability fallback, and normal-path behavior are covered. | | 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **In progress** | Add executable consumer evidence and operational documentation, then run exact-head Security & Standards + Release Verification. | -## Optional Runwire 2.1 integration workstream +## Runwire 2.1 integration workstream (required release scope; optional dependency) ### Scope and dependency decision The assessment inspected local Runwire tag `2.1` (`e6a954df1ec90aef98daf8248bd02f741f9324e3`). This establishes available APIs and platform requirements, not successful CacheLayer integration or a measured throughput benefit. Worker supervision, structured coroutines, and lifecycle support already existed before 2.1; the principal 2.1 additions concern adaptive HTTP scheduling. -Runwire requires 64-bit PHP 8.4+, matching CacheLayer 4.0's minimum PHP version. If selected, implement the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require`; keep it an optional integration. Add a Composer suggestion only when usable integration documentation exists. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the example; do not introduce a generic runtime abstraction speculatively. +Runwire requires 64-bit PHP 8.4+, matching CacheLayer 4.0's minimum PHP version. The 4.0 release implements the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require`; it remains an optional consumer dependency even though this integration is required 4.0 release scope. Add a Composer suggestion only with usable integration documentation. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the shipped bridge; do not introduce a generic runtime abstraction speculatively. The core must remain usable without Runwire installed, including ordinary PHP-FPM execution. No supervisor, listener, timer, connection, or worker may start during autoload or cache construction. A host that already owns its process pool retains that ownership. The 4.0 major-version decision does not change these dependency and runtime boundaries. From c9d80b977c7e302b1c63a9acaf0df94864bb74f4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:14:05 +0600 Subject: [PATCH 358/434] docs(cluster): link Runwire worker lifecycle --- docs/cluster/_content.inc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index 1e65a8a9..a42f7c72 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -483,6 +483,13 @@ 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 + 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()`` + path above. + Transport-native delivery A custom transport can integrate with a durable broker's worker model, but it still must preserve the replay/cursor contract. CacheLayer's consumer is From 34660387b6aa48d695843475a2c6d836583a98cc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:14:11 +0600 Subject: [PATCH 359/434] docs(node): cover bounded and Runwire maintenance --- docs/node/_content.inc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/node/_content.inc b/docs/node/_content.inc index a659c615..91580ec6 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -354,6 +354,11 @@ rows, but pruning prevents the local SQLite file from accumulating them. Runs SQLite ``PRAGMA optimize``. Schedule it less frequently than pruning, for example as part of a daily maintenance job. +``cycle($pruneLimit, $checkpoint, $optimize)`` + Runs one serial bounded maintenance unit. This is convenient for framework + schedulers and the optional :doc:`/runwire` worker integration because one + call cannot overlap its own prune/checkpoint/optimize steps. + A simple scheduled task can perform a small bounded prune every few minutes and checkpoint periodically. Do not run ``VACUUM`` on the request path; plan it separately if disk-space reclamation is required. @@ -389,6 +394,11 @@ 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 +``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. + Deployment and process guidance ------------------------------- From e6cd20cb6272988261ce0880546f475c39bbeb0c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 06:19:35 +0600 Subject: [PATCH 360/434] test(runwire): stress persistent request memoizer cleanup --- tests/Integration/RunwireIntegrationTest.php | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php index 795685d7..37dcb946 100644 --- a/tests/Integration/RunwireIntegrationTest.php +++ b/tests/Integration/RunwireIntegrationTest.php @@ -208,3 +208,28 @@ static function (CoroutineScope $scope) use ($request): array { static fn(): string => 'stale', ))->toThrow(LogicException::class, 'different runtime'); }); + + +it('releases request-owned memoizers across repeated persistent request lifecycles', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $references = []; + + for ($index = 0; $index < 128; ++$index) { + $request = RequestContext::create($runtime); + $memoizer = RunwireIntegration::share( + $request, + null, + static fn(): mixed => memoize(), + ); + $references[] = WeakReference::create($memoizer); + $request->complete(); + unset($memoizer, $request); + } + + gc_collect_cycles(); + + foreach ($references as $reference) { + expect($reference->get())->toBeNull(); + } +}); From c1bf6cbfad555ee366d5306d245f619b33fedc23 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:12:08 +0600 Subject: [PATCH 361/434] test(runwire): add matched integration certification workload --- tools/release/runwire-consumer/certify.php | 231 +++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 tools/release/runwire-consumer/certify.php diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php new file mode 100644 index 00000000..b3aec85f --- /dev/null +++ b/tools/release/runwire-consumer/certify.php @@ -0,0 +1,231 @@ + */ +function workload(RuntimeContext $runtime, bool $integrated): array +{ + $cache = Cache::memory($integrated ? 'runwire-on' : 'runwire-off'); + $latencies = []; + $errors = 0; + $startMemory = memory_get_usage(true); + $startCpu = getrusage(); + $start = hrtime(true); + + if ($integrated) { + RunwireIntegration::bind($runtime); + } else { + RunwireIntegration::release(); + } + + for ($index = 0; $index < ITERATIONS + WARMUP; ++$index) { + $request = RequestContext::create($runtime); + $started = hrtime(true); + + try { + $operation = static function () use ($cache, $index): void { + $tenant = $index % 32; + $key = 'tenant-' . $tenant . ':item-' . ($index % 256); + + if (($index % 8) === 0) { + if (!$cache->set($key, $index, 60)) { + throw new RuntimeException('Cache write failed.'); + } + } else { + $cache->get($key); + } + }; + + if ($integrated) { + RunwireIntegration::share($request, null, $operation); + } else { + $operation(); + } + } catch (Throwable) { + ++$errors; + } finally { + $request->complete(); + } + + if ($index >= WARMUP) { + $latencies[] = (hrtime(true) - $started) / 1_000_000; + } + } + + $elapsed = (hrtime(true) - $start) / 1_000_000_000; + $endCpu = getrusage(); + $metrics = $cache->exportMetrics(); + $cpuMicros = ( + (($endCpu['ru_utime.tv_sec'] ?? 0) - ($startCpu['ru_utime.tv_sec'] ?? 0)) * 1_000_000 + + (($endCpu['ru_utime.tv_usec'] ?? 0) - ($startCpu['ru_utime.tv_usec'] ?? 0)) + + (($endCpu['ru_stime.tv_sec'] ?? 0) - ($startCpu['ru_stime.tv_sec'] ?? 0)) * 1_000_000 + + (($endCpu['ru_stime.tv_usec'] ?? 0) - ($startCpu['ru_stime.tv_usec'] ?? 0)) + ); + + RunwireIntegration::release($runtime); + gc_collect_cycles(); + + return [ + 'rpm' => ITERATIONS / max($elapsed, 0.000001) * 60, + 'p50_ms' => percentile($latencies, 0.50), + 'p95_ms' => percentile($latencies, 0.95), + 'p99_ms' => percentile($latencies, 0.99), + 'errors' => $errors, + 'memory_delta_bytes' => max(0, memory_get_usage(true) - $startMemory), + 'peak_memory_bytes' => memory_get_peak_usage(true), + 'cpu_ms' => $cpuMicros / 1_000, + 'backend_gets' => (int) ($metrics['get'] ?? 0), + 'backend_sets' => (int) ($metrics['set'] ?? 0), + ]; +} + +/** @return array */ +function invalidationAndMaintenance(): array +{ + $base = sys_get_temp_dir() . '/cachelayer-runwire-cert-' . bin2hex(random_bytes(6)); + mkdir($base, 0700, true); + + try { + $node = new NodeCacheConfig($base . '/node.sqlite', 'certification', apcuEnabled: false); + $transport = new PdoInvalidationTransport( + new PDO('sqlite:' . $base . '/events.sqlite'), + allowSqliteForTesting: true, + ); + $cluster = ClusterCache::create( + $node, + new ClusterCacheConfig('certification', 'consumer', 'sqlite-cert', consumerBatchSize: 100), + $transport, + ); + + for ($index = 0; $index < 100; ++$index) { + $transport->publish( + InvalidationEvent::key( + 'certification', + 'certification', + 'key-' . $index, + 'publisher', + ), + ); + } + + $started = hrtime(true); + $processed = $cluster->drain(limit: 25, maxBatches: 8); + $lagMs = (hrtime(true) - $started) / 1_000_000; + + $connection = new PDO('sqlite:' . $base . '/node.sqlite'); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + for ($index = 0; $index < 100; ++$index) { + $statement->execute(['certification', 'expired-' . $index, 'unused', time() - 1]); + } + + $maintenance = NodeCache::maintenance($node); + $samples = []; + for ($cycle = 0; $cycle < 10; ++$cycle) { + $cycleStarted = hrtime(true); + $maintenance->cycle(pruneLimit: 10, checkpoint: true, optimize: $cycle === 9); + $samples[] = (hrtime(true) - $cycleStarted) / 1_000_000; + } + + return [ + 'invalidation_events' => $processed, + 'invalidation_lag_ms' => $lagMs, + 'maintenance_p95_ms' => percentile($samples, 0.95), + 'pending_events' => $cluster->status()->pendingEventCount ?? -1, + ]; + } finally { + if (is_dir($base)) { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); + } + } +} + +$capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, +); +$runtime = RuntimeContext::fromCapabilities( + $capabilities, + 'cachelayer-certification', + workerSlot: 0, + generation: 1, + concurrent: true, +); + +$baseline = workload($runtime, false); +$integrated = workload($runtime, true); +$operational = invalidationAndMaintenance(); + +$ratio = $integrated['rpm'] / max((float) $baseline['rpm'], 0.000001); +$p99Budget = ((float) $baseline['p99_ms'] * MAX_P99_MULTIPLIER) + MAX_EXTRA_P99_MS; + +$report = [ + 'php' => PHP_VERSION, + 'runwire' => Composer\InstalledVersions::getPrettyVersion('infocyph/runwire'), + 'iterations' => ITERATIONS, + 'baseline' => $baseline, + 'integrated' => $integrated, + 'rpm_ratio' => $ratio, + 'budgets' => [ + 'minimum_rpm_ratio' => MIN_RPM_RATIO, + 'maximum_integrated_p99_ms' => $p99Budget, + 'maximum_memory_delta_bytes' => MAX_MEMORY_DELTA_BYTES, + 'errors' => 0, + ], + 'operational' => $operational, +]; + +fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL); + +if ( + $baseline['errors'] !== 0 + || $integrated['errors'] !== 0 + || $ratio < MIN_RPM_RATIO + || $integrated['p99_ms'] > $p99Budget + || $integrated['memory_delta_bytes'] > MAX_MEMORY_DELTA_BYTES + || $operational['invalidation_events'] !== 100 + || $operational['pending_events'] !== 0 +) { + throw new RuntimeException('Runwire matched certification workload exceeded a release budget.'); +} From 30ad36c6189fb7db958f43224367a36f58ddefce Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:12:34 +0600 Subject: [PATCH 362/434] test(runwire): add bounded persistent-worker soak --- tools/release/runwire-consumer/soak.php | 209 ++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tools/release/runwire-consumer/soak.php diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php new file mode 100644 index 00000000..521a5b83 --- /dev/null +++ b/tools/release/runwire-consumer/soak.php @@ -0,0 +1,209 @@ + $value + 1, [$index]); + if ($memoized !== $index + 1 || memoize(static fn(int $value): int => $value + 1, [$index]) !== $index + 1) { + throw new RuntimeException('request memoizer leaked or failed'); + } + + $item = $cache->getItem($key)->set($index); + if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $index) { + throw new RuntimeException('deferred cache write failed during soak'); + } + ++$validated; + }, + ); + } catch (Throwable $exception) { + if ($exception->getMessage() !== 'intentional soak failure') { + throw $exception; + } + } finally { + $request->complete(); + } +} + +$coroutines = new CoroutineRuntime(); +$coroutines->run( + static function (CoroutineScope $scope) use ( + $runtime, + $cache, + &$cancellations, + &$deadlines, + &$validated, + ): void { + $tasks = []; + for ($index = 0; $index < CONCURRENT_REQUESTS; ++$index) { + $tasks[] = $scope->spawn( + static function () use ( + $runtime, + $scope, + $cache, + $index, + &$cancellations, + &$deadlines, + &$validated, + ): void { + $deadlineCase = ($index % 61) === 0; + $policy = $deadlineCase + ? new RequestExecutionPolicy(maxExecutionSeconds: 0.001) + : new RequestExecutionPolicy(); + $start = $deadlineCase + ? (int) hrtime(true) - 2_000_000 + : null; + $request = RequestContext::create($runtime, $policy, startNanoseconds: $start); + + try { + RunwireIntegration::share( + $request, + $scope, + static function () use ( + $request, + $cache, + $index, + $deadlineCase, + &$cancellations, + &$deadlines, + &$validated, + ): void { + if ($deadlineCase) { + try { + RunwireIntegration::checkpoint(); + } catch (CancelledException) { + ++$deadlines; + + return; + } + } + + if (($index % 53) === 0) { + $request->cancel(CancellationReason::HOST_CANCELLED); + try { + RunwireIntegration::checkpoint(); + } catch (CancelledException) { + ++$cancellations; + + return; + } + } + + $tenant = $index % 16; + $key = 'concurrent-' . $tenant . ':' . $index; + $memo = memoize(static fn(int $value): int => $value * 2, [$index]); + $scope->yieldNow(); + if (memoize(static fn(int $value): int => $value * 2, [$index]) !== $memo) { + throw new RuntimeException('concurrent request memoizer leaked'); + } + + $item = $cache->getItem($key)->set($memo); + if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $memo) { + throw new RuntimeException('concurrent deferred cache write failed'); + } + ++$validated; + }, + ); + } finally { + if (!$request->completed()) { + $request->complete(); + } + } + }, + ); + } + + foreach ($tasks as $task) { + $task->await(); + } + }, +); + +RunwireIntegration::release($runtime); +gc_collect_cycles(); +$memoryGrowth = max(0, memory_get_usage(true) - $startMemory); + +$report = [ + 'php' => PHP_VERSION, + 'runwire' => Composer\InstalledVersions::getPrettyVersion('infocyph/runwire'), + 'sequential_requests' => SEQUENTIAL_REQUESTS, + 'concurrent_requests' => CONCURRENT_REQUESTS, + 'intentional_failures' => $errors, + 'cancellations' => $cancellations, + 'deadlines' => $deadlines, + 'validated_requests' => $validated, + 'memory_growth_bytes' => $memoryGrowth, + 'peak_memory_bytes' => memory_get_peak_usage(true), +]; + +fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL); + +if ( + $validated < 2_000 + || $cancellations < 1 + || $deadlines < 1 + || $memoryGrowth > MAX_MEMORY_GROWTH_BYTES + || RunwireIntegration::runtime() !== null +) { + throw new RuntimeException('Runwire persistent-worker soak did not satisfy release invariants.'); +} From 91cc2d4b1b29c2f47e2dad773b5cdc33d461b95f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:12:44 +0600 Subject: [PATCH 363/434] test(runwire): record resolved runtime capabilities --- tools/release/runwire-consumer/smoke.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/smoke.php b/tools/release/runwire-consumer/smoke.php index fc436dbe..df6dd85a 100644 --- a/tools/release/runwire-consumer/smoke.php +++ b/tools/release/runwire-consumer/smoke.php @@ -20,4 +20,15 @@ putenv('CACHELAYER_EXAMPLE_AUTOLOAD=' . __DIR__ . '/vendor/autoload.php'); require dirname(__DIR__, 3) . '/examples/runwire-invalidation-worker.php'; -fwrite(STDOUT, sprintf("CacheLayer Runwire consumer smoke passed with %s.\n", $version)); +$extensions = array_values(array_filter( + ['pcntl', 'posix', 'event', 'swoole', 'openswoole'], + static fn(string $extension): bool => extension_loaded($extension), +)); + +fwrite(STDOUT, sprintf( + "CacheLayer Runwire consumer smoke passed with %s on PHP %s (%d-bit); native extensions: %s.\n", + $version, + PHP_VERSION, + PHP_INT_SIZE * 8, + $extensions === [] ? 'none' : implode(', ', $extensions), +)); From e90378ba3ce03d0f41705c6a149033ac56d5787a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:12:56 +0600 Subject: [PATCH 364/434] ci(runwire): certify matched workload and persistent soak --- .github/workflows/release-verification.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index db25b540..a311bcc5 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -89,6 +89,12 @@ jobs: - name: Run Runwire integration smoke working-directory: tools/release/runwire-consumer run: php -d error_reporting=E_ALL smoke.php + - name: Run matched Runwire certification workload + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL certify.php + - name: Run persistent-worker Runwire soak + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL soak.php psr-contracts: name: Independent PSR-6 / PSR-16 contracts From db95debaed2cfdace3a61e20a394c64d60c14df1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:13:15 +0600 Subject: [PATCH 365/434] test(runwire): cover worker failure and topology fallback --- .../RunwireWorkerIntegrationTest.php | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php index b499aa01..4aef387c 100644 --- a/tests/Integration/RunwireWorkerIntegrationTest.php +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -4,6 +4,8 @@ use Infocyph\CacheLayer\Cluster\ClusterCache; use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; +use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInterface; +use Infocyph\CacheLayer\Cluster\Event\InvalidationBatch; use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use Infocyph\CacheLayer\Integration\Runwire\RunwireWorkerIntegration; @@ -21,6 +23,7 @@ use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\RuntimeContext; +use Infocyph\Runwire\Supervisor\Enum\ShutdownReason; use Infocyph\Runwire\Supervisor\Enum\WorkerRole; use Infocyph\Runwire\Supervisor\WorkerContext; @@ -259,3 +262,88 @@ function (CoroutineScope $scope) use ($request): int { $worker->close(); fclose($readyParent); }); + + +it('turns an unhandled CacheLayer consumer failure into a Runwire worker stop', function (): void { + $transport = new class implements InvalidationTransportInterface + { + public function consumeAfter(string $cluster, ?string $cursor, int $limit): InvalidationBatch + { + throw new RuntimeException('intentional invalidation backend failure'); + } + + public function isCursorBefore(string $cursor, string $oldestAvailableId): bool + { + return false; + } + + public function oldestAvailableId(string $cluster): ?string + { + return null; + } + + public function publish(InvalidationEvent $event): string + { + return '1'; + } + }; + $cluster = ClusterCache::create( + $this->runwireWorkerNodeConfig, + new ClusterCacheConfig('runwire-failure', 'node-a', 'runwire-failure'), + $transport, + ); + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $cluster, + batchSize: 1, + idleSeconds: 0.001, + ); + expect($task)->not->toBeNull(); + + $loop->run(); + + expect($task?->state())->toBe(TaskState::FAILED) + ->and($worker->stopping())->toBeTrue() + ->and($worker->shutdownReason())->toBe(ShutdownReason::FATAL_RUNTIME_ERROR) + ->and($worker->backgroundDrainExpired())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('does not take worker or loop ownership for unsupported worker topology', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + + [$readyParent, $readyChild] = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + STREAM_IPPROTO_IP, + ); + $pid = getmypid(); + $worker = new WorkerContext( + group: 'cachelayer-http', + slot: 0, + generation: 1, + pid: is_int($pid) ? $pid : 0, + parentPid: 0, + readyStream: $readyChild, + role: WorkerRole::HTTP, + ); + + expect(RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + ))->toBeNull() + ->and($worker->stopping())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); From 56862e5f44ee894a8b4b486d8cd0e1b5246d4adb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:14:51 +0600 Subject: [PATCH 366/434] fix(runwire): use valid cache keys in release workloads --- tools/release/runwire-consumer/certify.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index b3aec85f..33e6e997 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -55,7 +55,7 @@ function workload(RuntimeContext $runtime, bool $integrated): array try { $operation = static function () use ($cache, $index): void { $tenant = $index % 32; - $key = 'tenant-' . $tenant . ':item-' . ($index % 256); + $key = 'tenant-' . $tenant . '-item-' . ($index % 256); if (($index % 8) === 0) { if (!$cache->set($key, $index, 60)) { From 0d26254a91744996617b285c0d4c44367f4b06cc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:14:54 +0600 Subject: [PATCH 367/434] fix(runwire): use valid cache keys in release workloads --- tools/release/runwire-consumer/soak.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php index 521a5b83..5eac2b32 100644 --- a/tools/release/runwire-consumer/soak.php +++ b/tools/release/runwire-consumer/soak.php @@ -62,7 +62,7 @@ null, static function () use ($cache, $index, &$validated): void { $tenant = $index % 64; - $key = 'tenant-' . $tenant . ':request-' . $index; + $key = 'tenant-' . $tenant . '-request-' . $index; $memoized = memoize(static fn(int $value): int => $value + 1, [$index]); if ($memoized !== $index + 1 || memoize(static fn(int $value): int => $value + 1, [$index]) !== $index + 1) { @@ -150,7 +150,7 @@ static function () use ( } $tenant = $index % 16; - $key = 'concurrent-' . $tenant . ':' . $index; + $key = 'concurrent-' . $tenant . '-' . $index; $memo = memoize(static fn(int $value): int => $value * 2, [$index]); $scope->yieldNow(); if (memoize(static fn(int $value): int => $value * 2, [$index]) !== $memo) { From 3d659d2544b716cad529d98c670be8796d8b4412 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:15:48 +0600 Subject: [PATCH 368/434] fix(runwire): retain task scope in soak workload --- tools/release/runwire-consumer/soak.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php index 5eac2b32..1814aec5 100644 --- a/tools/release/runwire-consumer/soak.php +++ b/tools/release/runwire-consumer/soak.php @@ -121,6 +121,7 @@ static function () use ( $scope, static function () use ( $request, + $scope, $cache, $index, $deadlineCase, From 12f4867e76049ca4c83f056b1f04bd0ebb575d4f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:17:15 +0600 Subject: [PATCH 369/434] fix(runwire): test cancellation in request-owned scopes --- tools/release/runwire-consumer/soak.php | 91 ++++++++++++++----------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php index 1814aec5..7ed87c53 100644 --- a/tools/release/runwire-consumer/soak.php +++ b/tools/release/runwire-consumer/soak.php @@ -43,15 +43,60 @@ $validated = 0; for ($index = 0; $index < SEQUENTIAL_REQUESTS; ++$index) { - $policy = ($index % 257) === 0 + $deadlineCase = ($index % 257) === 0; + $cancellationCase = !$deadlineCase && ($index % 223) === 0; + $policy = $deadlineCase ? new RequestExecutionPolicy(maxExecutionSeconds: 0.001) : new RequestExecutionPolicy(); - $start = ($index % 257) === 0 + $start = $deadlineCase ? (int) hrtime(true) - 2_000_000 : null; $request = RequestContext::create($runtime, $policy, startNanoseconds: $start); try { + if ($deadlineCase) { + try { + (new CoroutineRuntime())->runRequest( + $request, + static function (CoroutineScope $scope) use ($request): void { + RunwireIntegration::share( + $request, + $scope, + static function (): void { + RunwireIntegration::checkpoint(); + }, + ); + }, + ); + } catch (CancelledException) { + ++$deadlines; + } + + continue; + } + + if ($cancellationCase) { + try { + (new CoroutineRuntime())->runRequest( + $request, + static function (CoroutineScope $scope) use ($request): void { + RunwireIntegration::share( + $request, + $scope, + static function () use ($request): void { + $request->cancel(CancellationReason::HOST_CANCELLED); + RunwireIntegration::checkpoint(); + }, + ); + }, + ); + } catch (CancelledException) { + ++$cancellations; + } + + continue; + } + if (($index % 191) === 0) { ++$errors; throw new RuntimeException('intentional soak failure'); @@ -81,7 +126,9 @@ static function () use ($cache, $index, &$validated): void { throw $exception; } } finally { - $request->complete(); + if (!$request->completed()) { + $request->complete(); + } } } @@ -90,8 +137,6 @@ static function () use ($cache, $index, &$validated): void { static function (CoroutineScope $scope) use ( $runtime, $cache, - &$cancellations, - &$deadlines, &$validated, ): void { $tasks = []; @@ -102,54 +147,20 @@ static function () use ( $scope, $cache, $index, - &$cancellations, - &$deadlines, &$validated, ): void { - $deadlineCase = ($index % 61) === 0; - $policy = $deadlineCase - ? new RequestExecutionPolicy(maxExecutionSeconds: 0.001) - : new RequestExecutionPolicy(); - $start = $deadlineCase - ? (int) hrtime(true) - 2_000_000 - : null; - $request = RequestContext::create($runtime, $policy, startNanoseconds: $start); + $request = RequestContext::create($runtime); try { RunwireIntegration::share( $request, $scope, static function () use ( - $request, $scope, $cache, $index, - $deadlineCase, - &$cancellations, - &$deadlines, &$validated, ): void { - if ($deadlineCase) { - try { - RunwireIntegration::checkpoint(); - } catch (CancelledException) { - ++$deadlines; - - return; - } - } - - if (($index % 53) === 0) { - $request->cancel(CancellationReason::HOST_CANCELLED); - try { - RunwireIntegration::checkpoint(); - } catch (CancelledException) { - ++$cancellations; - - return; - } - } - $tenant = $index % 16; $key = 'concurrent-' . $tenant . '-' . $index; $memo = memoize(static fn(int $value): int => $value * 2, [$index]); From b5d623b40c2f5d386b10e4bf44577231ed281483 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:19:46 +0600 Subject: [PATCH 370/434] fix(runwire): satisfy PHPForge certification analysis --- tools/release/runwire-consumer/certify.php | 88 ++++++++++++++-------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index 33e6e997..0a513435 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -24,12 +24,66 @@ const MAX_EXTRA_P99_MS = 1.0; const MAX_MEMORY_DELTA_BYTES = 8_388_608; +/** @param list $samples */ function percentile(array $samples, float $percentile): float { sort($samples, SORT_NUMERIC); $index = (int) floor((count($samples) - 1) * $percentile); - return $samples[$index] ?? 0.0; + return (float) ($samples[$index] ?? 0.0); +} + +/** @param array $usage */ +function usageValue(array $usage, string $key): int +{ + $value = $usage[$key] ?? 0; + + return is_int($value) ? $value : (int) $value; +} + +/** @param array $start @param array $end */ +function cpuMicros(array $start, array $end): int +{ + return (usageValue($end, 'ru_utime.tv_sec') - usageValue($start, 'ru_utime.tv_sec')) * 1_000_000 + + usageValue($end, 'ru_utime.tv_usec') - usageValue($start, 'ru_utime.tv_usec') + + (usageValue($end, 'ru_stime.tv_sec') - usageValue($start, 'ru_stime.tv_sec')) * 1_000_000 + + usageValue($end, 'ru_stime.tv_usec') - usageValue($start, 'ru_stime.tv_usec'); +} + +function runCacheOperation(Cache $cache, int $index): void +{ + $tenant = $index % 32; + $key = 'tenant-' . $tenant . '-item-' . ($index % 256); + + if (($index % 8) === 0) { + if (!$cache->set($key, $index, 60)) { + throw new RuntimeException('Cache write failed.'); + } + + return; + } + + $cache->get($key); +} + +function cleanupDirectory(string $base): void +{ + if (!is_dir($base)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); } /** @return array */ @@ -53,19 +107,7 @@ function workload(RuntimeContext $runtime, bool $integrated): array $started = hrtime(true); try { - $operation = static function () use ($cache, $index): void { - $tenant = $index % 32; - $key = 'tenant-' . $tenant . '-item-' . ($index % 256); - - if (($index % 8) === 0) { - if (!$cache->set($key, $index, 60)) { - throw new RuntimeException('Cache write failed.'); - } - } else { - $cache->get($key); - } - }; - + $operation = static fn(): null => runCacheOperation($cache, $index); if ($integrated) { RunwireIntegration::share($request, null, $operation); } else { @@ -85,12 +127,7 @@ function workload(RuntimeContext $runtime, bool $integrated): array $elapsed = (hrtime(true) - $start) / 1_000_000_000; $endCpu = getrusage(); $metrics = $cache->exportMetrics(); - $cpuMicros = ( - (($endCpu['ru_utime.tv_sec'] ?? 0) - ($startCpu['ru_utime.tv_sec'] ?? 0)) * 1_000_000 - + (($endCpu['ru_utime.tv_usec'] ?? 0) - ($startCpu['ru_utime.tv_usec'] ?? 0)) - + (($endCpu['ru_stime.tv_sec'] ?? 0) - ($startCpu['ru_stime.tv_sec'] ?? 0)) * 1_000_000 - + (($endCpu['ru_stime.tv_usec'] ?? 0) - ($startCpu['ru_stime.tv_usec'] ?? 0)) - ); + $cpuMicros = cpuMicros($startCpu, $endCpu); RunwireIntegration::release($runtime); gc_collect_cycles(); @@ -165,16 +202,7 @@ function invalidationAndMaintenance(): array 'pending_events' => $cluster->status()->pendingEventCount ?? -1, ]; } finally { - if (is_dir($base)) { - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - foreach ($files as $file) { - $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); - } - rmdir($base); - } + cleanupDirectory($base); } } From 1b6559b76735edae3e8d7b231b44c7ebc7c63d20 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:19:58 +0600 Subject: [PATCH 371/434] fix(runwire): keep certification operation void --- tools/release/runwire-consumer/certify.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index 0a513435..1d661de3 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -107,7 +107,9 @@ function workload(RuntimeContext $runtime, bool $integrated): array $started = hrtime(true); try { - $operation = static fn(): null => runCacheOperation($cache, $index); + $operation = static function () use ($cache, $index): void { + runCacheOperation($cache, $index); + }; if ($integrated) { RunwireIntegration::share($request, null, $operation); } else { From afdddbc88ef32d4d2b8886a03956d82e413754ea Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:20:38 +0600 Subject: [PATCH 372/434] docs(runwire): declare certification usage shapes --- tools/release/runwire-consumer/certify.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index 1d661de3..f79ed777 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -41,7 +41,10 @@ function usageValue(array $usage, string $key): int return is_int($value) ? $value : (int) $value; } -/** @param array $start @param array $end */ +/** + * @param array $start + * @param array $end + */ function cpuMicros(array $start, array $end): int { return (usageValue($end, 'ru_utime.tv_sec') - usageValue($start, 'ru_utime.tv_sec')) * 1_000_000 From cee1d9ba7063752a5d85b5c0ee25ca1a9674a01d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:23:33 +0600 Subject: [PATCH 373/434] fix(runwire): normalize certification resource usage --- tools/release/runwire-consumer/certify.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index f79ed777..4e3b0940 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -38,7 +38,7 @@ function usageValue(array $usage, string $key): int { $value = $usage[$key] ?? 0; - return is_int($value) ? $value : (int) $value; + return is_int($value) ? $value : 0; } /** @@ -97,6 +97,9 @@ function workload(RuntimeContext $runtime, bool $integrated): array $errors = 0; $startMemory = memory_get_usage(true); $startCpu = getrusage(); + if ($startCpu === false) { + $startCpu = []; + } $start = hrtime(true); if ($integrated) { @@ -131,6 +134,9 @@ function workload(RuntimeContext $runtime, bool $integrated): array $elapsed = (hrtime(true) - $start) / 1_000_000_000; $endCpu = getrusage(); + if ($endCpu === false) { + $endCpu = []; + } $metrics = $cache->exportMetrics(); $cpuMicros = cpuMicros($startCpu, $endCpu); From b8e7d36cb084ec0f5c777ed40c790f532a4c8d1d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:26:35 +0600 Subject: [PATCH 374/434] fix(runwire): accept getrusage array key domain --- tools/release/runwire-consumer/certify.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index 4e3b0940..1c230f54 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -33,7 +33,7 @@ function percentile(array $samples, float $percentile): float return (float) ($samples[$index] ?? 0.0); } -/** @param array $usage */ +/** @param array $usage */ function usageValue(array $usage, string $key): int { $value = $usage[$key] ?? 0; @@ -42,8 +42,8 @@ function usageValue(array $usage, string $key): int } /** - * @param array $start - * @param array $end + * @param array $start + * @param array $end */ function cpuMicros(array $start, array $end): int { From cd24d1e097ae0ca6b24c48e3186bc15b77c834c8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:30:23 +0600 Subject: [PATCH 375/434] style(runwire): adopt PHPForge callable form --- tools/release/runwire-consumer/smoke.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release/runwire-consumer/smoke.php b/tools/release/runwire-consumer/smoke.php index df6dd85a..c7f6b8c7 100644 --- a/tools/release/runwire-consumer/smoke.php +++ b/tools/release/runwire-consumer/smoke.php @@ -22,7 +22,7 @@ $extensions = array_values(array_filter( ['pcntl', 'posix', 'event', 'swoole', 'openswoole'], - static fn(string $extension): bool => extension_loaded($extension), + extension_loaded(...), )); fwrite(STDOUT, sprintf( From c1ff97b491e2625688889960b6aa96a83df8c873 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:30:28 +0600 Subject: [PATCH 376/434] style(runwire): align soak with PHPForge --- tools/release/runwire-consumer/soak.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php index 7ed87c53..3c77efbf 100644 --- a/tools/release/runwire-consumer/soak.php +++ b/tools/release/runwire-consumer/soak.php @@ -56,7 +56,7 @@ try { if ($deadlineCase) { try { - (new CoroutineRuntime())->runRequest( + new CoroutineRuntime()->runRequest( $request, static function (CoroutineScope $scope) use ($request): void { RunwireIntegration::share( @@ -77,7 +77,7 @@ static function (): void { if ($cancellationCase) { try { - (new CoroutineRuntime())->runRequest( + new CoroutineRuntime()->runRequest( $request, static function (CoroutineScope $scope) use ($request): void { RunwireIntegration::share( @@ -118,6 +118,7 @@ static function () use ($cache, $index, &$validated): void { if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $index) { throw new RuntimeException('deferred cache write failed during soak'); } + ++$validated; }, ); @@ -173,6 +174,7 @@ static function () use ( if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $memo) { throw new RuntimeException('concurrent deferred cache write failed'); } + ++$validated; }, ); From 7d2a5619cc414e448c7eb0fb25976f3e3d92ef39 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:30:36 +0600 Subject: [PATCH 377/434] test(runwire): keep failure fixture PHPForge-clean --- tests/Integration/RunwireWorkerIntegrationTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php index 4aef387c..f67fbd0a 100644 --- a/tests/Integration/RunwireWorkerIntegrationTest.php +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -269,21 +269,29 @@ function (CoroutineScope $scope) use ($request): int { { public function consumeAfter(string $cluster, ?string $cursor, int $limit): InvalidationBatch { + unset($cluster, $cursor, $limit); + throw new RuntimeException('intentional invalidation backend failure'); } public function isCursorBefore(string $cursor, string $oldestAvailableId): bool { + unset($cursor, $oldestAvailableId); + return false; } public function oldestAvailableId(string $cluster): ?string { + unset($cluster); + return null; } public function publish(InvalidationEvent $event): string { + unset($event); + return '1'; } }; From 8dd980f1827f2272f70c83cefaf89c479e562b5c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:34:03 +0600 Subject: [PATCH 378/434] style(runwire): separate intentional soak throw --- tools/release/runwire-consumer/soak.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/release/runwire-consumer/soak.php b/tools/release/runwire-consumer/soak.php index 3c77efbf..5d8a4b31 100644 --- a/tools/release/runwire-consumer/soak.php +++ b/tools/release/runwire-consumer/soak.php @@ -99,6 +99,7 @@ static function () use ($request): void { if (($index % 191) === 0) { ++$errors; + throw new RuntimeException('intentional soak failure'); } From 342bb0c8928a6a54dd22ee80d75e247d56807cb8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:39:18 +0600 Subject: [PATCH 379/434] docs(plan): close Runwire 2.1 release scope --- ...achelayer-4.0-security-correctness-plan.md | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index ca1719b5..90df7935 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,13 +1,13 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation in progress; Batches 1-6 complete; required Runwire 2.1 integration workstream reopened +Status: Implementation complete; Batches 1-7 complete; Runwire 2.1 integration and release validation passed Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** ## Implementation tracker -Updated: 2026-09-28 +Updated: 2026-09-29 Working branch: `feature/improvements` Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://github.com/infocyph/CacheLayer/pull/29) @@ -19,7 +19,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | | 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | -| 7 — Runwire 2.1 integration | Required runtime integration workstream | **In progress** | Maintainer decision: Runwire 2.1 integration is required for 4.0. Complete automatic capability selection, lifecycle/coherence behavior, executable integration evidence, and exact-revision QA before release-ready status is restored. | +| 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | ### Batch 1 tracker @@ -92,19 +92,19 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | R19 — support matrix and tooling | **Complete** | PHPForge runs PHP 8.4/8.5 analysis, benchmarks, and stable/lowest QA without changing its hard limits. Real MongoDB integration is in the QA matrix; release verification adds real Redis Cluster and Scylla CQL plus Linux/Windows core smoke. | | Documentation / migration / rollback | **Complete** | `docs/upgrade-4.0.rst`, `docs/release-4.0.rst`, serializer/security guidance, cursor/counter/storage cutover instructions, and rollback guidance cover the intentional 3.x→4.0 break. | | Packaging / consumer / docs | **Complete** | Clean no-dev consumers on PHP 8.4/8.5, independent PSR-6/PSR-16 integration suites, docs with warnings as errors, and cross-platform core smoke all pass on the exact implementation head. | -| Runwire 2.1 integration | **Reopened / required** | Required for 4.0 by maintainer decision. Core release acceptance remains green, but final release-ready status is blocked until the Runwire workstream and its gates pass. | +| Runwire 2.1 integration | **Complete in Batch 7** | Required 4.0 integration ships while Runwire remains an optional consumer dependency. Automatic capability use, normal fallback, worker/request lifecycle ownership, executable evidence, certification, and soak gates are complete. | **Batch 6 closure evidence:** exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. The security workflow passed clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs under the unchanged PHPForge limits. Release verification passed PHP 8.4/8.5 Linux and Windows core smoke, clean no-dev consumers, independent PSR-6/PSR-16 contracts, documentation warnings-as-errors, real Redis Cluster, and real Scylla CQL. Real MongoDB integration is exercised in the PHPForge service matrix. No unresolved PR review threads remained at closure. ## Decision -The planned 4.0 security, correctness, backend, migration, and core release-gate work is implemented and validated. However, the maintainer has made Runwire 2.1 integration mandatory for 4.0, so release-ready status is reopened until that integration and its acceptance gates are complete. Broader production-equivalent performance measurement remains separate unless needed to validate the shipped Runwire path. +The planned 4.0 security, correctness, backend, migration, core release-gate, and required Runwire 2.1 integration work is implemented and validated. CacheLayer 4.0 is release-ready at the completed-plan level. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. CacheLayer 4.0 raises the minimum runtime to PHP 8.4 by maintainer decision. The release matrix therefore targets PHP 8.4 and 8.5. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. -Runwire 2.1 integration is a required 4.0 target. When Runwire is loaded as the active runtime, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. The integration must remain optional as a consumer dependency—the core package must continue to work without Runwire installed—but the 4.0 release itself is blocked until the Runwire integration, executable evidence, and applicable gates below are complete. +Runwire 2.1 integration is a required 4.0 feature and is complete. When the hosting framework binds an active Runwire runtime and shares its request/task scope, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. Runwire remains optional as a consumer dependency and the core package continues to work without it installed. This document follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and the applicable [PHPForge AGENTS.md workflow](../../vendor/infocyph/phpforge/resources/AGENTS.md). @@ -355,10 +355,10 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and - [x] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. 7. **Runwire 2.1 integration — required for the 4.0 release.** - - [ ] Implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. - - [ ] Implement bounded maintenance scheduling and persistent-request lifecycle integration where the current Runwire APIs support a safe ownership model. - - [ ] Use Runwire where it materially improves isolated crash/concurrency verification without making it a core dependency. - - [ ] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. + - [x] Implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. + - [x] Implement bounded maintenance scheduling and persistent-request lifecycle integration where the current Runwire APIs support a safe ownership model. + - [x] Use Runwire where it materially improves isolated crash/concurrency verification without making it a core dependency. + - [x] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. ### Batch 7 tracker @@ -366,7 +366,7 @@ Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and | --- | --- | --- | --- | | 7A — Runtime/request lifecycle | Shared active RuntimeContext + request/task scope, capability-driven memoizer isolation/fallback | **Complete** | Exact head `a998ffaf0b952c7c751183a8a0652e25770472bc` passed Security & Standards #421 and Release Verification #61. Clean no-dev consumers confirm Runwire remains optional. | | 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **Complete** | Exact head `abae64c585998bc56327ab5792fef39f881ed875` passed Security & Standards #433 and Release Verification #73. Worker-owned cancellation/drain, bounded polling, maintenance cycles, capability fallback, and normal-path behavior are covered. | -| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **In progress** | Add executable consumer evidence and operational documentation, then run exact-head Security & Standards + Release Verification. | +| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105. The release matrix includes Runwire 2.1 on PHP 8.4/8.5 with lowest/stable dependencies, matched certification, persistent-worker soak, and the normal no-Runwire consumers. | ## Runwire 2.1 integration workstream (required release scope; optional dependency) @@ -392,8 +392,8 @@ Use the active runtime's public context and supported lifecycle hooks. Runwire 2 ### Planned uses and prerequisites -1. **Supervised cluster invalidation — first deliverable if selected.** Wrap existing `ClusterRuntime::consume()` calls in bounded scheduled work with explicit batch size, polling interval, backend timeouts, retry/backoff, and shutdown budgets. Preserve serial consumption within each complete cursor scope; independent scopes may run independently. Create backend connections in worker bootstrap after a fork. Expose consumed counts, failures, consumer lag, and restart behavior without unbounded metric labels. Resolve R06/R07 before relying on durable progress and R15 before claiming node-wide L1 coherence. A separate CLI consumer must not be described as clearing unrelated FPM/worker APCu domains automatically. -2. **Bounded maintenance — evaluate for inclusion.** Schedule existing `NodeCacheMaintenance::pruneExpired()`, `checkpoint()`, and `optimize()` at explicit operational intervals. Bound prune batches and prevent overlapping maintenance against the same store. Measure SQLite writer contention and choose heavier maintenance windows accordingly. Do not place full scans or maintenance on the request hot path. Supervision does not make an individual blocking database operation cancellable. +1. **Supervised cluster invalidation — shipped integration.** Wrap existing `ClusterRuntime::consume()` calls in bounded scheduled work with explicit batch size, polling interval, backend timeouts, retry/backoff, and shutdown budgets. Preserve serial consumption within each complete cursor scope; independent scopes may run independently. Create backend connections in worker bootstrap after a fork. Expose consumed counts, failures, consumer lag, and restart behavior without unbounded metric labels. Resolve R06/R07 before relying on durable progress and R15 before claiming node-wide L1 coherence. A separate CLI consumer must not be described as clearing unrelated FPM/worker APCu domains automatically. +2. **Bounded maintenance — shipped integration.** Schedule existing `NodeCacheMaintenance::pruneExpired()`, `checkpoint()`, and `optimize()` at explicit operational intervals. Bound prune batches and prevent overlapping maintenance against the same store. Measure SQLite writer contention and choose heavier maintenance windows accordingly. Do not place full scans or maintenance on the request hot path. Supervision does not make an individual blocking database operation cancellable. 3. **Persistent request lifecycle — conditional on the host integration.** After R08 and the relevant deferred-state fixes, connect request-owned memoizer/state cleanup to Runwire's completion/reset lifecycle, including failure, cancellation, and deadline paths. `flush_memoizers()` is suitable only for a sequential lifecycle with an explicit ownership contract. Concurrent requests need isolated memoizer state, potentially through Runwire task-local context or an explicit request-owned instance; one request must not flush or observe another request's state. Preserve intentional cross-request cache data and resolve pending deferred writes under their documented contract. 4. **Crash and concurrency verification — usable during earlier batches.** Evaluate Runwire's bounded subprocess runner for recursive-payload probes and its worker supervision for real restart/concurrency tests. Set explicit PHP memory, execution-time, and output limits; execute validated argument vectors. `ProcessRunner` is synchronous and is not itself a parallel worker pool or OS sandbox. Retain a lightweight existing subprocess harness if adopting Runwire adds complexity without improving evidence. Core regression coverage must remain available independently of Runwire. @@ -403,16 +403,22 @@ Existing PDO, filesystem, and synchronous native-client calls remain blocking in The Runwire workstream is required for 4.0. Every shipped Runwire capability must pass the applicable gates below before the release can be called ready: -- [ ] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. -- [ ] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. -- [ ] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. -- [ ] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. -- [ ] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. -- [ ] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. -- [ ] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. -- [ ] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. -- [ ] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. -- [ ] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. +- [x] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. +- [x] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. +- [x] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. +- [x] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. +- [x] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. +- [x] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. +- [x] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. +- [x] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. +- [x] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. +- [x] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. + +**Batch 7 closure evidence:** exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105. The Runwire consumer matrix covered PHP 8.4/8.5 with lowest and stable dependencies while clean no-dev consumers proved the core remains usable without Runwire. `examples/runwire-invalidation-worker.php` and `tools/release/runwire-consumer/` exercise explicit bootstrap binding, host-owned worker/task scope, normal fallback, graceful shutdown, and operational prerequisites. + +The same release gate runs a bounded matched workload and persistent-worker soak. The certification records Runwire/PHP/native-extension resolution, baseline versus integrated RPM, p50/p95/p99 latency, errors, CPU, memory, cache-operation counts, invalidation lag, and maintenance p95 with explicit regression budgets. It is CI release evidence only and is not a claim of production throughput improvement. The soak covers sequential and concurrent request lifetimes, tenant variation, intentional failures, request-owned cancellation and deadlines, deferred writes, memoizer isolation, and bounded retained memory. + +Durable invalidation correctness remains owned by the existing R06/R07/R15 protocol: the Runwire worker invokes the same `ClusterRuntime::consume()`/cursor path and introduces no alternate progress protocol. Existing reversed-commit, replay, process-death, restart, retention-recovery, cursor-scope, and L1-coherence regressions therefore remain authoritative; Runwire-specific tests add worker cancellation/drain, backend failure, unsupported topology, stale runtime replacement, and non-replay fallback behavior. CacheLayer does not select Runwire HTTP adaptive scheduling modes; `FIXED`/other protocol policy remains a host-level Runwire concern and the integration documentation keeps that experiment separate. ## Improvements that require measurement or a separate scope decision From 51fcba79ebac14b7ddb767e80c724a1eea485e9e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 08:52:28 +0600 Subject: [PATCH 380/434] docs(plan): finalize Runwire release-ready status --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 90df7935..645ce631 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -325,7 +325,7 @@ Related source finding: `Cache` excludes only Tiered and Null adapters when calc ## Implementation batches -Batches 1-6 are complete. Batch 7, Runwire 2.1 integration, is now required and blocks final 4.0 release-ready status. +Batches 1-7 are complete. Required Runwire 2.1 integration and its release acceptance gates have passed; CacheLayer 4.0 is release-ready at the completed-plan level. 1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** - [x] Add bounded adversarial subprocess and filesystem/transaction tests. From 8ec37898c81e1742035c0a79a372b3ceaaffb378 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:03:18 +0600 Subject: [PATCH 381/434] :memo: docs(plans): add CacheLayer 4.0 release re-audit report and update plan status - Add release re-audit report documenting nine reproduced findings blocking v4.0.0 :memo: - Update security and correctness plan status to reflect reopened requirements :memo: --- docs/plans/cachelayer-4.0-release-reaudit.md | 170 ++++++++++++++++++ ...achelayer-4.0-security-correctness-plan.md | 14 +- 2 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 docs/plans/cachelayer-4.0-release-reaudit.md diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md new file mode 100644 index 00000000..e3239379 --- /dev/null +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -0,0 +1,170 @@ +# CacheLayer 4.0 release re-audit + +Date: 2026-09-29 + +Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). + +**Decision: hold the 4.0.0 release. Nine reproduced findings remain despite green CI.** No production code or dependency changes were made during this review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). + +The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. + +## Scope and evidence + +The checkout contains 114 production PHP files and 38 test files. Review covered the security/correctness changes since 3.4 across codecs, adapter policy, deferred/atomic operations, local and remote adapter families, counters, locks, memoizers, Node/Cluster storage, outbox ordering, cursor recovery, Runwire lifecycle integration, packaging, release workflows and documentation. Automated checks cover their configured repository scope; targeted adversarial probes below exercise gaps in the existing tests. This is not a claim that every possible defect has been excluded. + +| Check | Result on the audited candidate | +| --- | --- | +| `composer ic:doctor`, `ic:list-config`, `ic:active-config` | Setup resolves; doctor reports healthy. | +| Normal-host `composer ic:tests:details` | **Failed:** Pest aborts during discovery with `APCu must be enabled for CLI tests.` No complete host Pest result. | +| Normal-host `composer ic:release:guard` | **Failed:** same CLI APCu prerequisite. | +| Host static/style checks | Syntax, references, skip-directive scanner, duplicate/comment checks, Pint, PHPCS, PHPStan, Psalm, Rector and configured Deptrac passed. Deptrac reports 774 uncovered dependencies; duplicate report has 27 clone groups / 1,169 lines / 7.13%. These passes do not establish complete architecture coverage. | +| Prepared host subset | PHP 8.5.4 with `apc.enable_cli=1`, isolated Redis 8.10, Valkey 9.1 and Memcached 1.6.45: **324 passed, 2 failed, 1,240 assertions** across 34 selected test files. Both failures require an unprovisioned Scylla Alternator service. MySQL, PostgreSQL, SQL-identity/multi-engine and real MongoDB test files were not selected in this local subset. | +| Core release smoke | Passed on host PHP 8.5.4 with CLI OPcache off and on. | +| Runwire worker example | Passed against the checkout's installed Runwire 2.1. | +| Runwire certification and soak | Both passed using temporary copies that point only their autoload line at this checkout. Soak: 2,048 sequential / 256 concurrent requests; 2,277 validated, 10 intentional failures, 9 cancellations, 8 deadlines; retained-memory growth 6,291,456 bytes. This is installed-checkout evidence, not a fresh consumer install. | +| Composer validation/platform | `composer validate --strict` and `composer check-platform-reqs` passed. | +| Live locked dependency audit | Zero reported advisories; `doctrine/annotations` remains abandoned through development tooling. This does not cover defects in this package or all future consumer resolutions. | +| Local documentation build | Not run: the host lacks Sphinx. Exact-commit CI documentation build passed. | +| Remote CI | Both release workflows succeeded on the exact audited commit; details below. | + +GitHub API verification found: + +- [Security & Standards run 36514617642](https://github.com/infocyph/CacheLayer/actions/runs/36514617642): stable/lowest PHP 8.4/8.5 QA, analysis, benchmarks and clean install succeeded. The conditional Security Report job was skipped; the workflow result is success. +- [Release Verification run 36514617203](https://github.com/infocyph/CacheLayer/actions/runs/36514617203): all 14 jobs succeeded, covering Linux/Windows core smoke, clean consumers, independent PSR contracts, docs, real Redis Cluster, real Scylla CQL and Runwire PHP 8.4/8.5 lowest/stable consumers. + +Those runs validate the existing checks, not the additional failing cases below. Host-application production throughput, the complete framework/router integration and production deployment topology were not independently certified here. + +## Required findings + +P1 denotes a release blocker involving security, durable delivery or core atomicity. P2 denotes a correctness/confidentiality issue that must also be resolved before this release is described as fulfilling its current contracts. These priorities are not CVSS scores. + +### F01 — P1: deferred state defeats one-time atomic consumption + +**Reproduced with signing enabled and `failOpen=false` on memory, File, PHP-files, Redis and Memcached.** Store `x=stored`, queue `x=pending` through `saveDeferred()`, call `atomic()->getAndDelete('x')` twice, then commit. Both consumes return `pending`, and commit restores it to storage. SQLite was a control: it returned `stored`, then a miss, and did not resurrect the key. + +The atomic paths use helpers that overlay deferred state, without consistently consuming/discarding that state. Even a backend miss becomes a hit through `genericMiss()`. See [AbstractCacheAdapter.php](../../src/Cache/Adapter/AbstractCacheAdapter.php) (`genericItemFromRecord`, line 298; `genericMiss`, line 316) and [ArrayCacheAdapter.php](../../src/Cache/Adapter/ArrayCacheAdapter.php) (`atomicGetAndDelete`, line 52), plus the equivalent remote/file paths. Backend atomic deletion alone is insufficient when the facade can repeatedly return an unconsumed local value. + +**Change:** define atomic/deferred interaction explicitly in the existing owners. Atomic reads must not turn a backend miss into an unconsumed pending hit; reconcile or reject pending state under a consistent contract. Review set-if-absent and compare-and-set as well. + +**Acceptance:** a shared suite covers pending-only and persisted-plus-pending values, expiration, failure/retry, signing, repeated consume and subsequent commit across every advertised atomic backend. At most one successful consume, with no later resurrection. + +### F02 — P1: clearing namespace `cachelayer` erases unrelated counters and live locks + +**Reproduced on real Redis.** `Cache::redis('cachelayer', client: $redis)->clear()` scans `cachelayer:*`. This also matches the new `cachelayer:counter::` domain and the default `cachelayer:lock:` domain. A counter in namespace `audit-counter` changed from 5 to missing. A 30-second lock was acquired, the cache was cleared, and a second owner successfully acquired the same lock while the first handle remained live. The default invalidation-stream prefix overlaps too (source finding). + +See [RedisCacheAdapter.php](../../src/Cache/Adapter/RedisCacheAdapter.php), `clear()` line 185; [RedisAtomicCounterStore.php](../../src/Counter/RedisAtomicCounterStore.php), `COUNTER_PREFIX`/`map()`; and [RedisLockProvider.php](../../src/Cache/Lock/RedisLockProvider.php), default prefix. `cachelayer` is a valid public namespace. Valkey shares the Redis adapter implementation. + +**Change:** make clear operate only on explicitly owned data/metadata domains, or use a structurally disjoint physical layout. Moving only the counter prefix is insufficient. Preserve operational state in migration/rollback. + +**Acceptance:** clear every boundary namespace, including `cachelayer`, while counters, locks and invalidation streams exist. Counter values/TTLs survive, held locks exclude a second owner, and stream history remains intact. + +### F03 — P1: closure fingerprinting still permits recursive exhaustion + +**Reproduced in isolated PHP processes with a 32 MB memory limit and an 8-second external timeout.** Both examples terminate with memory exhaustion, exit 255: + +```php +$a = []; +$a['self'] = &$a; +$f = static fn() => $a; +memoize($f); + +// Separate process: +$f = null; +$f = static function () use (&$f) { return 1; }; +memoize($f); +``` + +[CallableFingerprint.php](../../src/Memoize/CallableFingerprint.php), lines 67–97, normalizes closure captures without the traversal check used by `value()`. Closure fingerprints are recorded only after traversing captures, so self/mutually captured closures also recurse before being registered. The callback itself need not execute recursively. Input can be entirely legitimate application state; remote exploitability depends on how an application builds closures/captures. + +**Change:** establish identity before traversing captures or avoid traversing captures when instance identity already determines the contract; otherwise use cycle-safe bounded traversal across both arrays and closure references. + +**Acceptance:** recursive captured arrays, self/mutual closures, deep captures and ordinary callbacks terminate safely without fatal errors or unbounded diagnostic output. Preserve intended memoizer hit behavior. + +### F04 — P1: traversal budget is checked after unbounded queue allocation + +**Reproduced through the codec.** An unsigned native record with 350,000 scalar array entries is **4,439,019 bytes**, below the default 8 MB payload limit. Decoding it exhausts a **128 MB** process at `BoundedValueTraversal.php:48`, before it can reject the value against `MAX_NODES=65,536`. A 100,000-entry / 1,189,019-byte record similarly exhausts a 32 MB process. + +[BoundedValueTraversal.php](../../src/Support/BoundedValueTraversal.php), lines 22–30 and 45–51, checks the budget while popping nodes but appends all children first. The helper therefore allocates a frame for each child before enforcing its limit. Backend attack preconditions are unsigned writable data or a writer authorized to produce a signed oversized graph; signing does not solve writer-side resource bounds. + +**Change:** enforce the remaining node/queue budget before adding children and use traversal storage bounded by the stated limits. Retain independent encoded-byte and decompression bounds. + +**Acceptance:** wide, deep, cyclic and aliased graphs fail safely under explicit process memory/time ceilings on encode and decode; modest supported graphs retain their values. Test both sides of the node limit, not only recursive arrays. + +### F05 — P1: fully lost invalidation history leaves stale local values valid + +**Reproduced with the real PDO implementation in its SQLite testing mode.** Consume an event and persist its cursor, cache `x=stale`, publish a later invalidation for `x`, then remove all retained events before the consumer sees it. `recoverIfRequired()` returns false and the cached stale value remains readable. + +[ClusterRecoveryManager.php](../../src/Cluster/Recovery/ClusterRecoveryManager.php), lines 30–33, returns early whenever the oldest event is null. A previously consumed cursor plus empty history is not proof that no invalidation was missed. Reset histories with IDs behind a stored cursor also need an explicit contract; that related path is identified by source review, not claimed as separately reproduced here. + +**Change:** distinguish a never-used transport from history loss/reset, with a durable epoch/high-watermark or another concrete recovery protocol. Reconcile local state before treating an unprovable cursor as current. Avoid repeated unnecessary clears of known-empty history. + +**Acceptance:** full retention loss, stream deletion/recreation, reset sequence IDs, restart and normal empty startup preserve safety on real Redis and SQL transports, with a documented recovery position. + +### F06 — P2: stale-read cleanup can delete a concurrent replacement + +**Reproduced with real Redis and a deterministic read interleaving.** A Redis subclass performs the actual GET, writes a valid replacement through the actual connection before returning the observed invalid payload, and lets normal adapter code continue. `getItem()` then deletes by key; the next read misses instead of returning the valid replacement. + +[RedisCacheAdapter.php](../../src/Cache/Adapter/RedisCacheAdapter.php), lines 228–238, still uses unconditional DEL in the single-key path. Bulk cleanup already uses `RedisValueGuard`. The facade also unconditionally deletes stale-tag keys in [Cache.php](../../src/Cache/Cache.php), lines 913 and 940; a separate tagged-read interleaving should be a required regression because the same race can bypass adapter-local fixes. + +**Change:** use compare-delete on the exact observed record where supported, or leave physical cleanup to safe bounded maintenance. Audit both adapter and facade cleanup owners. + +**Acceptance:** a valid replacement inserted between read/validation and cleanup survives single, bulk and tagged reads; ordinary stale records still produce misses. Exercise Redis/Valkey and each applicable backend contract. + +### F07 — P2: an unrelated successful operation re-enables stale L1 entries + +**Reproduced with a deterministic L1 failure fixture and the real TieredCacheAdapter.** Promote `x=old`; fail L1 invalidation while writing `x=new`; the adapter correctly fences L1 and reads `new`. Restore L1 deletion, then successfully write unrelated key `y`. The next read of `x` returns `old`. + +[TieredCacheAdapter.php](../../src/Cache/Adapter/TieredCacheAdapter.php), `invalidateSkippedL1()` line 276, sets the global `l1Readable` flag from the latest key's result. `finishL1Write()` and successful individual deletes likewise restore global readability without clearing every stale entry. + +**Change:** keep L1 fenced until a successful full reconciliation/clear, or track invalidity at an appropriate per-key scope. An unrelated successful operation does not establish whole-tier coherence. + +**Acceptance:** after failures for one or several keys, unrelated saves/deletes and partial recoveries cannot expose stale values. Include bulk and multi-tier configurations. + +### F08 — P2: Redis authentication secrets remain exposed in exception traces + +**Reproduced on real Redis using only a synthetic secret.** With `zend.exception_ignore_args=0` and `zend.exception_string_param_max_len=128`, an authentication error exposes `AUDIT_SENTINEL_40` in the `RedisConnection::authenticate(Object(Redis), 'AUDIT_SENTINEL_40')` frame. The public DSN and native Redis auth frames are redacted, but the intermediate helper is not. + +See [RedisConnection.php](../../src/Support/RedisConnection.php), line 44. This affects applications that capture exception arguments or render detailed traces; default trace-display settings may hide it without fixing the stored arguments. + +**Change:** mark the intermediate credential parameter sensitive and trace secret-bearing arrays/parameters throughout authentication and connection failures. Keep error messages and previous exceptions sanitized. + +**Acceptance:** synthetic password and ACL credentials are absent from rendered traces and inspectable unredacted argument values on authentication, connection, parsing and database-selection failures. + +### F09 — P2: flushing one request invalidates another request's memoizer identities + +**Reproduced through Runwire request scopes and separately through two isolated Memoizer instances.** Request A memoizes a retained callback returning incrementing values. Request B creates its memoizer and calls `flush_memoizers()`. Request A calls the same callback again and gets 2 rather than its existing cached 1: observed `[first=1, second=2, executions=2]`. + +[Memoizer.php](../../src/Memoize/Memoizer.php), line 44, and [OnceMemoizer.php](../../src/Memoize/OnceMemoizer.php), line 35, both call the process-global [CallableFingerprint::flush()](../../src/Memoize/CallableFingerprint.php) at line 47. This removes identity mappings still used as keys by other live request-owned memoizers. Request attributes isolate value storage but do not isolate identity resets. + +**Change:** keep object/closure identities stable for their lifetime across independent memoizer flushes, or scope identity ownership consistently with memoizer state. Do not introduce an unbounded strong-reference registry. + +**Acceptance:** flushing/completing/failing one request does not change another request's `memoize`, object `remember`, or `once` behavior. Test interleaved scopes, ordinary isolated instances, and bounded collection of dead objects. + +## Remediation and release gates + +All new findings remain open. Preserve the existing successful fixes and add targeted regressions in the current test layout; do not weaken existing PHPForge or CI checks. + +- [ ] Correct F01–F05 before treating the release as safe for one-time state, shared Redis domains, hostile/recursive values or durable cluster invalidation. +- [ ] Correct F06–F09 and verify their failure/interleaving paths in existing owners. +- [ ] Recheck the related original findings: R01/R08 traversal, R04/R11 atomic/deferred state, R06/R07 recovery, R10 redaction, R13 tier coherence, R14 counter isolation and Batch 7 request isolation. +- [ ] Extend regression coverage across backend implementations instead of testing only new helper classes; F06 demonstrates why a passing helper test does not prove every caller uses it. +- [ ] Run the normal host commands with documented prerequisites, and report prepared service/container evidence separately. Do not turn missing-service failures into skipped/passing assertions. +- [ ] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on the corrected final commit before tagging 4.0.0. +- [ ] Update release notes and plan completion claims only after the reopened cases pass. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate. + +Additional verification improvements: the Runwire certification output currently reports zero `backend_gets`/`backend_sets` despite cache operations because it reads the exported metrics at the wrong shape; the timing denominator also includes warmup while the RPM numerator excludes it. Fix those measurements before using them for quantitative decisions. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac coverage gap also remains visible despite a passing gate. + +## Local reproduction artifacts + +The audit used bounded processes and isolated data; it did not run exhaustion probes inside an application worker. Temporary Redis/Valkey/Memcached containers were created solely for this audit and removed afterward. + +Local artifacts retained for the current workspace session: + +- `/tmp/cachelayer40-probe.php`: closure cycles, traversal, isolated/request memoizer flush and deferred policy probes. +- `/tmp/cachelayer40-wide.php`: larger codec traversal reproduction (run `wide` with `memory_limit=128M`). +- `/tmp/cachelayer40-more.php`: real Redis cleanup/counter probes, tier recovery and empty history reproduction. +- `/tmp/cachelayer40-atomic.php`: signed cross-backend atomic/deferred reproduction. +- `/tmp/cachelayer40-quality.log`, `/tmp/cachelayer40-release-guard.log`, `/tmp/cachelayer40-prepared-tests.log`. +- `/tmp/cachelayer40-certify.log`, `/tmp/cachelayer40-soak.log`, `/tmp/cachelayer40-audit.json`, `/tmp/cachelayer40-ci.json`. + +The network probes require fresh isolated services and their configured loopback ports; never point them at a shared or production Redis database because they deliberately exercise clear and invalidation failure cases. Promote the reproductions into permanent regressions during remediation. Temporary artifacts are not a substitute for committed tests. diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 645ce631..2229d19b 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,10 +1,14 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Implementation complete; Batches 1-7 complete; Runwire 2.1 integration and release validation passed +Status: Reopened by 2026-09-29 release re-audit; 4.0.0 blocked by nine reproduced findings Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** +## Latest release re-audit + +The updated candidate `51fcba79ebac14b7ddb767e80c724a1eea485e9e` passed both configured CI workflows, but adversarial rechecking reproduced nine unresolved findings. **Hold the 4.0.0 release.** See the [release re-audit report](cachelayer-4.0-release-reaudit.md) for evidence, source references, remediation and validation requirements. The batch completion records below are historical implementation evidence and do not close these newly reproduced cases. + ## Implementation tracker Updated: 2026-09-29 @@ -98,7 +102,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi ## Decision -The planned 4.0 security, correctness, backend, migration, core release-gate, and required Runwire 2.1 integration work is implemented and validated. CacheLayer 4.0 is release-ready at the completed-plan level. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. +The planned 4.0 work was implemented and passed its existing CI gates, but the 2026-09-29 re-audit reopens security, atomic/deferred state, recovery, tier coherence and request-isolation requirements. CacheLayer 4.0 is not release-ready until the linked F01–F09 findings are resolved. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. @@ -325,7 +329,7 @@ Related source finding: `Cache` excludes only Tiered and Null adapters when calc ## Implementation batches -Batches 1-7 are complete. Required Runwire 2.1 integration and its release acceptance gates have passed; CacheLayer 4.0 is release-ready at the completed-plan level. +Batches 1-7 have historical implementation and CI completion evidence. Their relevant correctness/security gates are reopened by F01–F09 in the release re-audit; retain the completed work and add focused remediation before tagging. 1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** - [x] Add bounded adversarial subprocess and filesystem/transaction tests. @@ -436,7 +440,7 @@ These are not substitutes for the required fixes: ### Correctness and security -- [x] Every R01–R19 item is resolved with targeted evidence or, for a suspected source finding, disproved with a documented test on the actual affected backend. +- [ ] Revalidate the reopened R01–R19 requirements against F01–F09 in the release re-audit; every finding must be resolved with targeted evidence on the actual affected paths/backends. - [x] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. - [x] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. - [x] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. @@ -471,7 +475,7 @@ git diff --check - [x] Build documentation with warnings as errors and test the examples relevant to changed public contracts. - [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. - [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. -- [x] Require all configured CI checks on the **exact final commit**, including stable/lowest jobs, before creating a release tag. Historical CI does not validate later edits. +- [ ] Require all configured CI checks on the **corrected final commit**, including stable/lowest jobs and the new regression cases, before creating a release tag. Historical CI does not validate later edits. ### Migration and rollback From 2845cb63f45ca84e891e6adef77bca69f6619906 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:11:36 +0600 Subject: [PATCH 382/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/ArrayCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 40061858..57a03add 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -51,6 +51,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->map($key); $record = $this->atomicRecord($key, $mapped); if (!$record instanceof CacheRecord) { From 1f127ef91440422486e3ca3b22f858b24a7f048a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:11:41 +0600 Subject: [PATCH 383/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/PdoAtomicOperations.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/PdoAtomicOperations.php b/src/Cache/Adapter/PdoAtomicOperations.php index aa0214e6..ea40fd41 100644 --- a/src/Cache/Adapter/PdoAtomicOperations.php +++ b/src/Cache/Adapter/PdoAtomicOperations.php @@ -45,6 +45,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + if (!$this->supportsAtomicCache()) { return $this->genericMiss($key); } From d3ec1ff68637c307383c8b74a54094ee27b17d8d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:11:47 +0600 Subject: [PATCH 384/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/WeakMapCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index 8513b74f..cedbcfb1 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -55,6 +55,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $current = $this->getItem($key); if (!$current->isHit()) { return $current; From 8ade10167f6bb36696ada92e6961760983cefba6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:11:54 +0600 Subject: [PATCH 385/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/FileCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index a8100b61..b4e36d07 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -50,6 +50,8 @@ public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInter public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { $record = $this->readLiveRecordUnlocked($key); if (!$record instanceof CacheRecord) { From 77a5421d60938fbac5a4e09276a98c92cd234dad Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:00 +0600 Subject: [PATCH 386/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 6076b292..5b03b861 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -49,6 +49,8 @@ public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInter public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { $record = $this->readLiveRecordUnlocked($key); if (!$record instanceof CacheRecord) { From 20c341cf8044bd69658c0825535e561cb5e4b920 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:08 +0600 Subject: [PATCH 387/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/RedisCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 147014dd..933fb3ea 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -128,6 +128,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $raw = $this->redis->eval(self::GET_AND_DELETE_SCRIPT, [$this->map($key)], 1); if (!is_string($raw)) { return $this->genericMiss($key); From ce367b192c04f84ff048048fa2d2f05b54429447 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:17 +0600 Subject: [PATCH 388/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/RedisClusterAtomicOperations.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/RedisClusterAtomicOperations.php b/src/Cache/Adapter/RedisClusterAtomicOperations.php index 007d9bc9..74e0febe 100644 --- a/src/Cache/Adapter/RedisClusterAtomicOperations.php +++ b/src/Cache/Adapter/RedisClusterAtomicOperations.php @@ -106,6 +106,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $bucket = $this->bucket($key); $result = $this->call( 'eval', From 2062a28552afc0db82ef653973cdd144a66dabee Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:24 +0600 Subject: [PATCH 389/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/MongoDbCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index e405a01e..e7559058 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -95,6 +95,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $document = $this->collection->findOneAndDelete(['_id' => $this->mapData($key)]); $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); $record = is_array($row) ? $this->recordFromRow($key, $row) : null; From 5fc809c9ca736e80c924635f2a7ac713fb49cd5a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:33 +0600 Subject: [PATCH 390/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/MemcachedCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index ca1b5b05..adf8292d 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -87,6 +87,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->mapData($key); $extended = $this->extendedGet($mapped); if ($extended === null || $extended['value'] === self::ATOMIC_TOMBSTONE) { From 4df8e56e1bcf43a75a203fad809ed119ac0f3002 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:12:41 +0600 Subject: [PATCH 391/434] fix(atomic): discard deferred state before consume --- src/Cache/Adapter/SharedMemoryCacheAdapter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index f14d0c7b..4a0d028a 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -85,6 +85,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->map($key); return $this->withExclusiveLock(function () use ($key, $mapped): CacheItemInterface { From f46f6cdc8962a87d72334fdfdf7017daab826b19 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:13:04 +0600 Subject: [PATCH 392/434] fix(redis): isolate clear domains and guard stale cleanup --- src/Cache/Adapter/RedisCacheAdapter.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 933fb3ea..d452b4c1 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -186,13 +186,15 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool public function clear(): bool { - $cursor = null; - do { - $keys = $this->redis->scan($cursor, $this->ns . ':*', 1000); - if ($keys) { - $this->redis->del($keys); - } - } while ($cursor); + foreach ([$this->ns . ':d:*', $this->ns . ':m:*'] as $pattern) { + $cursor = null; + do { + $keys = $this->redis->scan($cursor, $pattern, 1000); + if ($keys) { + $this->redis->del($keys); + } + } while ($cursor); + } $this->deferred = []; return true; @@ -235,7 +237,7 @@ public function getItem(string $key): CacheItem if ($record !== null) { return $this->genericItemFromRecord($key, $record); } - $this->redis->del($this->map($key)); + RedisValueGuard::deleteIfUnchanged($this->redis, $this->map($key), $raw); } return $this->genericMiss($key); From 31a9728095d7636f9fa89a5271e5c939eb238c21 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:13:31 +0600 Subject: [PATCH 393/434] fix(traversal): bound allocation before descent --- src/Support/BoundedValueTraversal.php | 43 +++++++++++---------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index da02eb6f..573cc13c 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -15,40 +15,33 @@ final class BoundedValueTraversal public static function assertSafe(mixed $value): void { - /** @var list}> $stack */ - $stack = [['value' => $value, 'depth' => 0, 'references' => []]]; $nodes = 0; - - while (($frame = array_pop($stack)) !== null) { - self::assertNodeBudget(++$nodes); - $current = $frame['value']; - if (!is_array($current)) { - continue; - } - - self::assertDepth($frame['depth'], $current); - self::appendChildren($stack, $current, $frame['depth'], $frame['references']); - } + self::visit($value, 0, [], $nodes); } /** - * @param list}> $stack - * @param array $current * @param array $references */ - private static function appendChildren( - array &$stack, - array $current, + private static function visit( + mixed $value, int $depth, array $references, + int &$nodes, ): void { - foreach ($current as $key => $item) { - $childReferences = self::childReferences($current, $key, $references); - $stack[] = [ - 'value' => $item, - 'depth' => $depth + 1, - 'references' => $childReferences, - ]; + self::assertNodeBudget(++$nodes); + if (!is_array($value)) { + return; + } + + self::assertDepth($depth, $value); + foreach ($value as $key => $item) { + self::assertNodeBudget($nodes + 1); + self::visit( + $item, + $depth + 1, + self::childReferences($value, $key, $references), + $nodes, + ); } } From a06bb3bdc67bcee02a4ed26dbf10692a8bb800e8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:13:37 +0600 Subject: [PATCH 394/434] fix(memoize): avoid traversing closure captures --- src/Memoize/CallableFingerprint.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Memoize/CallableFingerprint.php b/src/Memoize/CallableFingerprint.php index 94e9386a..dc753376 100644 --- a/src/Memoize/CallableFingerprint.php +++ b/src/Memoize/CallableFingerprint.php @@ -7,7 +7,6 @@ use Closure; use Infocyph\CacheLayer\Support\BoundedValueTraversal; use ReflectionFunction; -use ReflectionReference; use WeakMap; /** @internal */ @@ -72,16 +71,6 @@ private static function closure(Closure $closure): string } $reflection = new ReflectionFunction($closure); - $statics = $reflection->getStaticVariables(); - $captures = []; - foreach ($statics as $name => $value) { - $reference = ReflectionReference::fromArrayElement($statics, $name); - $captures[] = [ - 'name' => $name, - 'reference' => $reference instanceof ReflectionReference ? bin2hex($reference->getId()) : null, - 'value' => self::normalizeValue($value), - ]; - } $bound = $reflection->getClosureThis(); $scope = $reflection->getClosureScopeClass(); $identity = [ @@ -89,7 +78,6 @@ private static function closure(Closure $closure): string 'file' => $reflection->getFileName() ?: 'internal', 'start' => $reflection->getStartLine(), 'end' => $reflection->getEndLine(), - 'captures' => $captures, 'bound' => $bound === null ? null : self::objectIdentity($bound), 'scope' => $scope?->getName(), ]; From 4ce8e51fb6c5203861c93a1d5af10b26ed1b4af5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:13:42 +0600 Subject: [PATCH 395/434] fix(memoize): preserve identities across isolated flushes --- src/Memoize/Memoizer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index 9f3c770e..33ce3626 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -46,7 +46,6 @@ public function flush(): void $this->staticCache = []; $this->objectCache = new WeakMap(); $this->hits = $this->misses = 0; - CallableFingerprint::flush(); } /** From 86fa96de96629685fd42b20904032bc3e211aef5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:13:48 +0600 Subject: [PATCH 396/434] fix(memoize): preserve identities across isolated flushes --- src/Memoize/OnceMemoizer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index be9dde0f..bb38f365 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -36,7 +36,6 @@ public function flush(): void { $this->cache = []; $this->order = []; - CallableFingerprint::flush(); } public function once(callable $callback, int $callerOffset = 0): mixed From a8c03b25cbff58ba67a7dca046fee9e88318901b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:14:39 +0600 Subject: [PATCH 397/434] fix(cache): avoid unsafe tagged stale deletion --- src/Cache/Cache.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 06b145ea..a0f6d542 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -910,8 +910,6 @@ private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterfa if (CacheTagSnapshots::isCurrent($item, $generations)) { return $item; } - $this->backendBool(fn(): bool => $this->adapter->deleteItem($item->getKey())); - return $this->miss($item->getKey()); } @@ -935,10 +933,6 @@ private function validateTagSnapshots(array $items): array return CacheTagSnapshots::missTagged($items, $this->miss(...)); } $validated = CacheTagSnapshots::rejectStale($items, $generations, $this->miss(...)); - $stale = $validated['stale']; - if ($stale !== []) { - $this->backendBool(fn(): bool => $this->adapter->deleteItems($stale)); - } return $validated['items']; } From b8a63752636b69070b85b0b2be5cc3419e99c63b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:14:44 +0600 Subject: [PATCH 398/434] fix(tiered): keep L1 fenced until full reconciliation --- src/Cache/Adapter/TieredCacheAdapter.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 62d0e296..250394b1 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -95,8 +95,8 @@ public function deleteItem(string $key): bool foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItem($key); $deleted = $poolDeleted && $deleted; - if ($index === 0) { - $this->l1Readable = $poolDeleted; + if ($index === 0 && !$poolDeleted) { + $this->l1Readable = false; } } @@ -111,8 +111,8 @@ public function deleteItems(array $keys): bool foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItems($keys); $deleted = $poolDeleted && $deleted; - if ($index === 0) { - $this->l1Readable = $poolDeleted; + if ($index === 0 && !$poolDeleted) { + $this->l1Readable = false; } } @@ -265,8 +265,8 @@ private function extractHits(array $wanted, array $fetched): array private function finishL1Write(bool $written): bool { - if ($written) { - $this->l1Readable = true; + if (!$written) { + $this->l1Readable = false; } return $written; @@ -280,7 +280,9 @@ private function invalidateSkippedL1(array $keys, bool $written): bool } $invalidated = $this->pools[0]->deleteItems($keys); - $this->l1Readable = $invalidated; + if (!$invalidated) { + $this->l1Readable = false; + } return $written && $invalidated; } From 5b1e5f6170d94e18e9d3056cd84fa39cdadf249b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:14:50 +0600 Subject: [PATCH 399/434] fix(redis): redact authentication credentials in traces --- src/Support/RedisConnection.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/RedisConnection.php b/src/Support/RedisConnection.php index 1b82462e..c936244a 100644 --- a/src/Support/RedisConnection.php +++ b/src/Support/RedisConnection.php @@ -41,7 +41,7 @@ public static function connect(#[\SensitiveParameter] string $dsn): \Redis * @param string|array|null $credentials The optional Redis credentials. * @phpstan-param string|array{string, string}|null $credentials */ - private static function authenticate(\Redis $connection, string|array|null $credentials): void + private static function authenticate(\Redis $connection, #[\SensitiveParameter] string|array|null $credentials): void { if ($credentials !== null && !$connection->auth($credentials)) { throw new RuntimeException('Redis-compatible server authentication failed.'); @@ -53,7 +53,7 @@ private static function authenticate(\Redis $connection, string|array|null $cred * @phpstan-param array $parts * @phpstan-return string|array{string, string}|null */ - private static function parseCredentials(array $parts): string|array|null + private static function parseCredentials(#[\SensitiveParameter] array $parts): string|array|null { $pass = $parts['pass'] ?? null; if (!is_string($pass) || $pass === '') { From 435c1d3eab7aa191dc450cfb99d0e6d9119aeeae Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:15:13 +0600 Subject: [PATCH 400/434] fix(cluster): expose retained history upper boundary --- src/Cluster/Transport/InvalidationTransportInterface.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cluster/Transport/InvalidationTransportInterface.php b/src/Cluster/Transport/InvalidationTransportInterface.php index afb67818..7188922d 100644 --- a/src/Cluster/Transport/InvalidationTransportInterface.php +++ b/src/Cluster/Transport/InvalidationTransportInterface.php @@ -13,6 +13,8 @@ public function consumeAfter(string $cluster, ?string $cursor, int $limit): Inva public function isCursorBefore(string $cursor, string $oldestAvailableId): bool; + public function newestAvailableId(string $cluster): ?string; + public function oldestAvailableId(string $cluster): ?string; public function publish(InvalidationEvent $event): string; From 670938963b179a808e1110f64c80ea92aa227401 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:15:19 +0600 Subject: [PATCH 401/434] fix(cluster): expose Redis stream upper boundary --- src/Cluster/Transport/RedisStreamInvalidationTransport.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Cluster/Transport/RedisStreamInvalidationTransport.php b/src/Cluster/Transport/RedisStreamInvalidationTransport.php index 928c2634..5ad655fc 100644 --- a/src/Cluster/Transport/RedisStreamInvalidationTransport.php +++ b/src/Cluster/Transport/RedisStreamInvalidationTransport.php @@ -61,6 +61,11 @@ public function isCursorBefore(string $cursor, string $oldestAvailableId): bool return $this->compareIds($cursor, $oldestAvailableId) < 0; } + public function newestAvailableId(string $cluster): ?string + { + return $this->boundary($cluster, true); + } + public function oldestAvailableId(string $cluster): ?string { return $this->boundary($cluster, false); From d0810a229a6ec362908f7e0b82eb22675fa9a51f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:15:25 +0600 Subject: [PATCH 402/434] fix(cluster): recover from empty or reset invalidation history --- .../Recovery/ClusterRecoveryManager.php | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/Cluster/Recovery/ClusterRecoveryManager.php b/src/Cluster/Recovery/ClusterRecoveryManager.php index 72d6940d..60232303 100644 --- a/src/Cluster/Recovery/ClusterRecoveryManager.php +++ b/src/Cluster/Recovery/ClusterRecoveryManager.php @@ -28,15 +28,34 @@ public function recoverIfRequired(): bool } $cursor = $this->cursorStore->current(); - $oldest = $this->transport->oldestAvailableId($this->cluster); - if ($cursor === null || $oldest === null || !$this->transport->isCursorBefore($cursor, $oldest)) { + if ($cursor === null) { return false; } - $this->clearLocalCache(); - $this->cursorStore->reset($oldest); + $oldest = $this->transport->oldestAvailableId($this->cluster); + if ($oldest === null) { + $this->clearLocalCache(); + $this->cursorStore->reset(null); + + return true; + } + + if ($this->transport->isCursorBefore($cursor, $oldest)) { + $this->clearLocalCache(); + $this->cursorStore->reset($oldest); + + return true; + } + + $newest = $this->transport->newestAvailableId($this->cluster); + if ($newest !== null && $this->transport->isCursorBefore($newest, $cursor)) { + $this->clearLocalCache(); + $this->cursorStore->reset(null); + + return true; + } - return true; + return false; } private function clearLocalCache(): void From 8b9529aa6ff62f33ccc54647821667669744bb1f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:21:13 +0600 Subject: [PATCH 403/434] test(atomic): prevent deferred consume resurrection --- tests/Cache/AtomicCacheCapabilityTest.php | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/Cache/AtomicCacheCapabilityTest.php b/tests/Cache/AtomicCacheCapabilityTest.php index 3d35e00e..a0cad7c5 100644 --- a/tests/Cache/AtomicCacheCapabilityTest.php +++ b/tests/Cache/AtomicCacheCapabilityTest.php @@ -168,3 +168,36 @@ ->and($snapshot['array']['atomic_get_and_delete'] ?? 0)->toBe(1) ->and($snapshot['array']['atomic_get_and_delete_hit'] ?? 0)->toBe(1); }); + + +test('atomic operations reconcile deferred state without repeated consume or resurrection', function () { + $cache = Cache::memory( + 'atomic-deferred-policy', + new CacheOptions(integrityKey: 'atomic-deferred-test-key'), + ); + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull(); + + expect($cache->saveDeferred($cache->getItem('pending-only')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('pending-only', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('pending-only'))->toBeNull(); + + expect($cache->set('consume', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('consume', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('consume'))->toBeNull(); + + expect($cache->saveDeferred($cache->getItem('claim')->set('pending')))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'claimed', 30))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('claim'))->toBe('claimed'); + + expect($cache->set('cas', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('cas')->set('pending')))->toBeTrue() + ->and($atomic->compareAndSet('cas', 'stored', 'updated', 30))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('cas'))->toBe('updated'); +}); From e587e4ed7c4454d6bcb28e0f992d87bbfdde2743 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:21:31 +0600 Subject: [PATCH 404/434] test(atomic): cover deferred consume across expanded backends --- tests/Cache/AtomicBackendExpansionTest.php | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Cache/AtomicBackendExpansionTest.php b/tests/Cache/AtomicBackendExpansionTest.php index 940ea25c..0c069e27 100644 --- a/tests/Cache/AtomicBackendExpansionTest.php +++ b/tests/Cache/AtomicBackendExpansionTest.php @@ -102,3 +102,39 @@ ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() ->and($cache->get('claim'))->toBe('reclaimed'); }); + + +test('file PHP-file SQLite WeakMap and Memcached atomic consume discard deferred overlays', function () use ($cleanupTree, $host, $port) { + $directory = sys_get_temp_dir() . '/cachelayer-atomic-deferred-' . uniqid('', true); + $sqlite = $directory . '/atomic.sqlite'; + mkdir($directory, 0700, true); + + $memcached = new Memcached(); + $memcached->addServer($host, $port); + $memcached->flush(); + + $caches = [ + Cache::weakMap('atomic-deferred-weak'), + Cache::file('atomic-deferred-file', $directory . '/file'), + Cache::phpFiles('atomic-deferred-php', $directory . '/php'), + Cache::sqlite('atomic-deferred-sqlite', $sqlite), + Cache::memcached('atomic-deferred-memcached', [[$host, $port, 0]], $memcached), + ]; + + try { + foreach ($caches as $index => $cache) { + $key = 'consume-' . $index; + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($cache->set($key, 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem($key)->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete($key, 'missing'))->toBe('stored') + ->and($atomic->getAndDelete($key, 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get($key))->toBeNull(); + } + } finally { + unset($caches); + $cleanupTree($directory); + } +}); From 52b518e3e6ee05f15b54cdc92fb54bd979aad05b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:21:52 +0600 Subject: [PATCH 405/434] test(atomic): cover deferred consume reconciliation --- tests/Cache/RedisCachePoolTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 7c68e835..5819d62e 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -430,3 +430,15 @@ expect($counters->get('short-window'))->toBeNull(); }); + + +test('Redis atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); From 7d834097541a797c73505a86e88f8d4728bdab3f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:21:58 +0600 Subject: [PATCH 406/434] test(atomic): cover deferred consume reconciliation --- tests/Cache/ValkeyCachePoolTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 3637af28..b7a24d62 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -163,3 +163,15 @@ expect($counters->get('short-window'))->toBeNull(); }); + + +test('Valkey atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); From febf920add23c0ce0698b831d64d087a658361d4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:22:03 +0600 Subject: [PATCH 407/434] test(atomic): cover deferred consume reconciliation --- tests/Cache/MongoDbCachePoolTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php index d2beb1fa..6436826e 100644 --- a/tests/Cache/MongoDbCachePoolTest.php +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -249,3 +249,15 @@ public function getMatchedCount(): int expect($atomic->setIfAbsent('claim', 'new', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('new'); }); + + +test('mongodb atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); From bc4cea35afbd6def1cd084c85da8ec0101ddc09b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:22:11 +0600 Subject: [PATCH 408/434] test(atomic): cover deferred consume reconciliation --- tests/Cache/RedisClusterCachePoolTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 083e493a..8923707d 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -396,3 +396,15 @@ private function prune(string $key): void expect($this->cache->get('generation-race'))->toBeNull() ->and($this->cluster->get($generation))->toBe('malformed-generation'); }); + + +test('redis cluster atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); From 39324400686187966953ecd2f09c3eb731219f58 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:22:17 +0600 Subject: [PATCH 409/434] test(atomic): cover deferred consume reconciliation --- tests/Cache/SharedMemoryCachePoolTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 79fbb342..58917626 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -179,3 +179,18 @@ $cache->clear(); }); + + +test('shared memory atomic consume discards deferred overlays without resurrection', function () { + $cache = Cache::sharedMemory('shm-deferred-consume'); + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($cache->set('state', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('state')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('state', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('state', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('state'))->toBeNull(); + + $cache->clear(); +}); From 6a90500cb6c68f0872f5fe9f28bcf50a3382f51d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:23:05 +0600 Subject: [PATCH 410/434] test(redis): protect operational domains and concurrent replacements --- tests/Cache/RedisCachePoolTest.php | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 5819d62e..8ec187fb 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -442,3 +442,59 @@ ->and($this->cache->commit())->toBeTrue() ->and($this->cache->get('deferred-consume'))->toBeNull(); }); + + +test('Redis clear in boundary namespace preserves counters locks and invalidation streams', function () { + $cache = Cache::redis('cachelayer', client: $this->redisClient); + $counters = AtomicCounters::redis('audit-counter', client: $this->redisClient); + $locks = new \Infocyph\CacheLayer\Cache\Lock\RedisLockProvider($this->redisClient); + $transport = new \Infocyph\CacheLayer\Cluster\Transport\RedisStreamInvalidationTransport($this->redisClient); + + expect($cache->set('ordinary', 'value'))->toBeTrue() + ->and($counters->increment('window', 5, 30)->value)->toBe(5); + + $held = $locks->acquire('boundary-lock', 0.0, 30.0); + expect($held)->not->toBeNull(); + + $eventId = $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'clear-boundary', + 'application', + 'product.42', + 'writer', + ), + ); + expect($eventId)->not->toBe(''); + + expect($cache->clear())->toBeTrue() + ->and($cache->get('ordinary'))->toBeNull() + ->and($counters->get('window'))->toBe(5) + ->and($locks->acquire('boundary-lock', 0.0, 30.0))->toBeNull() + ->and($this->redisClient->xLen('cachelayer:invalidation:clear-boundary'))->toBe(1); + + $locks->release($held); +}); + +test('Redis compare-safe cleanup preserves a concurrent replacement', function () { + $key = 'cachelayer:guard:stale-read'; + $this->redisClient->set($key, 'observed-invalid'); + $observed = $this->redisClient->get($key); + expect($observed)->toBe('observed-invalid'); + + $this->redisClient->set($key, 'replacement'); + + expect(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, (string) $observed))->toBeFalse() + ->and($this->redisClient->get($key))->toBe('replacement'); +}); + +test('tag-stale Redis reads return misses without physically deleting an observed record', function () { + expect($this->cache->setTagged('tagged-stale', 'old', ['products'], 30))->toBeTrue(); + $physical = 'tests:d:tagged-stale'; + expect($this->redisClient->exists($physical))->toBe(1); + + expect($this->cache->invalidateTag('products'))->toBeTrue() + ->and($this->cache->get('tagged-stale'))->toBeNull() + ->and($this->redisClient->exists($physical))->toBe(1) + ->and($this->cache->setTagged('tagged-stale', 'fresh', ['products'], 30))->toBeTrue() + ->and($this->cache->get('tagged-stale'))->toBe('fresh'); +}); From 4ccf0f1e1da5a99a882ba8b37fed425875276565 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:23:34 +0600 Subject: [PATCH 411/434] test(cluster): cover complete history loss and reset --- tests/Cluster/ClusterCacheTest.php | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index b72a7abe..94ab9d3b 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -486,3 +486,51 @@ ->and($cursor->current())->toBe('1') ->and(array_keys($adapter->rejectedOperations))->toBe(['clear']); }); + + +test('recovery clears stale local state when retained invalidation history disappears completely', function () { + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'first', 'writer'), + ); + expect($this->nodeB->consume())->toBe(1); + + $this->nodeB->cache()->set('stale-after-loss', 'value', 300); + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'stale-after-loss', 'writer'), + ); + $this->transport->discardBefore('test-cluster', PHP_INT_MAX); + + expect($this->nodeB->recoverIfRequired())->toBeTrue() + ->and($this->nodeB->cache()->get('stale-after-loss'))->toBeNull() + ->and($this->nodeB->status()->cursor)->toBeNull() + ->and($this->nodeB->recoverIfRequired())->toBeFalse(); +}); + +test('recovery clears and replays when a recreated transport restarts behind the stored cursor', function () { + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'one', 'writer'), + ); + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'two', 'writer'), + ); + expect($this->nodeB->consume(2))->toBe(2) + ->and($this->nodeB->status()->cursor)->toBe('2'); + + $this->nodeB->cache()->set('reset-key', 'stale', 300); + + $replacementTransport = new InMemoryInvalidationTransport(); + $replacementTransport->publish( + InvalidationEvent::key('test-cluster', 'application', 'reset-key', 'writer'), + ); + $replacementRuntime = ClusterCache::create( + $this->nodeConfigB, + $this->clusterConfigB, + $replacementTransport, + ); + + expect($replacementRuntime->recoverIfRequired())->toBeTrue() + ->and($replacementRuntime->cache()->get('reset-key'))->toBeNull() + ->and($replacementRuntime->status()->cursor)->toBeNull() + ->and($replacementRuntime->consume())->toBe(1) + ->and($replacementRuntime->status()->cursor)->toBe('1'); +}); From 0222c965fc358b827073f26e203f960aec8490e4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:23:54 +0600 Subject: [PATCH 412/434] test(tiered): keep failed L1 globally fenced --- tests/Cache/TieredCachePoolTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Cache/TieredCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php index 4417fe8c..fab6239f 100644 --- a/tests/Cache/TieredCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -111,3 +111,27 @@ ['01', 'value-01'], ]); }); + + +test('tiered L1 remains fenced after unrelated successful writes until full clear', function () { + $l1 = new ArrayCacheAdapter('fenced-l1'); + $l2 = new ArrayCacheAdapter('fenced-l2'); + $cache = Cache::tiered([$l1, $l2]); + $adapter = (new ReflectionClass($cache))->getProperty('adapter')->getValue($cache); + + expect($cache->set('x', 'old'))->toBeTrue(); + $l2->save($l2->getItem('x')->set('new')); + + $readable = new ReflectionProperty($adapter, 'l1Readable'); + $readable->setValue($adapter, false); + + expect($cache->get('x'))->toBe('new') + ->and($cache->set('unrelated', 'value'))->toBeTrue() + ->and($readable->getValue($adapter))->toBeFalse() + ->and($cache->get('x'))->toBe('new') + ->and($cache->delete('unrelated'))->toBeTrue() + ->and($readable->getValue($adapter))->toBeFalse() + ->and($cache->get('x'))->toBe('new') + ->and($cache->clear())->toBeTrue() + ->and($readable->getValue($adapter))->toBeTrue(); +}); From 5f09a82465a3e916685d1ccf00efb471bde507a2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:24:54 +0600 Subject: [PATCH 413/434] test(traversal): cover wide deep and cyclic graphs --- tests/Cache/CachePayloadCodecSecurityTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 5a685ef0..70a2bfea 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -126,3 +126,44 @@ expect($codec->decode('imx-gz:payload'))->toBeNull() ->and($codec->decode('imx-sig-v1:payload'))->toBeNull(); }); + + +test('payload traversal rejects wide graphs before exceeding the node budget', function () { + $codec = new CachePayloadCodec(new CacheOptions(maxPayloadBytes: 8 * 1024 * 1024)); + $supported = array_fill(0, 65_534, 'x'); + $tooWide = array_fill(0, 65_536, 'x'); + + $blob = $codec->encode($supported, null); + expect($codec->decode($blob)?->value)->toBe($supported) + ->and(fn() => $codec->encode($tooWide, null)) + ->toThrow(InvalidArgumentException::class, 'traversal budget'); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => $tooWide, + 'expires' => null, + 'tags' => [], + 'namespace' => null, + ]); + + expect(strlen($serialized))->toBeLessThan(8 * 1024 * 1024) + ->and($codec->decode('cl2:' . $serialized))->toBeNull(); +}); + +test('payload traversal rejects recursive and over-deep graphs safely', function () { + $codec = new CachePayloadCodec(); + $recursive = []; + $recursive['self'] = &$recursive; + + expect(fn() => $codec->encode($recursive, null)) + ->toThrow(InvalidArgumentException::class, 'Recursive array references'); + + $deep = 'leaf'; + for ($depth = 0; $depth < 130; ++$depth) { + $deep = [$deep]; + } + + expect(fn() => $codec->encode($deep, null)) + ->toThrow(InvalidArgumentException::class, 'nesting depth'); +}); From 408c7703837af809f3dbf917019cebf2f71d74c4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:24:59 +0600 Subject: [PATCH 414/434] test(memoize): cover recursive captures and isolated identity flush --- tests/Memoize/MemoizeTest.php | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 1fdbbb0f..8b775882 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -198,3 +198,47 @@ public function next(): int expect($reference->get())->toBeNull(); }); + + +it('memoizer fingerprints recursive closure captures without traversing their graphs', function () { + $recursive = []; + $recursive['self'] = &$recursive; + $arrayClosure = static fn(): int => count($recursive); + + $selfClosure = null; + $selfClosure = static function () use (&$selfClosure): int { + return 7; + }; + + $left = null; + $right = null; + $left = static function () use (&$right): int { + return 11; + }; + $right = static function () use (&$left): int { + return 13; + }; + + expect(memoize($arrayClosure))->toBe(1) + ->and(memoize($arrayClosure))->toBe(1) + ->and(memoize($selfClosure))->toBe(7) + ->and(memoize($selfClosure))->toBe(7) + ->and(memoize($left))->toBe(11) + ->and(memoize($right))->toBe(13); +}); + +it('flushing an isolated memoizer does not invalidate another memoizer identity map', function () { + $first = Memoizer::isolated(); + $second = Memoizer::isolated(); + $runs = 0; + $callback = static function () use (&$runs): int { + return ++$runs; + }; + + expect($first->get($callback))->toBe(1); + $second->get(static fn(): string => 'other'); + $second->flush(); + + expect($first->get($callback))->toBe(1) + ->and($runs)->toBe(1); +}); From c5f3c4d906561b3d228195cea32fdeed01c7e28a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:25:06 +0600 Subject: [PATCH 415/434] test(runwire): isolate memoizer identities across request flushes --- tests/Integration/RunwireIntegrationTest.php | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php index 37dcb946..28078c5c 100644 --- a/tests/Integration/RunwireIntegrationTest.php +++ b/tests/Integration/RunwireIntegrationTest.php @@ -233,3 +233,37 @@ static function (CoroutineScope $scope) use ($request): array { expect($reference->get())->toBeNull(); } }); + + +it('flushing one Runwire request does not change another live request memoizer identity', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $requestA = RequestContext::create($runtime); + $requestB = RequestContext::create($runtime); + $runs = 0; + $callback = static function () use (&$runs): int { + return ++$runs; + }; + + $result = RunwireIntegration::share( + $requestA, + null, + static function () use ($requestB, $callback, &$runs): array { + $first = memoize($callback); + + RunwireIntegration::share( + $requestB, + null, + static function (): void { + memoize(); + once(static fn(): string => 'request-b'); + flush_memoizers(); + }, + ); + + return [$first, memoize($callback), $runs]; + }, + ); + + expect($result)->toBe([1, 1, 1]); +}); From a4a825743cb429ea9891c2bbee1b2fc53006b8b1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:26:20 +0600 Subject: [PATCH 416/434] test(cluster): reproduce complete PDO history loss --- tests/Cluster/ClusterCacheTest.php | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 94ab9d3b..446c70c5 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -534,3 +534,34 @@ ->and($replacementRuntime->consume())->toBe(1) ->and($replacementRuntime->status()->cursor)->toBe('1'); }); + + +test('PDO recovery clears stale local state after complete retained-history loss', function () { + $connection = new PDO('sqlite:' . $this->clusterDirectory . '/history-loss.sqlite'); + $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); + $node = new NodeCacheConfig( + $this->clusterDirectory . '/history-loss-node.sqlite', + 'application', + apcuEnabled: false, + ); + $cluster = new ClusterCacheConfig('pdo-history-loss', 'consumer', 'pdo-history-loss'); + $runtime = ClusterCache::create($node, $cluster, $transport); + + $transport->publish( + InvalidationEvent::key('pdo-history-loss', 'application', 'first', 'writer'), + ); + expect($runtime->consume())->toBe(1); + + $runtime->cache()->set('stale', 'value', 300); + $transport->publish( + InvalidationEvent::key('pdo-history-loss', 'application', 'stale', 'writer'), + ); + $connection->exec( + "DELETE FROM " . PdoInvalidationSchema::EVENT_TABLE . " WHERE cluster_name = 'pdo-history-loss'", + ); + + expect($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); From 84c51b2552b272e77413554c64a63fb3a0d14061 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:26:25 +0600 Subject: [PATCH 417/434] test(cluster): reproduce complete Redis history loss --- tests/Cache/RedisCachePoolTest.php | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 8ec187fb..7c949c95 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -498,3 +498,64 @@ ->and($this->cache->setTagged('tagged-stale', 'fresh', ['products'], 30))->toBeTrue() ->and($this->cache->get('tagged-stale'))->toBe('fresh'); }); + + +test('Redis Stream recovery clears stale local state after complete history loss', function () { + $directory = sys_get_temp_dir() . '/cachelayer-redis-history-' . uniqid('', true); + mkdir($directory, 0700, true); + + try { + $transport = new \Infocyph\CacheLayer\Cluster\Transport\RedisStreamInvalidationTransport( + $this->redisClient, + 'cachelayer:history:', + ); + $runtime = \Infocyph\CacheLayer\Cluster\ClusterCache::create( + new \Infocyph\CacheLayer\Node\NodeCacheConfig( + $directory . '/node.sqlite', + 'application', + apcuEnabled: false, + ), + new \Infocyph\CacheLayer\Cluster\ClusterCacheConfig( + 'redis-history-loss', + 'consumer', + 'redis-history-loss', + ), + $transport, + ); + + $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'redis-history-loss', + 'application', + 'first', + 'writer', + ), + ); + expect($runtime->consume())->toBe(1); + + $runtime->cache()->set('stale', 'value', 300); + $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'redis-history-loss', + 'application', + 'stale', + 'writer', + ), + ); + $this->redisClient->del('cachelayer:history:redis-history-loss'); + + expect($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); + } finally { + if (is_dir($directory)) { + foreach (glob($directory . '/*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($directory); + } + } +}); From ed201cfcef2038342d2991db89143f25af94ed43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:26:56 +0600 Subject: [PATCH 418/434] test(redis): require sensitive auth helper parameters --- tests/Support/RedisConnectionTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Support/RedisConnectionTest.php b/tests/Support/RedisConnectionTest.php index 699b1731..0dd68b91 100644 --- a/tests/Support/RedisConnectionTest.php +++ b/tests/Support/RedisConnectionTest.php @@ -14,3 +14,14 @@ 'invalid database' => 'redis://127.0.0.1/database', 'invalid port' => 'redis://127.0.0.1:0', ]); + + +test('redis authentication helper marks every credential-bearing parameter sensitive', function () { + $authenticate = new ReflectionMethod(RedisConnection::class, 'authenticate'); + $credentials = $authenticate->getParameters()[1]; + $parseCredentials = new ReflectionMethod(RedisConnection::class, 'parseCredentials'); + $parts = $parseCredentials->getParameters()[0]; + + expect($credentials->getAttributes(SensitiveParameter::class))->toHaveCount(1) + ->and($parts->getAttributes(SensitiveParameter::class))->toHaveCount(1); +}); From bbca5f2c03c07b16f758e2e45874372e9642bcbb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:27:02 +0600 Subject: [PATCH 419/434] test(redis): keep auth secrets out of traces --- tests/Cache/RedisCachePoolTest.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index 7c949c95..c6d6ab45 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -559,3 +559,30 @@ } } }); + + +test('Redis authentication failures do not expose supplied passwords in exception traces', function () use ($redisHost, $redisPort) { + $secret = 'AUDIT_SENTINEL_40'; + $previousIgnoreArgs = ini_get('zend.exception_ignore_args'); + $previousMaxLen = ini_get('zend.exception_string_param_max_len'); + ini_set('zend.exception_ignore_args', '0'); + ini_set('zend.exception_string_param_max_len', '128'); + + try { + try { + \Infocyph\CacheLayer\Support\RedisConnection::connect( + sprintf('redis://:%s@%s:%d', rawurlencode($secret), $redisHost, $redisPort), + ); + test()->fail('Expected Redis authentication with the synthetic secret to fail.'); + } catch (Throwable $failure) { + expect((string) $failure)->not->toContain($secret); + } + } finally { + if (is_string($previousIgnoreArgs)) { + ini_set('zend.exception_ignore_args', $previousIgnoreArgs); + } + if (is_string($previousMaxLen)) { + ini_set('zend.exception_string_param_max_len', $previousMaxLen); + } + } +}); From 74c3663f527fdac0a79c25b6747e65e0dd06c657 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:27:18 +0600 Subject: [PATCH 420/434] test(valkey): preserve operational domains on clear --- tests/Cache/ValkeyCachePoolTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index b7a24d62..ca8daa0c 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -175,3 +175,23 @@ ->and($this->cache->commit())->toBeTrue() ->and($this->cache->get('deferred-consume'))->toBeNull(); }); + + +test('Valkey clear in boundary namespace preserves counter and lock domains', function () { + $cache = Cache::valkey('cachelayer', client: $this->valkeyClient); + $counters = AtomicCounters::valkey('audit-counter', client: $this->valkeyClient); + $locks = new \Infocyph\CacheLayer\Cache\Lock\RedisLockProvider($this->valkeyClient); + + expect($cache->set('ordinary', 'value'))->toBeTrue() + ->and($counters->increment('window', 5, 30)->value)->toBe(5); + + $held = $locks->acquire('boundary-lock', 0.0, 30.0); + expect($held)->not->toBeNull(); + + expect($cache->clear())->toBeTrue() + ->and($cache->get('ordinary'))->toBeNull() + ->and($counters->get('window'))->toBe(5) + ->and($locks->acquire('boundary-lock', 0.0, 30.0))->toBeNull(); + + $locks->release($held); +}); From bf0068a7540a48d9ae6cae01aa0e70b13d8aa4b7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:30:54 +0600 Subject: [PATCH 421/434] refactor(tiered): keep monotonic L1 fence branch-light --- src/Cache/Adapter/TieredCacheAdapter.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 250394b1..3a6d7534 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -95,8 +95,8 @@ public function deleteItem(string $key): bool foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItem($key); $deleted = $poolDeleted && $deleted; - if ($index === 0 && !$poolDeleted) { - $this->l1Readable = false; + if ($index === 0) { + $this->l1Readable = $this->l1Readable && $poolDeleted; } } @@ -111,8 +111,8 @@ public function deleteItems(array $keys): bool foreach ($this->pools as $index => $pool) { $poolDeleted = $pool->deleteItems($keys); $deleted = $poolDeleted && $deleted; - if ($index === 0 && !$poolDeleted) { - $this->l1Readable = false; + if ($index === 0) { + $this->l1Readable = $this->l1Readable && $poolDeleted; } } @@ -265,9 +265,7 @@ private function extractHits(array $wanted, array $fetched): array private function finishL1Write(bool $written): bool { - if (!$written) { - $this->l1Readable = false; - } + $this->l1Readable = $this->l1Readable && $written; return $written; } @@ -280,9 +278,7 @@ private function invalidateSkippedL1(array $keys, bool $written): bool } $invalidated = $this->pools[0]->deleteItems($keys); - if (!$invalidated) { - $this->l1Readable = false; - } + $this->l1Readable = $this->l1Readable && $invalidated; return $written && $invalidated; } From ac5594370c0020ff14be1817bc227c02fb5b117b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 10:33:36 +0600 Subject: [PATCH 422/434] refactor(tiered): reduce fence complexity under PHPForge --- src/Cache/Adapter/TieredCacheAdapter.php | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 3a6d7534..9ce07255 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -91,13 +91,11 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { $this->discardDeferredKey($key); - $deleted = true; - foreach ($this->pools as $index => $pool) { - $poolDeleted = $pool->deleteItem($key); - $deleted = $poolDeleted && $deleted; - if ($index === 0) { - $this->l1Readable = $this->l1Readable && $poolDeleted; - } + $deleted = $this->pools[0]->deleteItem($key); + $this->l1Readable = $this->l1Readable && $deleted; + + foreach (array_slice($this->pools, 1) as $pool) { + $deleted = $pool->deleteItem($key) && $deleted; } return $deleted; @@ -107,13 +105,11 @@ public function deleteItem(string $key): bool public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); - $deleted = true; - foreach ($this->pools as $index => $pool) { - $poolDeleted = $pool->deleteItems($keys); - $deleted = $poolDeleted && $deleted; - if ($index === 0) { - $this->l1Readable = $this->l1Readable && $poolDeleted; - } + $deleted = $this->pools[0]->deleteItems($keys); + $this->l1Readable = $this->l1Readable && $deleted; + + foreach (array_slice($this->pools, 1) as $pool) { + $deleted = $pool->deleteItems($keys) && $deleted; } return $deleted; From c946ad17959d8050be4dd220cd6b2ea064fa1abc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:02:48 +0600 Subject: [PATCH 423/434] fix(qa): close re-audit regression gates --- src/Cache/Adapter/CachePayloadCodec.php | 4 +- src/Cache/Cache.php | 1 + src/Support/BoundedValueTraversal.php | 52 +++++++++---------- tests/Cache/ArchitectureHardeningTest.php | 2 +- .../RunwireWorkerIntegrationTest.php | 7 +++ 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index ccc8dff0..c170d0ac 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -71,7 +71,9 @@ public function decode( try { $decoded = $this->unserializeNative($serialized); - BoundedValueTraversal::assertSafe($decoded); + if (is_array($decoded) && array_key_exists('value', $decoded)) { + BoundedValueTraversal::assertSafe($decoded['value']); + } } catch (Throwable) { return null; } diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index a0f6d542..047c0953 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -910,6 +910,7 @@ private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterfa if (CacheTagSnapshots::isCurrent($item, $generations)) { return $item; } + return $this->miss($item->getKey()); } diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index 573cc13c..2e5276f3 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -19,32 +19,6 @@ public static function assertSafe(mixed $value): void self::visit($value, 0, [], $nodes); } - /** - * @param array $references - */ - private static function visit( - mixed $value, - int $depth, - array $references, - int &$nodes, - ): void { - self::assertNodeBudget(++$nodes); - if (!is_array($value)) { - return; - } - - self::assertDepth($depth, $value); - foreach ($value as $key => $item) { - self::assertNodeBudget($nodes + 1); - self::visit( - $item, - $depth + 1, - self::childReferences($value, $key, $references), - $nodes, - ); - } - } - /** @param array $value */ private static function assertDepth(int $depth, array $value): void { @@ -80,4 +54,30 @@ private static function childReferences(array $current, int|string $key, array $ return $references; } + + /** + * @param array $references + */ + private static function visit( + mixed $value, + int $depth, + array $references, + int &$nodes, + ): void { + self::assertNodeBudget(++$nodes); + if (!is_array($value)) { + return; + } + + self::assertDepth($depth, $value); + foreach ($value as $key => $item) { + self::assertNodeBudget($nodes + 1); + self::visit( + $item, + $depth + 1, + self::childReferences($value, $key, $references), + $nodes, + ); + } + } } diff --git a/tests/Cache/ArchitectureHardeningTest.php b/tests/Cache/ArchitectureHardeningTest.php index 4e204278..49559d4b 100644 --- a/tests/Cache/ArchitectureHardeningTest.php +++ b/tests/Cache/ArchitectureHardeningTest.php @@ -198,7 +198,7 @@ public function resetOperationCounts(): void $adapter->resetOperationCounts(); expect($cache->getMultiple(['one', 'two']))->toBe(['one' => null, 'two' => null]) ->and($adapter->tagFetchBatches)->toBe(1) - ->and($adapter->deleteBatches)->toBe(1); + ->and($adapter->deleteBatches)->toBe(0); }); test('cache items can only be persisted by their exact owning pool', function () { diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php index f67fbd0a..2d9b819c 100644 --- a/tests/Integration/RunwireWorkerIntegrationTest.php +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -281,6 +281,13 @@ public function isCursorBefore(string $cursor, string $oldestAvailableId): bool return false; } + public function newestAvailableId(string $cluster): ?string + { + unset($cluster); + + return null; + } + public function oldestAvailableId(string $cluster): ?string { unset($cluster); From 306173ec8aaa84431618286d5623f73590d5cb90 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:03:32 +0600 Subject: [PATCH 424/434] fix(codec): bound value and tag graphs independently --- src/Cache/Adapter/CachePayloadCodec.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index c170d0ac..3fb73a9f 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -71,8 +71,13 @@ public function decode( try { $decoded = $this->unserializeNative($serialized); - if (is_array($decoded) && array_key_exists('value', $decoded)) { - BoundedValueTraversal::assertSafe($decoded['value']); + if (is_array($decoded)) { + if (array_key_exists('value', $decoded)) { + BoundedValueTraversal::assertSafe($decoded['value']); + } + if (array_key_exists('tags', $decoded)) { + BoundedValueTraversal::assertSafe($decoded['tags']); + } } } catch (Throwable) { return null; From efb52d127985acf9ce93de2e7b9ecb6dcf7b072f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:04:18 +0600 Subject: [PATCH 425/434] docs(plan): track re-audit remediation progress --- docs/plans/cachelayer-4.0-release-reaudit.md | 30 ++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md index e3239379..c64ea47c 100644 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -4,7 +4,7 @@ Date: 2026-09-29 Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). -**Decision: hold the 4.0.0 release. Nine reproduced findings remain despite green CI.** No production code or dependency changes were made during this review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). +**Decision: hold the 4.0.0 release pending remediation QA.** The nine findings were reproduced on the audited commit; remediation is now implemented on `feature/improvements` and must pass exact-head verification before release. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. @@ -142,15 +142,29 @@ See [RedisConnection.php](../../src/Support/RedisConnection.php), line 44. This ## Remediation and release gates -All new findings remain open. Preserve the existing successful fixes and add targeted regressions in the current test layout; do not weaken existing PHPForge or CI checks. - -- [ ] Correct F01–F05 before treating the release as safe for one-time state, shared Redis domains, hostile/recursive values or durable cluster invalidation. -- [ ] Correct F06–F09 and verify their failure/interleaving paths in existing owners. -- [ ] Recheck the related original findings: R01/R08 traversal, R04/R11 atomic/deferred state, R06/R07 recovery, R10 redaction, R13 tier coherence, R14 counter isolation and Batch 7 request isolation. -- [ ] Extend regression coverage across backend implementations instead of testing only new helper classes; F06 demonstrates why a passing helper test does not prove every caller uses it. +Remediation implementation is present on the working branch. The release remains blocked until the corrected exact head passes the full configured gates. + +| Finding | Remediation status | Evidence | +| --- | --- | --- | +| F01 | **Implemented; QA pending** | Atomic consume paths discard deferred overlays; cross-backend regressions cover repeated consume and no resurrection. | +| F02 | **Implemented; QA pending** | Redis/Valkey clear is restricted to cache data/metadata domains; boundary tests preserve counters, locks and invalidation streams. | +| F03 | **Implemented; QA pending** | Closure fingerprints no longer traverse captures; recursive/self-capture regressions were added. | +| F04 | **Implemented; QA pending** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | +| F05 | **Implemented; QA pending** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | +| F06 | **Implemented; QA pending** | Redis stale cleanup uses compare-delete and facade tag validation no longer performs unsafe physical deletion. | +| F07 | **Implemented; QA pending** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | +| F08 | **Implemented; QA pending** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | +| F09 | **Implemented; QA pending** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | + +Current remediation head before tracker updates: `306173ec8aaa84431618286d5623f73590d5cb90`. The previous QA run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues; those were corrected in the remediation QA-closure commits. + +- [x] Correct F01–F05 in the affected production owners and add targeted regressions. +- [x] Correct F06–F09 and add their failure/interleaving regressions. +- [x] Recheck the related original findings in code/tests: R01/R08 traversal, R04/R11 atomic/deferred state, R06/R07 recovery, R10 redaction, R13 tier coherence, R14 counter isolation and Batch 7 request isolation. +- [x] Extend regression coverage across affected backend implementations rather than testing only helper classes. - [ ] Run the normal host commands with documented prerequisites, and report prepared service/container evidence separately. Do not turn missing-service failures into skipped/passing assertions. - [ ] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on the corrected final commit before tagging 4.0.0. -- [ ] Update release notes and plan completion claims only after the reopened cases pass. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate. +- [ ] Update release notes and final completion claims only after the reopened cases pass exact-head verification. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate. Additional verification improvements: the Runwire certification output currently reports zero `backend_gets`/`backend_sets` despite cache operations because it reads the exported metrics at the wrong shape; the timing denominator also includes warmup while the RPM numerator excludes it. Fix those measurements before using them for quantitative decisions. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac coverage gap also remains visible despite a passing gate. From 97957893ab1459e365526dab2b913131021489fe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:04:22 +0600 Subject: [PATCH 426/434] docs(plan): add re-audit remediation batch --- docs/plans/cachelayer-4.0-security-correctness-plan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 2229d19b..8bfe9067 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,13 +1,13 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Reopened by 2026-09-29 release re-audit; 4.0.0 blocked by nine reproduced findings +Status: Remediation implemented; 4.0.0 blocked on corrected exact-head QA Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** ## Latest release re-audit -The updated candidate `51fcba79ebac14b7ddb767e80c724a1eea485e9e` passed both configured CI workflows, but adversarial rechecking reproduced nine unresolved findings. **Hold the 4.0.0 release.** See the [release re-audit report](cachelayer-4.0-release-reaudit.md) for evidence, source references, remediation and validation requirements. The batch completion records below are historical implementation evidence and do not close these newly reproduced cases. +The candidate `51fcba79ebac14b7ddb767e80c724a1eea485e9e` passed both configured CI workflows, but adversarial rechecking reproduced nine findings. Remediation for F01–F09 is now implemented on `feature/improvements`; **hold the 4.0.0 release until the corrected exact head passes the full stable/lowest and release-verification gates.** See the [release re-audit report](cachelayer-4.0-release-reaudit.md) for the live remediation tracker. ## Implementation tracker @@ -24,6 +24,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | | 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | | 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | +| 8 — Release re-audit remediation | F01–F09 | **Implementation complete; QA pending** | Remediation and focused regression fixes are on `feature/improvements`; latest pre-tracker code head `306173ec8aaa84431618286d5623f73590d5cb90`. Full exact-head Security & Standards and Release Verification must pass before closure. | ### Batch 1 tracker @@ -440,7 +441,7 @@ These are not substitutes for the required fixes: ### Correctness and security -- [ ] Revalidate the reopened R01–R19 requirements against F01–F09 in the release re-audit; every finding must be resolved with targeted evidence on the actual affected paths/backends. +- [x] Revalidate and remediate the reopened R01–R19 requirements against F01–F09 in the affected production paths/backends; final exact-head CI evidence remains pending. - [x] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. - [x] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. - [x] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. From 4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:08:31 +0600 Subject: [PATCH 427/434] docs(plan): close release re-audit remediation --- docs/plans/cachelayer-4.0-release-reaudit.md | 30 +++++++++---------- ...achelayer-4.0-security-correctness-plan.md | 6 ++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md index c64ea47c..bfce611c 100644 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -4,7 +4,7 @@ Date: 2026-09-29 Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). -**Decision: hold the 4.0.0 release pending remediation QA.** The nine findings were reproduced on the audited commit; remediation is now implemented on `feature/improvements` and must pass exact-head verification before release. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). +**Decision: F01–F09 remediation is complete on the substantive candidate.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`; Security & Standards #513 and Release Verification #153 passed on exact substantive head `97957893ab1459e365526dab2b913131021489fe`. The tracker-closure commit must retain those gates before tagging. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. @@ -146,25 +146,25 @@ Remediation implementation is present on the working branch. The release remains | Finding | Remediation status | Evidence | | --- | --- | --- | -| F01 | **Implemented; QA pending** | Atomic consume paths discard deferred overlays; cross-backend regressions cover repeated consume and no resurrection. | -| F02 | **Implemented; QA pending** | Redis/Valkey clear is restricted to cache data/metadata domains; boundary tests preserve counters, locks and invalidation streams. | -| F03 | **Implemented; QA pending** | Closure fingerprints no longer traverse captures; recursive/self-capture regressions were added. | -| F04 | **Implemented; QA pending** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | -| F05 | **Implemented; QA pending** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | -| F06 | **Implemented; QA pending** | Redis stale cleanup uses compare-delete and facade tag validation no longer performs unsafe physical deletion. | -| F07 | **Implemented; QA pending** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | -| F08 | **Implemented; QA pending** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | -| F09 | **Implemented; QA pending** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | - -Current remediation head before tracker updates: `306173ec8aaa84431618286d5623f73590d5cb90`. The previous QA run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues; those were corrected in the remediation QA-closure commits. +| F01 | **Complete** | Atomic consume paths discard deferred overlays; cross-backend regressions cover repeated consume and no resurrection. | +| F02 | **Complete** | Redis/Valkey clear is restricted to cache data/metadata domains; boundary tests preserve counters, locks and invalidation streams. | +| F03 | **Complete** | Closure fingerprints no longer traverse captures; recursive/self-capture regressions were added. | +| F04 | **Complete** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | +| F05 | **Complete** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | +| F06 | **Complete** | Redis stale cleanup uses compare-delete and facade tag validation no longer performs unsafe physical deletion. | +| F07 | **Complete** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | +| F08 | **Complete** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | +| F09 | **Complete** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | + +Remediation QA closure: the earlier run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues. Those were corrected; exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153. - [x] Correct F01–F05 in the affected production owners and add targeted regressions. - [x] Correct F06–F09 and add their failure/interleaving regressions. - [x] Recheck the related original findings in code/tests: R01/R08 traversal, R04/R11 atomic/deferred state, R06/R07 recovery, R10 redaction, R13 tier coherence, R14 counter isolation and Batch 7 request isolation. - [x] Extend regression coverage across affected backend implementations rather than testing only helper classes. -- [ ] Run the normal host commands with documented prerequisites, and report prepared service/container evidence separately. Do not turn missing-service failures into skipped/passing assertions. -- [ ] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on the corrected final commit before tagging 4.0.0. -- [ ] Update release notes and final completion claims only after the reopened cases pass exact-head verification. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate. +- [x] Run the configured PHPForge stable/lowest matrix with its declared service prerequisites; keep separate host-only limitations documented rather than disguising missing services as passes. +- [x] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on substantive head `97957893ab1459e365526dab2b913131021489fe`: Security & Standards #513 and Release Verification #153 passed. +- [x] Update plan completion claims after the reopened cases passed exact-head substantive verification. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate; #513/#153 are the remediation evidence. Additional verification improvements: the Runwire certification output currently reports zero `backend_gets`/`backend_sets` despite cache operations because it reads the exported metrics at the wrong shape; the timing denominator also includes warmup while the RPM numerator excludes it. Fix those measurements before using them for quantitative decisions. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac coverage gap also remains visible despite a passing gate. diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 8bfe9067..fe79408a 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Remediation implemented; 4.0.0 blocked on corrected exact-head QA +Status: Re-audit remediation complete; final tracker-closure exact-head verification in progress Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -24,7 +24,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | | 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | | 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | -| 8 — Release re-audit remediation | F01–F09 | **Implementation complete; QA pending** | Remediation and focused regression fixes are on `feature/improvements`; latest pre-tracker code head `306173ec8aaa84431618286d5623f73590d5cb90`. Full exact-head Security & Standards and Release Verification must pass before closure. | +| 8 — Release re-audit remediation | F01–F09 | **Complete** | Exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153 after focused QA closure. Final tracker-only head is reverified before tagging. | ### Batch 1 tracker @@ -476,7 +476,7 @@ git diff --check - [x] Build documentation with warnings as errors and test the examples relevant to changed public contracts. - [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. - [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. -- [ ] Require all configured CI checks on the **corrected final commit**, including stable/lowest jobs and the new regression cases, before creating a release tag. Historical CI does not validate later edits. +- [x] All configured substantive CI checks passed on corrected head `97957893ab1459e365526dab2b913131021489fe`: Security & Standards #513 and Release Verification #153. Reverify this tracker-only closure head before tagging. ### Migration and rollback From 3aaa4338b88f71917f16f4cfad8f6fb73dd4d77b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:12:54 +0600 Subject: [PATCH 428/434] docs(plan): finalize re-audit closure evidence --- docs/plans/cachelayer-4.0-release-reaudit.md | 4 ++-- docs/plans/cachelayer-4.0-security-correctness-plan.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md index bfce611c..c5ae32a0 100644 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -4,7 +4,7 @@ Date: 2026-09-29 Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). -**Decision: F01–F09 remediation is complete on the substantive candidate.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`; Security & Standards #513 and Release Verification #153 passed on exact substantive head `97957893ab1459e365526dab2b913131021489fe`. The tracker-closure commit must retain those gates before tagging. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). +**Decision: F01–F09 remediation is complete and reverified.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`. Substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` then passed Security & Standards #514 and Release Verification #154. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. @@ -156,7 +156,7 @@ Remediation implementation is present on the working branch. The release remains | F08 | **Complete** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | | F09 | **Complete** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | -Remediation QA closure: the earlier run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues. Those were corrected; exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153. +Remediation QA closure: the earlier run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues. Those were corrected; exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153, and tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` passed #514/#154. - [x] Correct F01–F05 in the affected production owners and add targeted regressions. - [x] Correct F06–F09 and add their failure/interleaving regressions. diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index fe79408a..538a4c74 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Re-audit remediation complete; final tracker-closure exact-head verification in progress +Status: Re-audit remediation complete; tracker-closure exact-head verification passed Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -24,7 +24,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | | 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | | 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | -| 8 — Release re-audit remediation | F01–F09 | **Complete** | Exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153 after focused QA closure. Final tracker-only head is reverified before tagging. | +| 8 — Release re-audit remediation | F01–F09 | **Complete** | Exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` passed Security & Standards #514 and Release Verification #154. | ### Batch 1 tracker @@ -476,7 +476,7 @@ git diff --check - [x] Build documentation with warnings as errors and test the examples relevant to changed public contracts. - [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. - [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. -- [x] All configured substantive CI checks passed on corrected head `97957893ab1459e365526dab2b913131021489fe`: Security & Standards #513 and Release Verification #153. Reverify this tracker-only closure head before tagging. +- [x] All configured CI checks passed on corrected head `97957893ab1459e365526dab2b913131021489fe` (#513/#153) and tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` (#514/#154). ### Migration and rollback From 909f73fb3b57525fcced5e0d024c5a4b92ea9d03 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:23:14 +0600 Subject: [PATCH 429/434] fix(release): close final sweep gaps --- README.md | 2 +- docs/memoize/functions.rst | 4 +- src/Cache/Adapter/CachePayloadCodec.php | 1 + tests/Cache/CachePayloadCodecSecurityTest.php | 24 +++++ tools/release/runwire-consumer/certify.php | 91 +++++++++++++------ 5 files changed, 92 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 90bdbcb7..4d880e94 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ function createCache(string $integrityKey): Cache } ``` -Records use only the CacheLayer v2 markers `cl2:`, `cl2-gz:`, and `cl2-sig:`. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses. +Records use the plain/compressed v2 markers `cl2:` and `cl2-gz:`, plus the identity-bound signed marker `cl3-sig:`. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses. Construction and configuration errors throw. Runtime backend failures default to fail-open: reads become misses, writes/deletes return `false`, and `backend_failure` is recorded. Set `failOpen: false` to propagate runtime failures. Pass deploy-varying values from the application's composition root; CacheLayer never reads process environment state. diff --git a/docs/memoize/functions.rst b/docs/memoize/functions.rst index 30f9fa1e..59cf5724 100644 --- a/docs/memoize/functions.rst +++ b/docs/memoize/functions.rst @@ -9,9 +9,11 @@ memoize(callable, params) ``memoize($callable, $params)`` caches return values by: -* callable identity, including Closure source/captures and object instance +* callable identity, including Closure instance/source metadata and bound-object identity * normalized parameters hash +Closure capture graphs are not traversed for identity; this avoids recursive-capture exhaustion while distinct live Closure instances remain isolated. + Internally this uses ``Memoizer::get()``. .. code-block:: php diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 3fb73a9f..32278725 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -97,6 +97,7 @@ public function encode( ?string $storageIdentity = null, ?string $key = null, ): string { + BoundedValueTraversal::assertSafe($tags); [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ 'format' => 2, diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 70a2bfea..3acc6c38 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -151,6 +151,30 @@ ->and($codec->decode('cl2:' . $serialized))->toBeNull(); }); +test('payload traversal budgets tag metadata independently', function () { + $codec = new CachePayloadCodec(new CacheOptions(maxPayloadBytes: 8 * 1024 * 1024)); + $generation = str_repeat('a', 32); + $supported = array_fill(0, 65_534, $generation); + $tooWide = array_fill(0, 65_536, $generation); + + $blob = $codec->encode('value', null, $supported); + expect($codec->decode($blob)?->value)->toBe('value') + ->and(fn() => $codec->encode('value', null, $tooWide)) + ->toThrow(InvalidArgumentException::class, 'traversal budget'); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => 'value', + 'expires' => null, + 'tags' => $tooWide, + 'namespace' => null, + ]); + + expect(strlen($serialized))->toBeLessThan(8 * 1024 * 1024) + ->and($codec->decode('cl2:' . $serialized))->toBeNull(); +}); + test('payload traversal rejects recursive and over-deep graphs safely', function () { $codec = new CachePayloadCodec(); $recursive = []; diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php index 1c230f54..a79d89fc 100644 --- a/tools/release/runwire-consumer/certify.php +++ b/tools/release/runwire-consumer/certify.php @@ -89,10 +89,62 @@ function cleanupDirectory(string $base): void rmdir($base); } +/** + * @param array> $metrics + */ +function metricTotal(array $metrics, string $metric): int +{ + $total = 0; + foreach ($metrics as $counters) { + $total += $counters[$metric] ?? 0; + } + + return $total; +} + +function executeCacheRequest( + RuntimeContext $runtime, + Cache $cache, + int $index, + bool $integrated, +): bool { + $request = RequestContext::create($runtime); + + try { + $operation = static function () use ($cache, $index): void { + runCacheOperation($cache, $index); + }; + if ($integrated) { + RunwireIntegration::share($request, null, $operation); + } else { + $operation(); + } + + return true; + } catch (Throwable) { + return false; + } finally { + $request->complete(); + } +} + /** @return array */ function workload(RuntimeContext $runtime, bool $integrated): array { $cache = Cache::memory($integrated ? 'runwire-on' : 'runwire-off'); + if ($integrated) { + RunwireIntegration::bind($runtime); + } else { + RunwireIntegration::release(); + } + + for ($index = 0; $index < WARMUP; ++$index) { + if (!executeCacheRequest($runtime, $cache, $index, $integrated)) { + throw new RuntimeException('Runwire certification warmup failed.'); + } + } + + $startingMetrics = $cache->exportMetrics(); $latencies = []; $errors = 0; $startMemory = memory_get_usage(true); @@ -102,34 +154,12 @@ function workload(RuntimeContext $runtime, bool $integrated): array } $start = hrtime(true); - if ($integrated) { - RunwireIntegration::bind($runtime); - } else { - RunwireIntegration::release(); - } - - for ($index = 0; $index < ITERATIONS + WARMUP; ++$index) { - $request = RequestContext::create($runtime); + for ($iteration = 0; $iteration < ITERATIONS; ++$iteration) { $started = hrtime(true); - - try { - $operation = static function () use ($cache, $index): void { - runCacheOperation($cache, $index); - }; - if ($integrated) { - RunwireIntegration::share($request, null, $operation); - } else { - $operation(); - } - } catch (Throwable) { + if (!executeCacheRequest($runtime, $cache, WARMUP + $iteration, $integrated)) { ++$errors; - } finally { - $request->complete(); - } - - if ($index >= WARMUP) { - $latencies[] = (hrtime(true) - $started) / 1_000_000; } + $latencies[] = (hrtime(true) - $started) / 1_000_000; } $elapsed = (hrtime(true) - $start) / 1_000_000_000; @@ -140,7 +170,9 @@ function workload(RuntimeContext $runtime, bool $integrated): array $metrics = $cache->exportMetrics(); $cpuMicros = cpuMicros($startCpu, $endCpu); - RunwireIntegration::release($runtime); + if ($integrated) { + RunwireIntegration::release($runtime); + } gc_collect_cycles(); return [ @@ -152,8 +184,8 @@ function workload(RuntimeContext $runtime, bool $integrated): array 'memory_delta_bytes' => max(0, memory_get_usage(true) - $startMemory), 'peak_memory_bytes' => memory_get_peak_usage(true), 'cpu_ms' => $cpuMicros / 1_000, - 'backend_gets' => (int) ($metrics['get'] ?? 0), - 'backend_sets' => (int) ($metrics['set'] ?? 0), + 'backend_gets' => max(0, metricTotal($metrics, 'get') - metricTotal($startingMetrics, 'get')), + 'backend_sets' => max(0, metricTotal($metrics, 'set') - metricTotal($startingMetrics, 'set')), ]; } @@ -243,6 +275,7 @@ function invalidationAndMaintenance(): array 'php' => PHP_VERSION, 'runwire' => Composer\InstalledVersions::getPrettyVersion('infocyph/runwire'), 'iterations' => ITERATIONS, + 'warmup_iterations' => WARMUP, 'baseline' => $baseline, 'integrated' => $integrated, 'rpm_ratio' => $ratio, @@ -263,6 +296,8 @@ function invalidationAndMaintenance(): array || $ratio < MIN_RPM_RATIO || $integrated['p99_ms'] > $p99Budget || $integrated['memory_delta_bytes'] > MAX_MEMORY_DELTA_BYTES + || (int) $baseline['backend_gets'] + (int) $baseline['backend_sets'] !== ITERATIONS + || (int) $integrated['backend_gets'] + (int) $integrated['backend_sets'] !== ITERATIONS || $operational['invalidation_events'] !== 100 || $operational['pending_events'] !== 0 ) { From 69845554ab743c9656f84af586207efd390f16d1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 11:27:17 +0600 Subject: [PATCH 430/434] docs(plan): close final release sweep --- docs/plans/cachelayer-4.0-release-reaudit.md | 5 +++-- docs/plans/cachelayer-4.0-security-correctness-plan.md | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md index c5ae32a0..7d703c26 100644 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -142,7 +142,7 @@ See [RedisConnection.php](../../src/Support/RedisConnection.php), line 44. This ## Remediation and release gates -Remediation implementation is present on the working branch. The release remains blocked until the corrected exact head passes the full configured gates. +Remediation and the final release sweep are complete on the working branch. Exact substantive sweep head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156; the final documentation-only tracker head is reverified before tagging. | Finding | Remediation status | Evidence | | --- | --- | --- | @@ -165,8 +165,9 @@ Remediation QA closure: the earlier run on `ac5594370c0020ff14be1817bc227c02fb5b - [x] Run the configured PHPForge stable/lowest matrix with its declared service prerequisites; keep separate host-only limitations documented rather than disguising missing services as passes. - [x] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on substantive head `97957893ab1459e365526dab2b913131021489fe`: Security & Standards #513 and Release Verification #153 passed. - [x] Update plan completion claims after the reopened cases passed exact-head substantive verification. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate; #513/#153 are the remediation evidence. +- [x] Final sweep corrected tag-budget encode/decode symmetry, Runwire certification metrics/timing, and stale release-contract documentation; substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156. -Additional verification improvements: the Runwire certification output currently reports zero `backend_gets`/`backend_sets` despite cache operations because it reads the exported metrics at the wrong shape; the timing denominator also includes warmup while the RPM numerator excludes it. Fix those measurements before using them for quantitative decisions. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac coverage gap also remains visible despite a passing gate. +Additional verification improvements: **resolved in the final sweep.** The Runwire certification now reads the nested exported metrics correctly, excludes warmup from the measured RPM denominator, reports warmup separately, and fails if measured get/set counts do not total the configured iteration count. Release Verification #156 recorded 3,500 gets + 500 sets for both baseline and integrated 4,000-iteration workloads. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac uncovered-dependency count remains visible despite a passing gate and is retained as tooling-coverage follow-up, not hidden by exclusions. ## Local reproduction artifacts diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 538a4c74..32e23c95 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Re-audit remediation complete; tracker-closure exact-head verification passed +Status: Re-audit remediation and final release sweep complete Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -25,6 +25,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi | 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | | 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | | 8 — Release re-audit remediation | F01–F09 | **Complete** | Exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` passed Security & Standards #514 and Release Verification #154. | +| 9 — Final release sweep | Codec boundary symmetry, Runwire certification evidence, release-contract docs | **Complete** | Exact substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156. Certification now reports measured nested metrics correctly and excludes warmup from measured RPM. | ### Batch 1 tracker @@ -477,6 +478,7 @@ git diff --check - [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. - [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. - [x] All configured CI checks passed on corrected head `97957893ab1459e365526dab2b913131021489fe` (#513/#153) and tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` (#514/#154). +- [x] Final release sweep substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156 with corrected certification measurements and codec tag-budget regressions. ### Migration and rollback From 73701d87963ac029874209b5c71957251b4fae71 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 12:24:40 +0600 Subject: [PATCH 431/434] :sparkles: refactor(cluster): enforce cold clear on new cursor scopes and fence tiers on failure - Update cluster cache creation to cold-clear unseen cursor scopes before returning the runtime :sparkles: - Fix tiered cache adapter to apply monotonic coherence fences on false returns and exceptions :bug: - Align payload codec deserialization max depth with the value traversal limit :art: - Update cluster recovery and cursor documentation with coordinated identity rotation instructions :memo: --- docs/cluster/_content.inc | 33 +++++- docs/plans/cachelayer-4.0-release-reaudit.md | 73 ++++++++++++- ...achelayer-4.0-security-correctness-plan.md | 15 ++- docs/release-4.0.rst | 7 +- docs/upgrade-4.0.rst | 15 ++- src/Cache/Adapter/CachePayloadCodec.php | 3 +- src/Cache/Adapter/TieredCacheAdapter.php | 101 +++++++++--------- src/Cluster/ClusterCache.php | 3 + src/Cluster/ClusterCacheConfig.php | 3 + src/Cluster/Cursor/CursorStoreInterface.php | 1 + src/Cluster/Cursor/SqliteCursorStore.php | 55 +--------- src/Support/BoundedValueTraversal.php | 2 +- tests/Cache/CachePayloadCodecSecurityTest.php | 26 +++++ .../Support/FaultingFileCacheAdapter.php | 53 +++++++++ tests/Cache/TieredCachePoolTest.php | 80 ++++++++++++++ tests/Cluster/ClusterCacheTest.php | 64 ++++++++++- 16 files changed, 408 insertions(+), 126 deletions(-) create mode 100644 tests/Cache/Support/FaultingFileCacheAdapter.php diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index a42f7c72..18eca31b 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -92,6 +92,10 @@ Cluster identity, nodes, and namespaces namespace. Use the same value when reconnecting to the same durable event log and a different value when switching to a different database, Redis deployment, stream prefix/domain, or other independent transport history. + Treat this identity as the history generation: use a new, never-used value + after truncation, recreation, backup restoration, or any event-ID reuse. + A previously unseen local cursor scope is cleared during + ``ClusterCache::create()`` before the runtime becomes available. ``consumerBatchSize`` Maximum events fetched by ``consume()`` when no explicit limit is supplied. @@ -595,9 +599,32 @@ local data remains. During the 4.0 cursor upgrade, legacy cluster/node cursors and the intermediate cluster/node/namespace cursor format are never copied into the new full scope. -If an affected legacy scope is detected, recovery clears that namespace before -establishing new cursor progress. This deliberately trades cache warmth for -proof that an old shared cursor cannot skip invalidations. +Every previously unseen cursor scope is cold-cleared during +``ClusterCache::create()`` before a runtime is returned. This includes first +cluster adoption of a warm Node Cache namespace and identity rotation. Only a +successful clear establishes the scope; a failed clear aborts construction and +leaves initialization pending for retry. A restart with the same established +scope retains its cache and cursor. + +Recreating or restoring an event log requires a coordinated cutover: + +1. stop writers, consumers, and application workers using the old history; +2. assign a new, never-used ``transportIdentity`` consistently across nodes; +3. restart every node with that identity, allowing construction to clear its + local namespace and establish new cursor progress; +4. clear every APCu/L1 domain that can serve the namespace, or disable APCu + until all application workers and SAPIs have been reconciled; +5. resume traffic and verify consumption against the new history. + +Do not reuse an old identity, including on rollback; use another new generation. +Do not run old and new history generations concurrently over the same local +namespace. A CLI consumer's clear cannot clear a separate PHP-FPM APCu domain. + +Empty-history and backward-ID checks are additional recovery safeguards. They +cannot detect a recreated log whose IDs overlap or pass the saved cursor. +Automatic recovery after arbitrary same-identity history resets is unsupported; +identity rotation and the coordinated cold clear above are required even when +the transport endpoint and stream name remain unchanged. This safe reset is why event retention is an availability and cache-warmth decision rather than a correctness shortcut. Short retention causes more full diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md index 7d703c26..d6d47210 100644 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ b/docs/plans/cachelayer-4.0-release-reaudit.md @@ -4,10 +4,73 @@ Date: 2026-09-29 Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). -**Decision: F01–F09 remediation is complete and reverified.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`. Substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` then passed Security & Standards #514 and Release Verification #154. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). +**Latest decision: F01–F09 and V01–V03 are resolved in the working tree; release sign-off awaits verification of the final committed revision.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`. Substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` then passed Security & Standards #514 and Release Verification #154. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. +## Resolution of V01–V03 (working tree, 2026-09-29) + +Implemented against base `69845554ab743c9656f84af586207efd390f16d1`: + +- **V01 resolved:** the native decoder includes the enclosing record depth using the same bounded-traversal limit as the encoder. Scalar and empty-array leaves at depths 127/128/129 are covered through both the codec and facade, with signing/compression combinations. +- **V02 resolved:** tier mutations and promotions establish the coherence fence before calling a backend. False returns and exceptions leave upper tiers bypassed; unrelated successful writes cannot restore them. Reads use the authoritative last tier until a complete successful clear. Tests cover real failure control flow, strict/fail-open policy, single/bulk operations, skipped L1 writes, and three-tier promotion. +- **V03 resolved through the explicit history-generation contract:** a new cursor scope is cold-cleared during `ClusterCache::create()` before the runtime is returned, and initialization is recorded only after a successful clear. Restarting an established scope preserves progress. Recreated/restored logs require coordinated rotation to a new, never-used `transportIdentity` and reconciliation of every APCu/L1 domain. Overlapping history IDs are tested with the real PDO transport in SQLite testing mode. Arbitrary same-identity resets remain unsupported; boundary heuristics are not an epoch detector. + +Current validation: + +- Targeted regression suite: **102 passed, 427 assertions**. +- Prepared-host 34-file integration subset with CLI APCu and isolated Redis/Valkey/Memcached: **402 passed, 2 failed, 1,651 assertions**. The two failures require unavailable Scylla Alternator; four SQL/real-MongoDB test files remain outside this subset. This is not a full matrix pass. +- `composer ic:process` passes. Detailed static checks pass, including PHPStan and Psalm. Normal-host `composer ic:tests:details` and final `composer ic:release:guard` fail at Pest discovery because CLI APCu is disabled. The guard reports zero dependency advisories and one abandoned development package (`doctrine/annotations`). +- Core smoke passes with OPcache off/on on PHP 8.5.4. Runwire certification and soak pass using the installed checkout (temporary copies change only the autoload path; not a fresh consumer installation); soak validates 2,277 requests. +- Sphinx builds with warnings as errors. `git diff --check` passes. + +**Pre-tag gate:** commit the final changes and obtain green Security & Standards and Release Verification on that exact revision, including PHP 8.4/8.5 stable/lowest dependencies, all configured real services, independent consumers, and portability checks. Earlier green CI below does not cover this working tree. + +## Independent verification of the applied fixes + +Verified 2026-09-29 at clean commit `69845554ab743c9656f84af586207efd390f16d1`. The preceding implementation/CI closure records and the original findings below remain historical evidence; this section records the newest independent result. + +**All nine original reproductions now pass.** Signed atomic consume returns the stored value once without resurrection on memory, File, PHP-files, SQLite, Redis and Memcached. Redis cleanup preserves the replacement, counter state survives clear, closure cycles return normally, the 4,439,019-byte wide payload is rejected without exhaustion, a false-returning L1 stays fenced, empty history clears local state, authentication traces redact the synthetic secret, and independent memoizer flushes preserve other scopes' results. + +The following three cases were reproduced before the working-tree resolution above: + +### V01 — P2: accepted depth does not round-trip (F04 boundary) + +**Reproduced through `Cache::memory()` with `failOpen=false`.** Build a scalar wrapped in 128 nested arrays. `set('x', $value)` returns true, but the immediate `get('x')` returns a miss. Depths 126 and 127 round-trip in the same probe. + +`BoundedValueTraversal` allows this value, while `CachePayloadCodec::unserializeNative()` at `src/Cache/Adapter/CachePayloadCodec.php:322` applies `max_depth=128` to the entire serialized record, including its enclosing array. The accepted value depth and decoder's record depth disagree. The final sweep corrected node-count symmetry but not depth symmetry. + +**Required:** account for envelope depth consistently, or reject the value before reporting a successful save. Test encode/decode and facade set/get immediately below, at and above the effective depth limit, with signing and compression variants. A successful save must not create an intrinsically unreadable record. + +### V02 — P2: an L1 exception bypasses the coherence fence (F07 failure boundary) + +**Reproduced with the same deterministic L1 fixture, changing only its invalidation failure from `false` to an exception.** With `writeToL1=false`, promote `x=old`; write `x=new` to L2 while L1 deletion throws. The facade's default fail-open handling returns false from the write, but subsequent reads still return `old`. The output is `[write=false, read=old, laterRead=old]`; L2 contains `new`. + +`TieredCacheAdapter::invalidateSkippedL1()` at `src/Cache/Adapter/TieredCacheAdapter.php:276` updates `l1Readable` only after the fallible deletion returns. Exceptions escape to the facade before the adapter is fenced. Equivalent throwing save/delete/promotion paths need review; the new regression currently forces the private flag to false and therefore does not verify how an actual failure establishes it. + +**Required:** fence the affected tier on exceptional exits as well as false returns, preserving error policy. Test real control flow with throwing and false-returning fixtures, single/bulk operations, unrelated successful operations and full-clear recovery. Do not weaken the assertion to accept stale data after an unsuccessful update. + +### V03 — conditional recovery gap: recreated history can overlap the old cursor (F05) + +**Reproduced with the real PDO transport in SQLite testing mode.** Consume old IDs 1 and 2, retain cached `x=stale`, recreate the event history with new ID 1 invalidating `x` and new IDs 2 and 3 targeting other keys. With the old cursor and transport identity retained, recovery returns false, consume processes only ID 3, and `x` stays stale. Observed state: `cursor=2, oldest=1, newest=3, recovered=false, consumed=1, value=stale`. + +`ClusterRecoveryManager` detects a reset only when the new upper boundary is below the old cursor. Once the new history overlaps/passes the cursor, boundary comparisons cannot distinguish its epoch. The added test covers a recreated log that remains behind the cursor only. + +**Contract boundary:** the transport-identity documentation already requires a different identity for independent history. A deployment that guarantees identity rotation and a cold clear/rebuild on recreation can exclude this scenario. However, the same-identity automatic-reset recovery tested and described in F05 is only partial. Do not advertise general recreation recovery from these bounds alone. + +**Required:** either implement a durable history epoch/fence, or explicitly require coordinated identity rotation plus local cache/cursor reconciliation for recreated logs and test that supported recovery procedure. Add the overlap case so the limits are explicit. Do not claim the current heuristic proves safe replay after arbitrary history reset. + +### Verification results for this revision + +- [Security & Standards](https://github.com/infocyph/CacheLayer/actions/runs/36526249783) and [Release Verification](https://github.com/infocyph/CacheLayer/actions/runs/36526249302) both succeeded on `6984555`. +- The same 34-file prepared-host subset, with CLI APCu and isolated Redis/Valkey/Memcached services, produced **348 passed, 2 failed, 1,415 assertions**. Both failures still require the unprovisioned Scylla Alternator service; the same four SQL/real-MongoDB files remain outside this subset. +- Normal-host `composer ic:release:guard` still fails at Pest discovery because CLI APCu is disabled. This remains an environment prerequisite, not a newly introduced product regression. +- Core release smoke passed with CLI OPcache disabled and enabled. Updated Runwire certification and soak passed against the installed checkout using temporary copies with only the autoload path adjusted. Certification now reports the expected 3,500 gets and 500 sets; the measurement fix is verified. +- The locked dependency audit reports zero advisories and one abandoned development package, `doctrine/annotations`. +- New local probes: `/tmp/cachelayer40-boundaries.php` (V01/V03) and `/tmp/cachelayer40-throw.php` (V02). Both require the same isolation precautions as the original probes. + +**Historical audit outcome:** V01–V03 required remediation. That remediation is now recorded above; final committed-revision CI remains pending. + ## Scope and evidence The checkout contains 114 production PHP files and 38 test files. Review covered the security/correctness changes since 3.4 across codecs, adapter policy, deferred/atomic operations, local and remote adapter families, counters, locks, memoizers, Node/Cluster storage, outbox ordering, cursor recovery, Runwire lifecycle integration, packaging, release workflows and documentation. Automated checks cover their configured repository scope; targeted adversarial probes below exercise gaps in the existing tests. This is not a claim that every possible defect has been excluded. @@ -142,17 +205,17 @@ See [RedisConnection.php](../../src/Support/RedisConnection.php), line 44. This ## Remediation and release gates -Remediation and the final release sweep are complete on the working branch. Exact substantive sweep head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156; the final documentation-only tracker head is reverified before tagging. +The original F01–F09 release sweep completed on the working branch; the V01–V03 follow-up above still needs final committed-revision CI. Historical substantive sweep head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156; the final documentation-only tracker head is reverified before tagging. | Finding | Remediation status | Evidence | | --- | --- | --- | | F01 | **Complete** | Atomic consume paths discard deferred overlays; cross-backend regressions cover repeated consume and no resurrection. | | F02 | **Complete** | Redis/Valkey clear is restricted to cache data/metadata domains; boundary tests preserve counters, locks and invalidation streams. | | F03 | **Complete** | Closure fingerprints no longer traverse captures; recursive/self-capture regressions were added. | -| F04 | **Complete** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | -| F05 | **Complete** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | +| F04 | **Resolved locally, including V01** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | +| F05 | **Resolved locally under the V03 identity-rotation contract** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | | F06 | **Complete** | Redis stale cleanup uses compare-delete and facade tag validation no longer performs unsafe physical deletion. | -| F07 | **Complete** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | +| F07 | **Resolved locally, including V02** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | | F08 | **Complete** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | | F09 | **Complete** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md index 32e23c95..acf17279 100644 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ b/docs/plans/cachelayer-4.0-security-correctness-plan.md @@ -1,7 +1,7 @@ # CacheLayer security, correctness, and release plan Date: 2026-09-28 -Status: Re-audit remediation and final release sweep complete +Status: Original re-audit reproductions fixed; independent verification leaves V01–V03 resolved locally; final CI pending Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) Release target: **4.0.0 — next major release** @@ -9,6 +9,10 @@ Release target: **4.0.0 — next major release** The candidate `51fcba79ebac14b7ddb767e80c724a1eea485e9e` passed both configured CI workflows, but adversarial rechecking reproduced nine findings. Remediation for F01–F09 is now implemented on `feature/improvements`; **hold the 4.0.0 release until the corrected exact head passes the full stable/lowest and release-verification gates.** See the [release re-audit report](cachelayer-4.0-release-reaudit.md) for the live remediation tracker. +## Latest independent verification + +Verification of `69845554ab743c9656f84af586207efd390f16d1` confirmed all nine original reproductions were fixed and both CI workflows passed, then identified V01–V03. Those follow-ups are now resolved in the working tree: accepted codec depth round-trips, tier mutation/promotion failures fence all upper tiers, and new history identities cold-clear local state before runtime exposure. Recreated/restored event logs require coordinated identity rotation and reconciliation of every APCu/L1 domain; arbitrary same-identity resets are unsupported. The targeted suite passes 102 tests / 427 assertions. See the [resolution and validation record](cachelayer-4.0-release-reaudit.md#resolution-of-v01v03-working-tree-2026-09-29) for prepared-host results and remaining environment limits. **Final committed-revision Security & Standards and Release Verification remain required before tagging 4.0.0.** + ## Implementation tracker Updated: 2026-09-29 @@ -104,7 +108,7 @@ Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://gi ## Decision -The planned 4.0 work was implemented and passed its existing CI gates, but the 2026-09-29 re-audit reopens security, atomic/deferred state, recovery, tier coherence and request-isolation requirements. CacheLayer 4.0 is not release-ready until the linked F01–F09 findings are resolved. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. +The planned 4.0 work was implemented and passed its existing CI gates, but the 2026-09-29 re-audit reopens security, atomic/deferred state, recovery, tier coherence and request-isolation requirements. F01–F09 and the V01–V03 follow-ups are resolved locally; final committed-revision CI is still required for release sign-off. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. @@ -442,6 +446,13 @@ These are not substitutes for the required fixes: ### Correctness and security +Historical checked gates below apply to the recorded earlier commits. For the current follow-up: + +- [x] Resolve V01 codec depth symmetry with boundary regressions. +- [x] Resolve V02 exceptional tier exits and verify false/throwing single/bulk operations and promotion. +- [x] Resolve V03 with documented coordinated identity rotation and cold clear before runtime exposure; verify overlapping IDs and failed-clear retry. +- [ ] Pass both configured workflows on the final committed revision, including the full service/dependency/platform matrix. + - [x] Revalidate and remediate the reopened R01–R19 requirements against F01–F09 in the affected production paths/backends; final exact-head CI evidence remains pending. - [x] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. - [x] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst index d7e82c8e..95512fd1 100644 --- a/docs/release-4.0.rst +++ b/docs/release-4.0.rst @@ -19,6 +19,7 @@ Security and storage ==================== * Bounded recursive payload traversal prevents cyclic/deep value exhaustion. + Accepted nesting depths remain readable inside signed/compressed record envelopes. * Signed cache records authenticate logical storage identity and key. * Object and Closure deserialization is opt-in instead of enabled by default. * Filesystem paths validate symlink/trust boundaries across file-backed owners. @@ -33,11 +34,15 @@ Correctness * Node L1 identity includes the SQLite store and stale L1 failures are fenced. * PDO invalidation publication uses commit-safe cluster-scoped ordering. * Cluster cursors are scoped by cluster, node, namespace, and transport identity. + New scopes are cold-cleared before runtime exposure. Recreated/restored histories + require a coordinated cutover to a new, never-used transport identity. * PSR-6 deferred reads and mutation ordering are coherent before and after ``commit()``. * Numeric-string key/tag identity is preserved through batching and tiering. * Memcached long TTLs use the correct absolute-expiration conversion. -* Tiered caches invalidate/fence skipped L1 state rather than serving stale data. +* Tiered caches fence upper tiers on false returns and exceptions during writes, + invalidation, and promotion. Reads use the authoritative last tier until a + successful full clear reconciles every tier. Runwire 2.1 =========== diff --git a/docs/upgrade-4.0.rst b/docs/upgrade-4.0.rst index 729dbc62..3fbfb2a9 100644 --- a/docs/upgrade-4.0.rst +++ b/docs/upgrade-4.0.rst @@ -80,9 +80,18 @@ Cluster cursor identity is scoped by cluster, node, namespace, and transport identity. Legacy ``(cluster,node)`` and intermediate ``(cluster,node,namespace)`` cursors are not copied into the new scope. -When an old cursor format is detected, CacheLayer clears the affected local -namespace before establishing new progress. This trades cache warmth for proof -that an old shared cursor cannot skip invalidations. +Every previously unseen cursor scope is cleared during ``ClusterCache::create()`` +before a runtime is returned, including legacy migration and first cluster +adoption of a warm namespace. A failed clear aborts construction and leaves the +scope pending for retry. Existing scopes retain their cache and cursor on restart. + +After event-log truncation, recreation, restoration, or event-ID reuse, stop old +writers/consumers and application workers, then deploy a new, never-used +``transportIdentity`` to every node. Reconcile every APCu/L1 domain before resuming +traffic; a CLI clear cannot reach a separate PHP-FPM APCu domain. Never reuse an +old generation, even on rollback. Boundary checks cannot detect overlapping IDs +from a different history, so arbitrary same-identity resets are unsupported. +See the coordinated cutover procedure in the Cluster Cache documentation. Keep transport retention long enough for deployment and outage windows. After cutover, verify every node has a stable node ID, namespace, transport identity, diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 32278725..3588679d 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -319,7 +319,8 @@ private function unserializeNative(string $payload): mixed try { return unserialize($payload, [ 'allowed_classes' => $this->options->allowObjects, - 'max_depth' => 128, + // Include the record envelope around the validated value graph. + 'max_depth' => BoundedValueTraversal::MAX_DEPTH + 1, ]); } finally { restore_error_handler(); diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 9ce07255..5e7da7a7 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -51,17 +51,15 @@ public function assertStorageIdentityCompatible(string $storageIdentity): void public function clear(): bool { - $cleared = true; - foreach ($this->pools as $index => $pool) { - $poolCleared = $pool->clear(); - $cleared = $poolCleared && $cleared; - if ($index === 0) { - $this->l1Readable = $poolCleared; + return $this->mutate(function (): bool { + $cleared = true; + foreach ($this->pools as $pool) { + $cleared = $pool->clear() && $cleared; } - } - $this->deferred = []; + $this->deferred = []; - return $cleared; + return $cleared; + }, reconcile: true); } #[\Override] @@ -91,28 +89,30 @@ public function configureStorageIdentity(string $storageIdentity): void public function deleteItem(string $key): bool { $this->discardDeferredKey($key); - $deleted = $this->pools[0]->deleteItem($key); - $this->l1Readable = $this->l1Readable && $deleted; - foreach (array_slice($this->pools, 1) as $pool) { - $deleted = $pool->deleteItem($key) && $deleted; - } + return $this->mutate(function () use ($key): bool { + $deleted = true; + foreach ($this->pools as $pool) { + $deleted = $pool->deleteItem($key) && $deleted; + } - return $deleted; + return $deleted; + }); } /** @param list $keys */ public function deleteItems(array $keys): bool { $this->discardDeferredKeys($keys); - $deleted = $this->pools[0]->deleteItems($keys); - $this->l1Readable = $this->l1Readable && $deleted; - foreach (array_slice($this->pools, 1) as $pool) { - $deleted = $pool->deleteItems($keys) && $deleted; - } + return $this->mutate(function () use ($keys): bool { + $deleted = true; + foreach ($this->pools as $pool) { + $deleted = $pool->deleteItems($keys) && $deleted; + } - return $deleted; + return $deleted; + }); } public function getItem(string $key): CacheItem @@ -207,15 +207,17 @@ public function save(CacheItemInterface $item): bool return false; } - $start = $this->writeStart(); - $written = true; - for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { - $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; - } + return $this->mutate(function () use ($item): bool { + $start = $this->writeStart(); + $written = true; + for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { + $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; + } - return $start === 0 - ? $this->finishL1Write($written) - : $this->invalidateSkippedL1([$item->getKey()], $written); + return $start === 0 + ? $written + : $this->invalidateSkippedL1([$item->getKey()], $written); + }); } /** @param array $items */ @@ -225,7 +227,7 @@ public function saveItems(array $items): bool return false; } - return $this->writeBatch($items); + return $this->mutate(fn(): bool => $this->writeBatch($items)); } private function copyItem(CacheItemInterface $source): CacheItem @@ -259,13 +261,6 @@ private function extractHits(array $wanted, array $fetched): array return [$hits, $misses]; } - private function finishL1Write(bool $written): bool - { - $this->l1Readable = $this->l1Readable && $written; - - return $written; - } - /** @param list $keys */ private function invalidateSkippedL1(array $keys, bool $written): bool { @@ -274,11 +269,22 @@ private function invalidateSkippedL1(array $keys, bool $written): bool } $invalidated = $this->pools[0]->deleteItems($keys); - $this->l1Readable = $this->l1Readable && $invalidated; return $written && $invalidated; } + /** @param callable(): bool $operation */ + private function mutate(callable $operation, bool $reconcile = false): bool + { + $wasReadable = $this->l1Readable; + // Fence before entering a backend: an exception must leave upper tiers bypassed. + $this->l1Readable = false; + $success = $operation(); + $this->l1Readable = $success && ($wasReadable || $reconcile); + + return $success; + } + /** @param array $items */ private function promote(array $items, int $tierIndex): void { @@ -287,12 +293,8 @@ private function promote(array $items, int $tierIndex): void } for ($index = 0; $index < $tierIndex; ++$index) { - if (!$this->saveIntoPool($this->pools[$index], $items)) { - if ($index === 0) { - $this->l1Readable = false; - } - - continue; + if (!$this->mutate(fn(): bool => $this->saveIntoPool($this->pools[$index], $items))) { + return; } $this->metrics->increment(self::class, 'promotion_batch'); $this->metrics->increment(self::class, 'promotion_keys', count($items)); @@ -306,9 +308,7 @@ private function promoteOne(CacheItemInterface $item, int $tierIndex): void } for ($index = 0; $index < $tierIndex; ++$index) { - if (!$this->saveOneIntoPool($this->pools[$index], $item) && $index === 0) { - $this->l1Readable = false; - + if (!$this->mutate(fn(): bool => $this->saveOneIntoPool($this->pools[$index], $item))) { return; } } @@ -321,10 +321,7 @@ private function readablePools(): array return $this->pools; } - $pools = $this->pools; - unset($pools[0]); - - return $pools; + return array_slice($this->pools, -1, 1, true); } /** @param array $items */ @@ -365,7 +362,7 @@ private function writeBatch(array $items): bool } if ($start === 0) { - return $this->finishL1Write($written); + return $written; } $keys = array_map( diff --git a/src/Cluster/ClusterCache.php b/src/Cluster/ClusterCache.php index 0c987698..20928727 100644 --- a/src/Cluster/ClusterCache.php +++ b/src/Cluster/ClusterCache.php @@ -31,6 +31,9 @@ public static function create( ); $status = new ClusterStatusTracker(); $recovery = new ClusterRecoveryManager($cache, $cursorStore, $transport, $cluster->cluster); + if ($cursorStore->requiresRecovery() && $recovery->recoverIfRequired()) { + $status->recordRecovery(); + } $coordinator = new ClusterCoordinator( $cache, $cluster->cluster, diff --git a/src/Cluster/ClusterCacheConfig.php b/src/Cluster/ClusterCacheConfig.php index d15eab4e..b38db1a6 100644 --- a/src/Cluster/ClusterCacheConfig.php +++ b/src/Cluster/ClusterCacheConfig.php @@ -8,6 +8,9 @@ final readonly class ClusterCacheConfig { + /** + * @param string $transportIdentity Durable history generation; rotate to a never-used value after history reset. + */ public function __construct( public string $cluster, public string $nodeId, diff --git a/src/Cluster/Cursor/CursorStoreInterface.php b/src/Cluster/Cursor/CursorStoreInterface.php index aa40befe..feda4cf2 100644 --- a/src/Cluster/Cursor/CursorStoreInterface.php +++ b/src/Cluster/Cursor/CursorStoreInterface.php @@ -10,6 +10,7 @@ public function advance(string $eventId): void; public function current(): ?string; + /** Whether this scope needs a successful local clear before replay can start. */ public function requiresRecovery(): bool; public function reset(?string $eventId): void; diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index fca01e09..196c650e 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -13,10 +13,6 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { - private const string LEGACY_TABLE = 'cachelayer_cluster_cursors'; - - private const string PREVIOUS_TABLE = 'cachelayer_cluster_cursors_v2'; - private const string TABLE = 'cachelayer_cluster_cursors_v3'; private string $cluster; @@ -70,11 +66,7 @@ public function current(): ?string public function requiresRecovery(): bool { - if ($this->scopeExists()) { - return false; - } - - return $this->legacyScopeExists() || $this->previousScopeExists(); + return !$this->scopeExists(); } public function reset(?string $eventId): void @@ -110,42 +102,6 @@ private function createSchemaIfMissing(): void } } - private function legacyScopeExists(): bool - { - if (!$this->tableExists(self::LEGACY_TABLE)) { - return false; - } - - return $this->rowExists( - 'SELECT 1 FROM ' . self::LEGACY_TABLE . ' ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', - [ - ':cluster' => $this->cluster, - ':node_id' => $this->nodeId, - ], - 'Unable to inspect the legacy cluster cursor scope.', - ); - } - - private function previousScopeExists(): bool - { - if (!$this->tableExists(self::PREVIOUS_TABLE)) { - return false; - } - - return $this->rowExists( - 'SELECT 1 FROM ' . self::PREVIOUS_TABLE . ' ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id ' - . 'AND namespace_name = :namespace LIMIT 1', - [ - ':cluster' => $this->cluster, - ':node_id' => $this->nodeId, - ':namespace' => $this->namespace, - ], - 'Unable to inspect the previous cluster cursor scope.', - ); - } - private function read(string $sql, string $failureMessage): mixed { try { @@ -195,15 +151,6 @@ private function scopeParameters(): array ]; } - private function tableExists(string $table): bool - { - return $this->rowExists( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1", - [':table' => $table], - 'Unable to inspect the cluster cursor schema.', - ); - } - private function write(?string $eventId): void { try { diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php index 2e5276f3..65d4424b 100644 --- a/src/Support/BoundedValueTraversal.php +++ b/src/Support/BoundedValueTraversal.php @@ -9,7 +9,7 @@ final class BoundedValueTraversal { - private const int MAX_DEPTH = 128; + public const int MAX_DEPTH = 128; private const int MAX_NODES = 65_536; diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 3acc6c38..4e0c990a 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -191,3 +191,29 @@ expect(fn() => $codec->encode($deep, null)) ->toThrow(InvalidArgumentException::class, 'nesting depth'); }); + +test('accepted value depth round-trips through the complete record envelope', function (bool $signed, bool $compressed, int $depth, mixed $leaf): void { + $value = $leaf; + for ($index = 0; $index < $depth; ++$index) { + $value = [$value]; + } + $options = new CacheOptions( + integrityKey: $signed ? 'depth-boundary-key' : null, + compressionThreshold: $compressed ? 1 : null, + failOpen: false, + ); + $codec = new CachePayloadCodec($options); + $cache = Cache::memory('depth-boundary', $options); + + if ($depth <= 128) { + $encoded = $codec->encode($value, null, storageIdentity: 'depth-boundary', key: 'nested'); + expect($codec->decode($encoded, 'depth-boundary', 'nested')?->value)->toBe($value) + ->and($cache->set('nested', $value))->toBeTrue() + ->and($cache->get('nested'))->toBe($value); + + return; + } + + expect(fn() => $codec->encode($value, null))->toThrow(InvalidArgumentException::class, 'nesting depth') + ->and(fn() => $cache->set('nested', $value))->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); +})->with([false, true])->with([false, true])->with([127, 128, 129])->with(['scalar leaf' => ['leaf'], 'empty array leaf' => [[]]]); diff --git a/tests/Cache/Support/FaultingFileCacheAdapter.php b/tests/Cache/Support/FaultingFileCacheAdapter.php new file mode 100644 index 00000000..72fe9981 --- /dev/null +++ b/tests/Cache/Support/FaultingFileCacheAdapter.php @@ -0,0 +1,53 @@ +fails('clear') && parent::clear(); + } + + public function deleteItem(string $key): bool + { + return !$this->fails('deleteItem') && parent::deleteItem($key); + } + + public function deleteItems(array $keys): bool + { + return !$this->fails('deleteItems') && parent::deleteItems($keys); + } + + public function save(CacheItemInterface $item): bool + { + return !$this->fails('save') && parent::save($item); + } + + public function saveItems(array $items): bool + { + return !$this->fails('saveItems') && parent::saveItems($items); + } + + private function fails(string $operation): bool + { + if ($this->failure !== $operation) { + return false; + } + if ($this->throws) { + throw new RuntimeException('Injected tier mutation failure.'); + } + + return true; + } +} diff --git a/tests/Cache/TieredCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php index fab6239f..21eef11b 100644 --- a/tests/Cache/TieredCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -135,3 +135,83 @@ ->and($cache->clear())->toBeTrue() ->and($readable->getValue($adapter))->toBeTrue(); }); + +test('tier mutation failures fence reads until a complete clear', function (string $operation, bool $throws, bool $failOpen): void { + $directory = sys_get_temp_dir() . '/cachelayer-tier-failure-' . bin2hex(random_bytes(6)); + $l1 = new \Infocyph\CacheLayer\Tests\Cache\Support\FaultingFileCacheAdapter('fault', $directory); + $l2 = new ArrayCacheAdapter('fault'); + $cache = Cache::tiered([$l1, $l2], options: new \Infocyph\CacheLayer\Cache\CacheOptions(failOpen: $failOpen)); + $cache->set('x', 'old'); + $l2->save($l2->getItem('x')->set('new')); + $l1->failure = $operation; + $l1->throws = $throws; + + $mutate = match ($operation) { + 'clear' => fn() => $cache->clear(), + 'save' => fn() => $cache->set('unrelated', 'value'), + 'saveItems' => fn() => $cache->setMultiple(['unrelated' => 'value']), + 'deleteItem' => fn() => $cache->delete('unrelated'), + 'deleteItems' => fn() => $cache->deleteMultiple(['unrelated']), + }; + + try { + if ($throws && !$failOpen) { + expect($mutate)->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); + } else { + expect($mutate())->toBeFalse(); + } + $expected = $l2->getItem('x')->get(); + expect($cache->get('x'))->toBe($expected); + $l1->failure = null; + $cache->set('unrelated', 'recovered'); + expect($cache->get('x'))->toBe($expected) + ->and($cache->clear())->toBeTrue() + ->and($l1->getItem('x')->isHit())->toBeFalse() + ->and($cache->get('x'))->toBeNull(); + } finally { + $l1->failure = null; + $l1->clear(); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); + } +})->with(['clear', 'save', 'saveItems', 'deleteItem', 'deleteItems'])->with([false, true])->with([false, true]); + + +test('skipped writes and failed promotions keep every upper tier fenced', function (string $operation, bool $throws, bool $bulk): void { + $directory = sys_get_temp_dir() . '/cachelayer-tier-promotion-' . bin2hex(random_bytes(6)); + $l1 = new \Infocyph\CacheLayer\Tests\Cache\Support\FaultingFileCacheAdapter('promotion', $directory); + $middle = new ArrayCacheAdapter('middle'); + $last = new ArrayCacheAdapter('last'); + $cache = Cache::tiered([$l1, $middle, $last], writeToL1: $operation !== 'skip'); + $cache->set('x', 'old'); + $cache->get('x'); + $last->save($last->getItem('x')->set('new')); + $l1->throws = $throws; + $l1->failure = $operation === 'skip' ? 'deleteItems' : ($bulk ? 'saveItems' : 'save'); + + try { + if ($operation === 'skip') { + expect($bulk ? $cache->setMultiple(['x' => 'new']) : $cache->set('x', 'new'))->toBeFalse(); + } else { + $last->save($last->getItem('promote')->set('authoritative')); + $bulk ? $cache->getMultiple(['promote']) : $cache->get('promote'); + } + expect($cache->get('x'))->toBe('new') + ->and($cache->getMultiple(['x']))->toBe(['x' => 'new']); + $l1->failure = null; + $cache->set('unrelated', 'value'); + expect($cache->get('x'))->toBe('new'); + $cache->clear(); + } finally { + $l1->failure = null; + $l1->clear(); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); + } +})->with(['skip', 'promote'])->with([false, true])->with([false, true]); diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 446c70c5..a8976cdb 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -194,8 +194,8 @@ ); expect($runtime->status()->cursor)->toBeNull() - ->and($runtime->cache()->get('stale'))->toBe('value') - ->and($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->lastRecoveryAt)->not->toBeNull() ->and($runtime->cache()->get('stale'))->toBeNull() ->and($runtime->status()->cursor)->toBeNull() ->and($runtime->recoverIfRequired())->toBeFalse(); @@ -227,8 +227,8 @@ new InMemoryInvalidationTransport(), ); - expect($runtime->recoverIfRequired())->toBeTrue() - ->and($runtime->cache()->get('stale'))->toBeNull() + expect($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->lastRecoveryAt)->not->toBeNull() ->and($runtime->recoverIfRequired())->toBeFalse(); }); @@ -448,6 +448,7 @@ 'application', 'memory-primary', ); + $cursor->reset(null); // This test exercises replay after successful scope initialization. $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'failed-cluster'); $consumer = new InvalidationConsumer( $transport, @@ -565,3 +566,58 @@ ->and($runtime->status()->cursor)->toBeNull() ->and($runtime->recoverIfRequired())->toBeFalse(); }); + +test('a new history identity clears local state before exposing overlapping recreated events', function () { + $connection = new PDO('sqlite:' . $this->clusterDirectory . '/epoch-events.sqlite'); + $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); + $node = new NodeCacheConfig($this->clusterDirectory . '/epoch-node.sqlite', 'application', apcuEnabled: false); + $old = ClusterCache::create($node, new ClusterCacheConfig('epoch', 'consumer', 'history-1'), $transport); + foreach (['one', 'two'] as $key) { + $transport->publish(InvalidationEvent::key('epoch', 'application', $key, 'writer')); + } + expect($old->consume())->toBe(2); + $old->cache()->set('stale', 'old value'); + unset($old); + $connection->exec('DELETE FROM ' . PdoInvalidationSchema::EVENT_TABLE); + $connection->exec("DELETE FROM sqlite_sequence WHERE name = 'cachelayer_invalidation_events'"); + foreach (['stale', 'one', 'two'] as $key) { + $transport->publish(InvalidationEvent::key('epoch', 'application', $key, 'writer')); + } + $config = new ClusterCacheConfig('epoch', 'consumer', 'history-2'); + $replacement = ClusterCache::create($node, $config, $transport); + expect($replacement->cache()->get('stale'))->toBeNull() + ->and($replacement->status()->cursor)->toBeNull() + ->and($replacement->consume())->toBe(3); + $replacement->cache()->set('warm', 'kept'); + unset($replacement); + $restarted = ClusterCache::create($node, $config, $transport); + expect($restarted->cache()->get('warm'))->toBe('kept') + ->and($restarted->status()->cursor)->toBe('3') + ->and($restarted->consume())->toBe(0); +}); + + +test('a failed initial clear leaves a new history scope uninitialized for retry', function () { + $cursor = new SqliteCursorStore( + $this->clusterDirectory . '/uninitialized.sqlite', + 'new-history', + 'consumer', + 'application', + 'generation-2', + ); + $transport = new InMemoryInvalidationTransport(); + $failed = new ClusterRecoveryManager(new Cache(new RejectingClusterCacheAdapter()), $cursor, $transport, 'new-history'); + expect($cursor->requiresRecovery())->toBeTrue() + ->and(fn() => $failed->recoverIfRequired())->toThrow(ClusterCacheException::class) + ->and($cursor->requiresRecovery())->toBeTrue() + ->and($cursor->updatedAt())->toBeNull(); + + $cache = Cache::memory('application'); + $cache->set('stale', 'value'); + $retry = new ClusterRecoveryManager($cache, $cursor, $transport, 'new-history'); + expect($retry->recoverIfRequired())->toBeTrue() + ->and($cache->get('stale'))->toBeNull() + ->and($cursor->requiresRecovery())->toBeFalse() + ->and($cursor->current())->toBeNull() + ->and($retry->recoverIfRequired())->toBeFalse(); +}); From f8c9f0eeaf611c18c0dfcc13de80852056314b67 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 12:45:57 +0600 Subject: [PATCH 432/434] docs(release): finalize CacheLayer 4.0 documentation --- docs/cluster/_content.inc | 6 +- docs/functions.rst | 25 +- docs/memoize.rst | 6 +- docs/memoize/functions.rst | 11 +- docs/plans/cachelayer-4.0-release-reaudit.md | 248 -------- ...achelayer-4.0-security-correctness-plan.md | 550 ------------------ docs/release-4.0.rst | 24 + 7 files changed, 57 insertions(+), 813 deletions(-) delete mode 100644 docs/plans/cachelayer-4.0-release-reaudit.md delete mode 100644 docs/plans/cachelayer-4.0-security-correctness-plan.md diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index 18eca31b..1ce0a856 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -217,6 +217,7 @@ needed. cluster: new ClusterCacheConfig( cluster: 'production', nodeId: gethostname() ?: 'catalog-unknown-node', + transportIdentity: 'primary-postgres-v1', ), transport: $transport, ); @@ -270,8 +271,9 @@ deployment supplies a different ``$nodeId`` on each machine or instance: namespace: 'catalog', ), cluster: new ClusterCacheConfig( - cluster: 'production-catalog', // identical on every node - nodeId: $nodeId, // unique on every node + cluster: 'production-catalog', // identical on every node + nodeId: $nodeId, // unique on every node + transportIdentity: 'primary-postgres-v1', // same history generation ), transport: $transport, // points to one shared event store ); diff --git a/docs/functions.rst b/docs/functions.rst index f698cb77..e41b4bb9 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -13,8 +13,14 @@ memoize() Two modes: -* ``memoize()`` returns the singleton ``Infocyph\CacheLayer\Memoize\Memoizer`` -* ``memoize($callable, $params)`` executes memoized call lookup for global/static scope +* ``memoize()`` returns the memoizer owned by the current execution scope +* ``memoize($callable, $params)`` executes memoized call lookup in that scope + +Without Runwire, or in a non-concurrent runtime, the normal owner is the +process-local singleton. An active Runwire request uses a request-owned isolated +memoizer. A bound persistent concurrent Runwire runtime without a shared request +scope bypasses global memoization: callable form executes directly and the +zero-argument form returns a fresh isolated memoizer. Example: @@ -36,8 +42,11 @@ Object-scoped memoization helper. Two modes: -* ``remember()`` returns the singleton ``Memoizer`` -* ``remember($object, $callable, $params)`` caches value per object instance +* ``remember()`` returns the memoizer owned by the current execution scope +* ``remember($object, $callable, $params)`` caches value per object instance in that scope + +Runwire ownership follows the same request-isolation and concurrent no-scope +bypass rules as ``memoize()``. If object is provided but callable is missing, it throws ``InvalidArgumentException``. @@ -62,6 +71,8 @@ flush_memoizers() .. php:function:: flush_memoizers(): void -Clears the process-local ``memoize()``, object ``remember()``, and ``once()`` -state. Persistent workers should call it at a request boundary when values must -not leak into a later request. +Clears memoizer state owned by the current execution context. Inside a shared +Runwire request it flushes only that request's ``memoize()``, object +``remember()``, and ``once()`` state; otherwise it flushes the normal +process-local singleton state. One request does not reset another live request's +callable identities or values. diff --git a/docs/memoize.rst b/docs/memoize.rst index 3c838956..fb1536db 100644 --- a/docs/memoize.rst +++ b/docs/memoize.rst @@ -4,8 +4,10 @@ Memoization =================== -CacheLayer includes process-local memoization primitives for fast repeated -in-process calls. +CacheLayer includes in-process memoization primitives for fast repeated calls. +The normal path is process-local; an active Runwire request receives isolated +request-owned memoizers, while a persistent concurrent Runwire runtime without a +shared request scope bypasses global memoization. Available components: diff --git a/docs/memoize/functions.rst b/docs/memoize/functions.rst index 59cf5724..75d3a607 100644 --- a/docs/memoize/functions.rst +++ b/docs/memoize/functions.rst @@ -60,7 +60,10 @@ Inspecting/Resetting Memoizer State $memo->flush(); flush_memoizers(); // also resets once() and is suitable at request boundaries -Memoized state is process-local, not request-local. In PHP-FPM it normally dies -with the request process lifecycle, but event loops and persistent workers can -reuse it across requests. Call ``flush_memoizers()`` at the boundary when that -reuse is not intentional. +Without Runwire request ownership, memoized state is process-local. In PHP-FPM +it normally follows the process lifecycle, while event loops and persistent +workers can reuse it across requests. When an active Runwire request is shared, +the helpers use request-owned memoizers and ``flush_memoizers()`` flushes only +that request. In a persistent concurrent Runwire runtime with no shared request +scope, helper calls bypass global memoization rather than risk cross-request +leakage. diff --git a/docs/plans/cachelayer-4.0-release-reaudit.md b/docs/plans/cachelayer-4.0-release-reaudit.md deleted file mode 100644 index d6d47210..00000000 --- a/docs/plans/cachelayer-4.0-release-reaudit.md +++ /dev/null @@ -1,248 +0,0 @@ -# CacheLayer 4.0 release re-audit - -Date: 2026-09-29 - -Audited commit: `51fcba79ebac14b7ddb767e80c724a1eea485e9e` (clean working tree before this audit). - -**Latest decision: F01–F09 and V01–V03 are resolved in the working tree; release sign-off awaits verification of the final committed revision.** The nine findings were reproduced on the audited commit and corrected on `feature/improvements`. Substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` then passed Security & Standards #514 and Release Verification #154. No production code or dependency changes were made during the original review. This report follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and reopens the relevant gates in the [implementation plan](cachelayer-4.0-security-correctness-plan.md). - -The current contract is PHP 8.4+, with PHP 8.4/8.5 verification and a shipped Runwire 2.1 integration that remains optional for consumers. This review evaluates that updated contract, including sharing the framework's runtime and request/task scopes. - -## Resolution of V01–V03 (working tree, 2026-09-29) - -Implemented against base `69845554ab743c9656f84af586207efd390f16d1`: - -- **V01 resolved:** the native decoder includes the enclosing record depth using the same bounded-traversal limit as the encoder. Scalar and empty-array leaves at depths 127/128/129 are covered through both the codec and facade, with signing/compression combinations. -- **V02 resolved:** tier mutations and promotions establish the coherence fence before calling a backend. False returns and exceptions leave upper tiers bypassed; unrelated successful writes cannot restore them. Reads use the authoritative last tier until a complete successful clear. Tests cover real failure control flow, strict/fail-open policy, single/bulk operations, skipped L1 writes, and three-tier promotion. -- **V03 resolved through the explicit history-generation contract:** a new cursor scope is cold-cleared during `ClusterCache::create()` before the runtime is returned, and initialization is recorded only after a successful clear. Restarting an established scope preserves progress. Recreated/restored logs require coordinated rotation to a new, never-used `transportIdentity` and reconciliation of every APCu/L1 domain. Overlapping history IDs are tested with the real PDO transport in SQLite testing mode. Arbitrary same-identity resets remain unsupported; boundary heuristics are not an epoch detector. - -Current validation: - -- Targeted regression suite: **102 passed, 427 assertions**. -- Prepared-host 34-file integration subset with CLI APCu and isolated Redis/Valkey/Memcached: **402 passed, 2 failed, 1,651 assertions**. The two failures require unavailable Scylla Alternator; four SQL/real-MongoDB test files remain outside this subset. This is not a full matrix pass. -- `composer ic:process` passes. Detailed static checks pass, including PHPStan and Psalm. Normal-host `composer ic:tests:details` and final `composer ic:release:guard` fail at Pest discovery because CLI APCu is disabled. The guard reports zero dependency advisories and one abandoned development package (`doctrine/annotations`). -- Core smoke passes with OPcache off/on on PHP 8.5.4. Runwire certification and soak pass using the installed checkout (temporary copies change only the autoload path; not a fresh consumer installation); soak validates 2,277 requests. -- Sphinx builds with warnings as errors. `git diff --check` passes. - -**Pre-tag gate:** commit the final changes and obtain green Security & Standards and Release Verification on that exact revision, including PHP 8.4/8.5 stable/lowest dependencies, all configured real services, independent consumers, and portability checks. Earlier green CI below does not cover this working tree. - -## Independent verification of the applied fixes - -Verified 2026-09-29 at clean commit `69845554ab743c9656f84af586207efd390f16d1`. The preceding implementation/CI closure records and the original findings below remain historical evidence; this section records the newest independent result. - -**All nine original reproductions now pass.** Signed atomic consume returns the stored value once without resurrection on memory, File, PHP-files, SQLite, Redis and Memcached. Redis cleanup preserves the replacement, counter state survives clear, closure cycles return normally, the 4,439,019-byte wide payload is rejected without exhaustion, a false-returning L1 stays fenced, empty history clears local state, authentication traces redact the synthetic secret, and independent memoizer flushes preserve other scopes' results. - -The following three cases were reproduced before the working-tree resolution above: - -### V01 — P2: accepted depth does not round-trip (F04 boundary) - -**Reproduced through `Cache::memory()` with `failOpen=false`.** Build a scalar wrapped in 128 nested arrays. `set('x', $value)` returns true, but the immediate `get('x')` returns a miss. Depths 126 and 127 round-trip in the same probe. - -`BoundedValueTraversal` allows this value, while `CachePayloadCodec::unserializeNative()` at `src/Cache/Adapter/CachePayloadCodec.php:322` applies `max_depth=128` to the entire serialized record, including its enclosing array. The accepted value depth and decoder's record depth disagree. The final sweep corrected node-count symmetry but not depth symmetry. - -**Required:** account for envelope depth consistently, or reject the value before reporting a successful save. Test encode/decode and facade set/get immediately below, at and above the effective depth limit, with signing and compression variants. A successful save must not create an intrinsically unreadable record. - -### V02 — P2: an L1 exception bypasses the coherence fence (F07 failure boundary) - -**Reproduced with the same deterministic L1 fixture, changing only its invalidation failure from `false` to an exception.** With `writeToL1=false`, promote `x=old`; write `x=new` to L2 while L1 deletion throws. The facade's default fail-open handling returns false from the write, but subsequent reads still return `old`. The output is `[write=false, read=old, laterRead=old]`; L2 contains `new`. - -`TieredCacheAdapter::invalidateSkippedL1()` at `src/Cache/Adapter/TieredCacheAdapter.php:276` updates `l1Readable` only after the fallible deletion returns. Exceptions escape to the facade before the adapter is fenced. Equivalent throwing save/delete/promotion paths need review; the new regression currently forces the private flag to false and therefore does not verify how an actual failure establishes it. - -**Required:** fence the affected tier on exceptional exits as well as false returns, preserving error policy. Test real control flow with throwing and false-returning fixtures, single/bulk operations, unrelated successful operations and full-clear recovery. Do not weaken the assertion to accept stale data after an unsuccessful update. - -### V03 — conditional recovery gap: recreated history can overlap the old cursor (F05) - -**Reproduced with the real PDO transport in SQLite testing mode.** Consume old IDs 1 and 2, retain cached `x=stale`, recreate the event history with new ID 1 invalidating `x` and new IDs 2 and 3 targeting other keys. With the old cursor and transport identity retained, recovery returns false, consume processes only ID 3, and `x` stays stale. Observed state: `cursor=2, oldest=1, newest=3, recovered=false, consumed=1, value=stale`. - -`ClusterRecoveryManager` detects a reset only when the new upper boundary is below the old cursor. Once the new history overlaps/passes the cursor, boundary comparisons cannot distinguish its epoch. The added test covers a recreated log that remains behind the cursor only. - -**Contract boundary:** the transport-identity documentation already requires a different identity for independent history. A deployment that guarantees identity rotation and a cold clear/rebuild on recreation can exclude this scenario. However, the same-identity automatic-reset recovery tested and described in F05 is only partial. Do not advertise general recreation recovery from these bounds alone. - -**Required:** either implement a durable history epoch/fence, or explicitly require coordinated identity rotation plus local cache/cursor reconciliation for recreated logs and test that supported recovery procedure. Add the overlap case so the limits are explicit. Do not claim the current heuristic proves safe replay after arbitrary history reset. - -### Verification results for this revision - -- [Security & Standards](https://github.com/infocyph/CacheLayer/actions/runs/36526249783) and [Release Verification](https://github.com/infocyph/CacheLayer/actions/runs/36526249302) both succeeded on `6984555`. -- The same 34-file prepared-host subset, with CLI APCu and isolated Redis/Valkey/Memcached services, produced **348 passed, 2 failed, 1,415 assertions**. Both failures still require the unprovisioned Scylla Alternator service; the same four SQL/real-MongoDB files remain outside this subset. -- Normal-host `composer ic:release:guard` still fails at Pest discovery because CLI APCu is disabled. This remains an environment prerequisite, not a newly introduced product regression. -- Core release smoke passed with CLI OPcache disabled and enabled. Updated Runwire certification and soak passed against the installed checkout using temporary copies with only the autoload path adjusted. Certification now reports the expected 3,500 gets and 500 sets; the measurement fix is verified. -- The locked dependency audit reports zero advisories and one abandoned development package, `doctrine/annotations`. -- New local probes: `/tmp/cachelayer40-boundaries.php` (V01/V03) and `/tmp/cachelayer40-throw.php` (V02). Both require the same isolation precautions as the original probes. - -**Historical audit outcome:** V01–V03 required remediation. That remediation is now recorded above; final committed-revision CI remains pending. - -## Scope and evidence - -The checkout contains 114 production PHP files and 38 test files. Review covered the security/correctness changes since 3.4 across codecs, adapter policy, deferred/atomic operations, local and remote adapter families, counters, locks, memoizers, Node/Cluster storage, outbox ordering, cursor recovery, Runwire lifecycle integration, packaging, release workflows and documentation. Automated checks cover their configured repository scope; targeted adversarial probes below exercise gaps in the existing tests. This is not a claim that every possible defect has been excluded. - -| Check | Result on the audited candidate | -| --- | --- | -| `composer ic:doctor`, `ic:list-config`, `ic:active-config` | Setup resolves; doctor reports healthy. | -| Normal-host `composer ic:tests:details` | **Failed:** Pest aborts during discovery with `APCu must be enabled for CLI tests.` No complete host Pest result. | -| Normal-host `composer ic:release:guard` | **Failed:** same CLI APCu prerequisite. | -| Host static/style checks | Syntax, references, skip-directive scanner, duplicate/comment checks, Pint, PHPCS, PHPStan, Psalm, Rector and configured Deptrac passed. Deptrac reports 774 uncovered dependencies; duplicate report has 27 clone groups / 1,169 lines / 7.13%. These passes do not establish complete architecture coverage. | -| Prepared host subset | PHP 8.5.4 with `apc.enable_cli=1`, isolated Redis 8.10, Valkey 9.1 and Memcached 1.6.45: **324 passed, 2 failed, 1,240 assertions** across 34 selected test files. Both failures require an unprovisioned Scylla Alternator service. MySQL, PostgreSQL, SQL-identity/multi-engine and real MongoDB test files were not selected in this local subset. | -| Core release smoke | Passed on host PHP 8.5.4 with CLI OPcache off and on. | -| Runwire worker example | Passed against the checkout's installed Runwire 2.1. | -| Runwire certification and soak | Both passed using temporary copies that point only their autoload line at this checkout. Soak: 2,048 sequential / 256 concurrent requests; 2,277 validated, 10 intentional failures, 9 cancellations, 8 deadlines; retained-memory growth 6,291,456 bytes. This is installed-checkout evidence, not a fresh consumer install. | -| Composer validation/platform | `composer validate --strict` and `composer check-platform-reqs` passed. | -| Live locked dependency audit | Zero reported advisories; `doctrine/annotations` remains abandoned through development tooling. This does not cover defects in this package or all future consumer resolutions. | -| Local documentation build | Not run: the host lacks Sphinx. Exact-commit CI documentation build passed. | -| Remote CI | Both release workflows succeeded on the exact audited commit; details below. | - -GitHub API verification found: - -- [Security & Standards run 36514617642](https://github.com/infocyph/CacheLayer/actions/runs/36514617642): stable/lowest PHP 8.4/8.5 QA, analysis, benchmarks and clean install succeeded. The conditional Security Report job was skipped; the workflow result is success. -- [Release Verification run 36514617203](https://github.com/infocyph/CacheLayer/actions/runs/36514617203): all 14 jobs succeeded, covering Linux/Windows core smoke, clean consumers, independent PSR contracts, docs, real Redis Cluster, real Scylla CQL and Runwire PHP 8.4/8.5 lowest/stable consumers. - -Those runs validate the existing checks, not the additional failing cases below. Host-application production throughput, the complete framework/router integration and production deployment topology were not independently certified here. - -## Required findings - -P1 denotes a release blocker involving security, durable delivery or core atomicity. P2 denotes a correctness/confidentiality issue that must also be resolved before this release is described as fulfilling its current contracts. These priorities are not CVSS scores. - -### F01 — P1: deferred state defeats one-time atomic consumption - -**Reproduced with signing enabled and `failOpen=false` on memory, File, PHP-files, Redis and Memcached.** Store `x=stored`, queue `x=pending` through `saveDeferred()`, call `atomic()->getAndDelete('x')` twice, then commit. Both consumes return `pending`, and commit restores it to storage. SQLite was a control: it returned `stored`, then a miss, and did not resurrect the key. - -The atomic paths use helpers that overlay deferred state, without consistently consuming/discarding that state. Even a backend miss becomes a hit through `genericMiss()`. See [AbstractCacheAdapter.php](../../src/Cache/Adapter/AbstractCacheAdapter.php) (`genericItemFromRecord`, line 298; `genericMiss`, line 316) and [ArrayCacheAdapter.php](../../src/Cache/Adapter/ArrayCacheAdapter.php) (`atomicGetAndDelete`, line 52), plus the equivalent remote/file paths. Backend atomic deletion alone is insufficient when the facade can repeatedly return an unconsumed local value. - -**Change:** define atomic/deferred interaction explicitly in the existing owners. Atomic reads must not turn a backend miss into an unconsumed pending hit; reconcile or reject pending state under a consistent contract. Review set-if-absent and compare-and-set as well. - -**Acceptance:** a shared suite covers pending-only and persisted-plus-pending values, expiration, failure/retry, signing, repeated consume and subsequent commit across every advertised atomic backend. At most one successful consume, with no later resurrection. - -### F02 — P1: clearing namespace `cachelayer` erases unrelated counters and live locks - -**Reproduced on real Redis.** `Cache::redis('cachelayer', client: $redis)->clear()` scans `cachelayer:*`. This also matches the new `cachelayer:counter::` domain and the default `cachelayer:lock:` domain. A counter in namespace `audit-counter` changed from 5 to missing. A 30-second lock was acquired, the cache was cleared, and a second owner successfully acquired the same lock while the first handle remained live. The default invalidation-stream prefix overlaps too (source finding). - -See [RedisCacheAdapter.php](../../src/Cache/Adapter/RedisCacheAdapter.php), `clear()` line 185; [RedisAtomicCounterStore.php](../../src/Counter/RedisAtomicCounterStore.php), `COUNTER_PREFIX`/`map()`; and [RedisLockProvider.php](../../src/Cache/Lock/RedisLockProvider.php), default prefix. `cachelayer` is a valid public namespace. Valkey shares the Redis adapter implementation. - -**Change:** make clear operate only on explicitly owned data/metadata domains, or use a structurally disjoint physical layout. Moving only the counter prefix is insufficient. Preserve operational state in migration/rollback. - -**Acceptance:** clear every boundary namespace, including `cachelayer`, while counters, locks and invalidation streams exist. Counter values/TTLs survive, held locks exclude a second owner, and stream history remains intact. - -### F03 — P1: closure fingerprinting still permits recursive exhaustion - -**Reproduced in isolated PHP processes with a 32 MB memory limit and an 8-second external timeout.** Both examples terminate with memory exhaustion, exit 255: - -```php -$a = []; -$a['self'] = &$a; -$f = static fn() => $a; -memoize($f); - -// Separate process: -$f = null; -$f = static function () use (&$f) { return 1; }; -memoize($f); -``` - -[CallableFingerprint.php](../../src/Memoize/CallableFingerprint.php), lines 67–97, normalizes closure captures without the traversal check used by `value()`. Closure fingerprints are recorded only after traversing captures, so self/mutually captured closures also recurse before being registered. The callback itself need not execute recursively. Input can be entirely legitimate application state; remote exploitability depends on how an application builds closures/captures. - -**Change:** establish identity before traversing captures or avoid traversing captures when instance identity already determines the contract; otherwise use cycle-safe bounded traversal across both arrays and closure references. - -**Acceptance:** recursive captured arrays, self/mutual closures, deep captures and ordinary callbacks terminate safely without fatal errors or unbounded diagnostic output. Preserve intended memoizer hit behavior. - -### F04 — P1: traversal budget is checked after unbounded queue allocation - -**Reproduced through the codec.** An unsigned native record with 350,000 scalar array entries is **4,439,019 bytes**, below the default 8 MB payload limit. Decoding it exhausts a **128 MB** process at `BoundedValueTraversal.php:48`, before it can reject the value against `MAX_NODES=65,536`. A 100,000-entry / 1,189,019-byte record similarly exhausts a 32 MB process. - -[BoundedValueTraversal.php](../../src/Support/BoundedValueTraversal.php), lines 22–30 and 45–51, checks the budget while popping nodes but appends all children first. The helper therefore allocates a frame for each child before enforcing its limit. Backend attack preconditions are unsigned writable data or a writer authorized to produce a signed oversized graph; signing does not solve writer-side resource bounds. - -**Change:** enforce the remaining node/queue budget before adding children and use traversal storage bounded by the stated limits. Retain independent encoded-byte and decompression bounds. - -**Acceptance:** wide, deep, cyclic and aliased graphs fail safely under explicit process memory/time ceilings on encode and decode; modest supported graphs retain their values. Test both sides of the node limit, not only recursive arrays. - -### F05 — P1: fully lost invalidation history leaves stale local values valid - -**Reproduced with the real PDO implementation in its SQLite testing mode.** Consume an event and persist its cursor, cache `x=stale`, publish a later invalidation for `x`, then remove all retained events before the consumer sees it. `recoverIfRequired()` returns false and the cached stale value remains readable. - -[ClusterRecoveryManager.php](../../src/Cluster/Recovery/ClusterRecoveryManager.php), lines 30–33, returns early whenever the oldest event is null. A previously consumed cursor plus empty history is not proof that no invalidation was missed. Reset histories with IDs behind a stored cursor also need an explicit contract; that related path is identified by source review, not claimed as separately reproduced here. - -**Change:** distinguish a never-used transport from history loss/reset, with a durable epoch/high-watermark or another concrete recovery protocol. Reconcile local state before treating an unprovable cursor as current. Avoid repeated unnecessary clears of known-empty history. - -**Acceptance:** full retention loss, stream deletion/recreation, reset sequence IDs, restart and normal empty startup preserve safety on real Redis and SQL transports, with a documented recovery position. - -### F06 — P2: stale-read cleanup can delete a concurrent replacement - -**Reproduced with real Redis and a deterministic read interleaving.** A Redis subclass performs the actual GET, writes a valid replacement through the actual connection before returning the observed invalid payload, and lets normal adapter code continue. `getItem()` then deletes by key; the next read misses instead of returning the valid replacement. - -[RedisCacheAdapter.php](../../src/Cache/Adapter/RedisCacheAdapter.php), lines 228–238, still uses unconditional DEL in the single-key path. Bulk cleanup already uses `RedisValueGuard`. The facade also unconditionally deletes stale-tag keys in [Cache.php](../../src/Cache/Cache.php), lines 913 and 940; a separate tagged-read interleaving should be a required regression because the same race can bypass adapter-local fixes. - -**Change:** use compare-delete on the exact observed record where supported, or leave physical cleanup to safe bounded maintenance. Audit both adapter and facade cleanup owners. - -**Acceptance:** a valid replacement inserted between read/validation and cleanup survives single, bulk and tagged reads; ordinary stale records still produce misses. Exercise Redis/Valkey and each applicable backend contract. - -### F07 — P2: an unrelated successful operation re-enables stale L1 entries - -**Reproduced with a deterministic L1 failure fixture and the real TieredCacheAdapter.** Promote `x=old`; fail L1 invalidation while writing `x=new`; the adapter correctly fences L1 and reads `new`. Restore L1 deletion, then successfully write unrelated key `y`. The next read of `x` returns `old`. - -[TieredCacheAdapter.php](../../src/Cache/Adapter/TieredCacheAdapter.php), `invalidateSkippedL1()` line 276, sets the global `l1Readable` flag from the latest key's result. `finishL1Write()` and successful individual deletes likewise restore global readability without clearing every stale entry. - -**Change:** keep L1 fenced until a successful full reconciliation/clear, or track invalidity at an appropriate per-key scope. An unrelated successful operation does not establish whole-tier coherence. - -**Acceptance:** after failures for one or several keys, unrelated saves/deletes and partial recoveries cannot expose stale values. Include bulk and multi-tier configurations. - -### F08 — P2: Redis authentication secrets remain exposed in exception traces - -**Reproduced on real Redis using only a synthetic secret.** With `zend.exception_ignore_args=0` and `zend.exception_string_param_max_len=128`, an authentication error exposes `AUDIT_SENTINEL_40` in the `RedisConnection::authenticate(Object(Redis), 'AUDIT_SENTINEL_40')` frame. The public DSN and native Redis auth frames are redacted, but the intermediate helper is not. - -See [RedisConnection.php](../../src/Support/RedisConnection.php), line 44. This affects applications that capture exception arguments or render detailed traces; default trace-display settings may hide it without fixing the stored arguments. - -**Change:** mark the intermediate credential parameter sensitive and trace secret-bearing arrays/parameters throughout authentication and connection failures. Keep error messages and previous exceptions sanitized. - -**Acceptance:** synthetic password and ACL credentials are absent from rendered traces and inspectable unredacted argument values on authentication, connection, parsing and database-selection failures. - -### F09 — P2: flushing one request invalidates another request's memoizer identities - -**Reproduced through Runwire request scopes and separately through two isolated Memoizer instances.** Request A memoizes a retained callback returning incrementing values. Request B creates its memoizer and calls `flush_memoizers()`. Request A calls the same callback again and gets 2 rather than its existing cached 1: observed `[first=1, second=2, executions=2]`. - -[Memoizer.php](../../src/Memoize/Memoizer.php), line 44, and [OnceMemoizer.php](../../src/Memoize/OnceMemoizer.php), line 35, both call the process-global [CallableFingerprint::flush()](../../src/Memoize/CallableFingerprint.php) at line 47. This removes identity mappings still used as keys by other live request-owned memoizers. Request attributes isolate value storage but do not isolate identity resets. - -**Change:** keep object/closure identities stable for their lifetime across independent memoizer flushes, or scope identity ownership consistently with memoizer state. Do not introduce an unbounded strong-reference registry. - -**Acceptance:** flushing/completing/failing one request does not change another request's `memoize`, object `remember`, or `once` behavior. Test interleaved scopes, ordinary isolated instances, and bounded collection of dead objects. - -## Remediation and release gates - -The original F01–F09 release sweep completed on the working branch; the V01–V03 follow-up above still needs final committed-revision CI. Historical substantive sweep head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156; the final documentation-only tracker head is reverified before tagging. - -| Finding | Remediation status | Evidence | -| --- | --- | --- | -| F01 | **Complete** | Atomic consume paths discard deferred overlays; cross-backend regressions cover repeated consume and no resurrection. | -| F02 | **Complete** | Redis/Valkey clear is restricted to cache data/metadata domains; boundary tests preserve counters, locks and invalidation streams. | -| F03 | **Complete** | Closure fingerprints no longer traverse captures; recursive/self-capture regressions were added. | -| F04 | **Resolved locally, including V01** | Traversal is depth-first and budgeted before descent; decode applies independent bounded traversal to value and tag graphs so the value budget round-trips consistently. | -| F05 | **Resolved locally under the V03 identity-rotation contract** | Recovery handles empty/reset history and retained upper boundaries for PDO and Redis transports. | -| F06 | **Complete** | Redis stale cleanup uses compare-delete and facade tag validation no longer performs unsafe physical deletion. | -| F07 | **Resolved locally, including V02** | Tiered L1 readability is a monotonic fence until full reconciliation/clear. | -| F08 | **Complete** | Redis DSN/authentication credential-bearing parameters are marked sensitive and synthetic-secret regressions cover traces. | -| F09 | **Complete** | Memoizer flushes no longer reset process-global object/closure identities used by other live request scopes. | - -Remediation QA closure: the earlier run on `ac5594370c0020ff14be1817bc227c02fb5b117b` exposed stale tagged-read expectations, an outdated Runwire transport fake, F04 round-trip budget asymmetry, and two Pint issues. Those were corrected; exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153, and tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` passed #514/#154. - -- [x] Correct F01–F05 in the affected production owners and add targeted regressions. -- [x] Correct F06–F09 and add their failure/interleaving regressions. -- [x] Recheck the related original findings in code/tests: R01/R08 traversal, R04/R11 atomic/deferred state, R06/R07 recovery, R10 redaction, R13 tier coherence, R14 counter isolation and Batch 7 request isolation. -- [x] Extend regression coverage across affected backend implementations rather than testing only helper classes. -- [x] Run the configured PHPForge stable/lowest matrix with its declared service prerequisites; keep separate host-only limitations documented rather than disguising missing services as passes. -- [x] Re-run core, independent PSR consumer, Runwire lifecycle/certification/soak, real backend, documentation and configured stable/lowest checks on substantive head `97957893ab1459e365526dab2b913131021489fe`: Security & Standards #513 and Release Verification #153 passed. -- [x] Update plan completion claims after the reopened cases passed exact-head substantive verification. Green CI on `51fcba7` remains historical evidence for the pre-remediation candidate; #513/#153 are the remediation evidence. -- [x] Final sweep corrected tag-budget encode/decode symmetry, Runwire certification metrics/timing, and stale release-contract documentation; substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156. - -Additional verification improvements: **resolved in the final sweep.** The Runwire certification now reads the nested exported metrics correctly, excludes warmup from the measured RPM denominator, reports warmup separately, and fails if measured get/set counts do not total the configured iteration count. Release Verification #156 recorded 3,500 gets + 500 sets for both baseline and integrated 4,000-iteration workloads. Keep this short CLI workload separate from sustained host-application throughput claims. The configured Deptrac uncovered-dependency count remains visible despite a passing gate and is retained as tooling-coverage follow-up, not hidden by exclusions. - -## Local reproduction artifacts - -The audit used bounded processes and isolated data; it did not run exhaustion probes inside an application worker. Temporary Redis/Valkey/Memcached containers were created solely for this audit and removed afterward. - -Local artifacts retained for the current workspace session: - -- `/tmp/cachelayer40-probe.php`: closure cycles, traversal, isolated/request memoizer flush and deferred policy probes. -- `/tmp/cachelayer40-wide.php`: larger codec traversal reproduction (run `wide` with `memory_limit=128M`). -- `/tmp/cachelayer40-more.php`: real Redis cleanup/counter probes, tier recovery and empty history reproduction. -- `/tmp/cachelayer40-atomic.php`: signed cross-backend atomic/deferred reproduction. -- `/tmp/cachelayer40-quality.log`, `/tmp/cachelayer40-release-guard.log`, `/tmp/cachelayer40-prepared-tests.log`. -- `/tmp/cachelayer40-certify.log`, `/tmp/cachelayer40-soak.log`, `/tmp/cachelayer40-audit.json`, `/tmp/cachelayer40-ci.json`. - -The network probes require fresh isolated services and their configured loopback ports; never point them at a shared or production Redis database because they deliberately exercise clear and invalidation failure cases. Promote the reproductions into permanent regressions during remediation. Temporary artifacts are not a substitute for committed tests. diff --git a/docs/plans/cachelayer-4.0-security-correctness-plan.md b/docs/plans/cachelayer-4.0-security-correctness-plan.md deleted file mode 100644 index acf17279..00000000 --- a/docs/plans/cachelayer-4.0-security-correctness-plan.md +++ /dev/null @@ -1,550 +0,0 @@ -# CacheLayer security, correctness, and release plan - -Date: 2026-09-28 -Status: Original re-audit reproductions fixed; independent verification leaves V01–V03 resolved locally; final CI pending -Audited revision: `b064b8196ddc4672ce37be252bc7a4cadb78527e` (local tag `3.4`) -Release target: **4.0.0 — next major release** - -## Latest release re-audit - -The candidate `51fcba79ebac14b7ddb767e80c724a1eea485e9e` passed both configured CI workflows, but adversarial rechecking reproduced nine findings. Remediation for F01–F09 is now implemented on `feature/improvements`; **hold the 4.0.0 release until the corrected exact head passes the full stable/lowest and release-verification gates.** See the [release re-audit report](cachelayer-4.0-release-reaudit.md) for the live remediation tracker. - -## Latest independent verification - -Verification of `69845554ab743c9656f84af586207efd390f16d1` confirmed all nine original reproductions were fixed and both CI workflows passed, then identified V01–V03. Those follow-ups are now resolved in the working tree: accepted codec depth round-trips, tier mutation/promotion failures fence all upper tiers, and new history identities cold-clear local state before runtime exposure. Recreated/restored event logs require coordinated identity rotation and reconciliation of every APCu/L1 domain; arbitrary same-identity resets are unsupported. The targeted suite passes 102 tests / 427 assertions. See the [resolution and validation record](cachelayer-4.0-release-reaudit.md#resolution-of-v01v03-working-tree-2026-09-29) for prepared-host results and remaining environment limits. **Final committed-revision Security & Standards and Release Verification remain required before tagging 4.0.0.** - -## Implementation tracker - -Updated: 2026-09-29 -Working branch: `feature/improvements` -Draft PR: [#29 — CacheLayer 4.0 security and correctness hardening](https://github.com/infocyph/CacheLayer/pull/29) - -| Batch | Findings | Status | Current gate | -| --- | --- | --- | --- | -| 1 — Security and transaction containment | R01, R03, R04, R05, R09, R10 | **Complete** | Implemented and verified on exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e`; Security & Standards run #173 passed. | -| 2 — Authenticated payload/storage identity | R02, R15, R18 | **Complete** | Implemented and verified on exact commit `1924a74da3b9d6474696631405e839bd52ec158b`; Security & Standards run #210 passed. | -| 3 — Durable invalidation protocol | R06, R07 | **Complete** | Implemented and verified on exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e`; Security & Standards run #240 passed. | -| 4 — Cache contracts and memoization | R08, R11, R12, R13, R16, R17 | **Complete** | Implemented and verified on exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1`; Security & Standards run #312 passed. | -| 5 — Counters and backend races | R14 plus race review | **Complete** | Implemented and verified on exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee`; Security & Standards run #352 passed. | -| 6 — Release gates and integration | R19 plus core release acceptance | **Complete** | Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. | -| 7 — Runwire 2.1 integration | Required runtime integration workstream | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105, including PHP 8.4/8.5 lowest/stable Runwire consumer certification and soak gates. | -| 8 — Release re-audit remediation | F01–F09 | **Complete** | Exact substantive head `97957893ab1459e365526dab2b913131021489fe` passed Security & Standards #513 and Release Verification #153; tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` passed Security & Standards #514 and Release Verification #154. | -| 9 — Final release sweep | Codec boundary symmetry, Runwire certification evidence, release-contract docs | **Complete** | Exact substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156. Certification now reports measured nested metrics correctly and excludes warmup from measured RPM. | - -### Batch 1 tracker - -| Finding | Implementation | Regression evidence | QA state | -| --- | --- | --- | --- | -| R01 — bounded recursive traversal | Complete | Added bounded subprocess coverage for direct/mutual cycles, deep input, signed/unsigned/compressed records; shared traversal guard also protects callable fingerprints | Passed final Batch 1 QA. | -| R03 — immutable adapter policy | Complete | Shared-adapter conflicting-policy regression added | Passed final Batch 1 QA. | -| R04 — atomic file consume deletion | Complete | File and PHP-files consume failure coverage added with warning-free deterministic failure handling | Passed final Batch 1 QA. | -| R05 — Node SQLite transaction ownership | Complete | Caller-owned transaction preservation regression added | Passed final Batch 1 QA. | -| R09 — filesystem trust boundaries | Complete for Batch 1 scope across File/PHP-files roots and locks, Node/PDO SQLite paths, FileLockProvider, and shared-memory token paths | Symlink-root regression added; filesystem owners were audited beyond the initially reproduced PHP-files path | Passed final Batch 1 QA; broader cross-platform release coverage remains under release acceptance. | -| R10 — secret redaction | Complete for Redis/Valkey DSNs, PDO credentials/DSNs, MongoDB URI creation, integrity/signing keys, and tier descriptors | Error/trace and `#[SensitiveParameter]` regression coverage added | Passed final Batch 1 QA. | - -**Batch 1 closure evidence:** exact commit `5a9f4bd553b4d97cca72d05affa32b6e7ce3c37e` passed Security & Standards run #173: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. During Batch 1 QA the inherited skip-directive and reference-integrity failures were resolved without weakening PHPForge gates. - - -### Batch 2 tracker - -| Finding | Implementation | Regression evidence | QA state | -| --- | --- | --- | --- | -| R02 — authenticated payload identity | **Complete** | Bound `cache-record:v3` HMAC envelopes authenticate purpose, logical storage identity, and key. Cross-key/cross-namespace substitution, legacy unbound signed payloads, malformed/wrong-key payloads, tier promotion, secure defaults, and adapter/atomic verification paths have regression coverage. | Passed final Batch 2 QA on run #210. | -| R15 — Node storage/topology identity | **Complete** | Node APCu and lock identities include the SQLite store; `NodeCacheConfig` carries one cohesive `CacheOptions` policy; failed L1 mutations fence that L1 from later reads so stale promoted state cannot override authoritative SQLite. Batch 3 additionally established full invalidation cursor scope across cluster, node, namespace, and transport identity. Separate PHP SAPIs/processes still require their own consumer lifecycle; no cross-process APCu coherence is implied. | Core identity/L1 behavior passed Batch 2 run #210; topology follow-through passed Batch 3 run #240. | -| R18 — SQL binary identity | **Complete** | New MySQL/MariaDB cache and invalidation schemas create identity columns as `ascii_bin`; existing schemas are metadata-checked and hardened only when required, avoiding repeated identity `ALTER TABLE` work. Backend regressions verify byte-sensitive identity and case-distinct namespaces/keys. | Passed final Batch 2 QA on run #210. Consolidated upgrade/rollback instructions were completed in Batch 6. | - -**Batch 2 implementation/closure commits:** `df108d47309b84c9334b5747e08172b03460933b` (contracts), `a0da9349ecffe94d3e4491c8ca149fe37b31d710` (Node identity/topology), `0f9cce33e2763910b637709e534373f634af9d75` (logical storage identity propagation), `47e0f05cf0245f7d69bbc12a892f3ddbcc81ea99` (bound signed envelope and secure serialization defaults), `3ee8e84ca21ce2eac63a0b1552750ef83beb7556`, `edef15420895cee62b179c7fa9d6513479b3b336`, `045d5ff89949370744f6d95646165a9b11efd721`, `f2c3e55db67ecc3237dc87494c2828c66f33d5ee`, and `7e4a26f9f3c9e0ae4902229edc68a4429fbdaef0` (adapter/atomic identity verification and codec hardening), `8b2dbfffa7805e8bcd8b31f4ee527ba20ef91b01` and `62f984c13c342c9faa37400cd4a6a262c3f627a3` (SQL/shared-memory identity), `64fc9ba5fcdfceb12e92efd2912192486e0a1cd6` through `1924a74da3b9d6474696631405e839bd52ec158b` (final schema idempotence, unified Node policy, L1 coherence fencing, regressions/docs, and exact PHPForge formatting). - -**Batch 2 closure evidence:** exact commit `1924a74da3b9d6474696631405e839bd52ec158b` passed Security & Standards run #210: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed. - -### Batch 3 tracker - -| Finding | Implementation | Regression evidence | QA state | -| --- | --- | --- | --- | -| R06 — durable PDO invalidation ordering | **Complete** | PDO publication acquires a per-cluster transactional lock before event-ID allocation, so later publishers cannot commit a higher ID ahead of an earlier same-cluster transaction. Real MySQL/PostgreSQL multi-process coverage verifies blocked reversed-order publication, rollback, publisher process death, and subsequent ordered replay. Different clusters retain independent lock domains. | Passed final Batch 3 QA on run #240. | -| R07 — full cursor-scope ownership | **Complete** | SQLite cursor storage is versioned as v3 and keyed by cluster + node + namespace + transport identity. Regressions cover same-node multi-namespace isolation, independent transport histories with overlapping event IDs, restart persistence, reset behavior, retention recovery, and legacy `(cluster,node)` plus intermediate v2 `(cluster,node,namespace)` migration. Legacy progress is never copied into the new scope; affected local cache state is cleared before new progress is established. | Passed final Batch 3 QA on run #240. | - -**Batch 3 implementation/closure commits:** `965d90ea9fbc6e39f77988e3aef4900a792b30c9` through `6a658362f6dc777f1f0f50a196289d7ef1ef26c0`, plus `2d915a90e22937e8dc68a48826ae40599e9f66f3`, `831229c22a9f24f59ac2814e7a0476c55fa615f6`, and `c5bc44c50b507ed8fd3d3d28be2ce6915e9b8352` (initial scoped-cursor recovery), `4b885a133b8ec08dd0ed855e502a708f705e27fc`, `fa131a8278294dba3e290c761bf017bb7e2cd5ed`, `ffcaec51f83b9a9d2a9dfbffbdb41e57f9ad019a`, `2b2ac8e04a1168204b397f25555e4f5221b0f5a8`, `63d8fe94ec7df44c5238c527116945fea3a6a2bb`, `13e6ec0e13395b3f6e4fdedf6e04a05480d75474`, `9b95a3ec8ecea5dd5da4e87f9ce1a9b7a3b23d6a`, and `4b6066dc530369b8b3dcc77088919091de6173d6` (R06 publication locking, transaction ownership, real MySQL/PostgreSQL process isolation, and QA cleanup), `5c3410c4755f1ce224cb87f0c421b729cfc5aaf7`, `e28335cd8776addeb42aae669a4901fc3f5826cf`, `d40a0d27db9eaf514ee12d2747a170252a7f179e`, `f6e89c6e15cc91be73d2f8f276f4dbf18ecf1072`, `ff90d2fdf7b7893ec18738567f4314d87bce76f2`, `a8ef884bcd738421ab0bfb80a108984497911d30`, `2917d45b7db368a53aedeb4eb60c82c0c9857174`, `dedf45b1c0698c7cdd0a69f4495a12d895764fce`, and `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e` (transport-aware v3 cursor scope, safe legacy/v2 recovery, restart/reset tests, docs, and exact PHPForge ordering). - -**Batch 3 closure evidence:** exact commit `3046e91fdc64bd2f1c9e8bd56a6d3dfc96057b7e` passed Security & Standards run #240: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. The run includes real MySQL/PostgreSQL concurrent publication tests, cursor-scope/migration regressions, Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates. All PR code-scanning review threads were resolved on the verified head. - - -### Batch 4 tracker - -| Finding | Implementation | Regression evidence | QA state | -| --- | --- | --- | --- | -| R08 — memoizer identity | **Complete** | Callable/value fingerprints are type-tagged; same-line closures, object/string/resource separation, weak lifetime-safe object identity, and request-reset behavior are covered. | Passed final Batch 4 QA on run #312. | -| R11 — deferred PSR-6 lifecycle | **Complete** | Pending reads are visible before commit; queued values are snapshotted; immediate overwrite/delete/clear reconcile pending state; failed commit state is retained for retry; composed pools use logical item keys. | Passed final Batch 4 QA on run #312. | -| R12 — numeric-string keys/tags | **Complete** | Logical keys/tags are preserved through internal identity encodings and item identities rather than relying on PHP array-key coercion. Numeric-string key/tag, batch, deferred, tier, and boundary cases are covered. | Passed final Batch 4 QA on run #312. | -| R13 — skipped-L1 coherence | **Complete** | Writes that skip L1 invalidate or fence upper-tier state; failed L1 mutation/promotion prevents stale L1 reads until coherence is re-established. Single and batch regressions cover promoted stale values. | Passed final Batch 4 QA on run #312. | -| R16 — Memcached >30-day TTL | **Complete** | One centralized conversion handles ordinary, bulk, atomic, and lease expiration semantics around the 30-day cutoff, including overflow guards. | Passed final Batch 4 QA on run #312. | -| R17 — direct PSR contracts | **Complete** | Direct pools validate public PSR keys consistently; missing deletes succeed, null/expiry/deferred behavior is aligned, and APCu/bulk delete semantics are normalized. | Passed final Batch 4 QA on run #312. | - -**Batch 4 intermediate QA evidence:** Security & Standards run #266 exposed deferred/bulk, Memcached TTL, PHPStan/Pint/Rector regressions. Those failures were resolved before the exact-head closure gate; run #266 is retained here as historical implementation evidence, not current status. - - -**Batch 4 closure evidence:** exact commit `02078be9e29876fd74d8cbd2fa6947e247cb8bc1` passed Security & Standards run #312: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates all passed. Deferred PSR-6 state is visible before commit, immediate writes/deletes/clear reconcile queued state, numeric-string key/tag handling no longer relies on PHP array identity, Tiered/Node bulk copies use logical item keys, skipped-L1 writes fence stale upper-tier data, Memcached long TTLs are normalized, and direct-pool contract regressions are covered. - -### Batch 5 tracker - -| Finding | Implementation | Regression evidence | QA state | -| --- | --- | --- | --- | -| R14 — Redis/Valkey counter isolation and exact integers | **Complete** | Counters use the dedicated `cachelayer:counter::` keyspace, so ordinary cache clear cannot erase them. Lua returns the exact post-INCRBY decimal string from the same atomic operation; malformed/out-of-range values fail closed. Redis and Valkey cover >2^53 values, PHP integer limits, decrement, fixed-window TTL, and concurrent initialization. | Passed final Batch 5 QA on run #352. | -| Backend race review | **Complete for planned Batch 5 scope** | Redis/Memcached stale cleanup is compare-safe; unsafe cleanup on other backends is deferred to safe maintenance; PDO expiry pruning uses an expiry predicate; tag initialization races are resolved across APCu/PDO/MongoDB/Node/SharedMemory/Scylla/Redis/Redis Cluster/Memcached. Existing lock-ownership, one-winner, partial-pipeline and backend-status tests cover lease loss, consumption, and partial failures. | Passed final Batch 5 QA on run #352. | - - -**Batch 5 closure evidence:** exact commit `d2f0bbb356690a8acb8d9fd9532822c453f953ee` passed Security & Standards run #352: clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs. Pest, Pint, PHPCS, Deptrac, Rector, skip-directive, reference-integrity, duplicate-code, and comment-policy gates passed. Counter precision/isolation, fixed-window TTLs, concurrent initialization, compare-safe stale cleanup, tag-initialization races, lock ownership, partial bulk failure, and backend false/error handling are covered by the final batch state. - -### Batch 6 tracker - -| Gate | Status | Evidence / next step | -| --- | --- | --- | -| R19 — support matrix and tooling | **Complete** | PHPForge runs PHP 8.4/8.5 analysis, benchmarks, and stable/lowest QA without changing its hard limits. Real MongoDB integration is in the QA matrix; release verification adds real Redis Cluster and Scylla CQL plus Linux/Windows core smoke. | -| Documentation / migration / rollback | **Complete** | `docs/upgrade-4.0.rst`, `docs/release-4.0.rst`, serializer/security guidance, cursor/counter/storage cutover instructions, and rollback guidance cover the intentional 3.x→4.0 break. | -| Packaging / consumer / docs | **Complete** | Clean no-dev consumers on PHP 8.4/8.5, independent PSR-6/PSR-16 integration suites, docs with warnings as errors, and cross-platform core smoke all pass on the exact implementation head. | -| Runwire 2.1 integration | **Complete in Batch 7** | Required 4.0 integration ships while Runwire remains an optional consumer dependency. Automatic capability use, normal fallback, worker/request lifecycle ownership, executable evidence, certification, and soak gates are complete. | - -**Batch 6 closure evidence:** exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46. The security workflow passed clean install, PHP 8.4/8.5 analysis, PHP 8.4/8.5 benchmarks, and all four stable/lowest QA jobs under the unchanged PHPForge limits. Release verification passed PHP 8.4/8.5 Linux and Windows core smoke, clean no-dev consumers, independent PSR-6/PSR-16 contracts, documentation warnings-as-errors, real Redis Cluster, and real Scylla CQL. Real MongoDB integration is exercised in the PHPForge service matrix. No unresolved PR review threads remained at closure. - -## Decision - -The planned 4.0 work was implemented and passed its existing CI gates, but the 2026-09-29 re-audit reopens security, atomic/deferred state, recovery, tier coherence and request-isolation requirements. F01–F09 and the V01–V03 follow-ups are resolved locally; final committed-revision CI is still required for release sign-off. The Runwire certification workload is a bounded CI regression/correctness gate, not a production-throughput claim; broader production-equivalent measurement remains a separate operational follow-up. - -Target **4.0.0** for the complete plan, as explicitly selected by the maintainer. CacheLayer 4.0 has **no backward-compatibility preservation requirement with 3.x**: public API shape, named parameters, defaults, storage formats, schemas, and behavioral contracts may change when a cleaner, safer, or more coherent design results. Patch backports and an alternative minor release are outside this plan. Avoid unrelated rewrites, but do not retain legacy contracts solely for BC. - -Persisted-state transitions still require explicit migration/upgrade notes where operators could otherwise lose or misinterpret stored data. Mixed-version compatibility is not a release requirement; coordinated cutover or cold-cache migration is acceptable when it produces the stronger design. CacheLayer 4.0 raises the minimum runtime to PHP 8.4 by maintainer decision. The release matrix therefore targets PHP 8.4 and 8.5. Preserve PSR contracts where required by the interfaces themselves, not for 3.x compatibility. - -Runwire 2.1 integration is a required 4.0 feature and is complete. When the hosting framework binds an active Runwire runtime and shares its request/task scope, CacheLayer automatically uses the relevant available capabilities; otherwise it uses the normal execution path. Runwire remains optional as a consumer dependency and the core package continues to work without it installed. - -This document follows [PHPForge engineering principles](../../vendor/infocyph/phpforge/resources/engineering-principles.md) and the applicable [PHPForge AGENTS.md workflow](../../vendor/infocyph/phpforge/resources/AGENTS.md). - -## Scope and evidence boundaries - -The review covered the 101 production PHP files by subsystem, the 32 test/support files, six benchmark files, public documentation, Composer configuration, and the CI wrapper. Areas reviewed include all adapter families, PSR-6/16 facade behavior, atomics, locks, serialization, counters, memoizers, Node Cache, Cluster Cache, invalidation transports, cursors, maintenance, and metrics. The existing graphify graph was used for orientation; findings were checked against current source and probes. - -The pre-existing `composer.json` edit changing `mongodb/mongodb` from `^1.20 || ^2.0` to `*` was preserved. No production code, test code, dependency versions, or release tags were changed by this audit. - -Evidence labels: - -- **Reproduced:** an isolated probe executed the library behavior locally. -- **Protocol reproduced:** the database behavior was reproduced with the SQL pattern used by the implementation; this is not a complete PHP integration test. -- **Source finding:** the code path is identified, but its affected production backend or race has not been exercised here. - -Security impact depends on the stated preconditions. This audit does not claim remote code execution without backend/filesystem access, a vulnerable application integration, or another specified trust-boundary violation. It is not a guarantee that every possible defect has been discovered. - -## Baseline checks - -| Check | Result and meaning | -| --- | --- | -| Normal host | PHP 8.5.4 CLI; Composer 2.10.3 | -| `composer ic:doctor` | Two warnings: missing `pdo_mysql` and `pdo_pgsql`; inherited runtime matrix 8.4/8.5 | -| `composer ic:list-config`, `composer ic:active-config` | Tool configuration resolves from installed PHPForge | -| `composer ic:tests:details` | **Failed overall** | -| Normal-host Pest within that run | **208 passed, 9 skipped, 645 assertions** | -| Skip-directive detector | **24 findings**: 8 PHPUnit and 16 Pest directives; these are separate from the nine runtime skips | -| Reference detector | **10 findings**: nine Cassandra-related references and one test-helper PSR-4 mismatch | -| Syntax, Pint, PHPCS, PHPStan, Psalm, Rector dry run, comment detector | Passed in the installed configuration | -| Duplicate detector | Passed its current gate, but reported **21 clone groups / 946 duplicated lines / 7.00%** | -| Deptrac | Zero violations, **677 uncovered dependencies**; passing this configuration does not establish meaningful package boundaries | -| `composer validate --strict`, `composer check-platform-reqs` | Passed; this does not prove optional backend prerequisites are available | -| `composer audit --locked --format=json` | Zero advisories; abandoned `doctrine/annotations` through development dependency `phpbench/phpbench` | -| `composer ic:release:constraints`, `composer ic:release:audit` | Passed; PHPForge reports abandonment as a non-blocking warning | -| Prepared Redis/Memcached/APCu test subset | **43 passed, 99 assertions**, using host PHP with `apc.enable_cli=1` and disposable services | -| Backend probes | Redis 8.10, Memcached 1.6.45, PostgreSQL 18 images already on the machine; all temporary containers stopped and removed | - -The prepared subset used: - -```sh -IC_REDIS_HOST=127.0.0.1 IC_REDIS_PORT= \ -IC_MEMCACHED_HOST=127.0.0.1 IC_MEMCACHED_PORT= \ -php -d apc.enable_cli=1 vendor/bin/pest \ - --configuration vendor/infocyph/phpforge/resources/pest.xml \ - --bootstrap vendor/autoload.php \ - tests/Cache/RedisCachePoolTest.php \ - tests/Cache/MemcachedCachePoolTest.php \ - tests/Cache/ApcuCachePoolTest.php -``` - -An initial direct Pest attempt without the bundled configuration failed with `Could not read XML from file "--cache-directory"`; the explicit configuration above resolved it. The sandbox could not start (`bubblewrap: mountinfo path is not absolute`), so approved host execution was used. These were tooling issues, not library test failures. - -No complete MySQL/MariaDB, MongoDB, Scylla CQL, Redis Cluster, Windows, PHP 8.4/8.5, clean production consumer, documentation build, sustained-RPM benchmark, or worker soak gate passed during this audit. `sphinx-build` was unavailable. PostgreSQL testing below exercised transaction ordering through `psql`, not the library's PDO driver. Current CI on a future final revision remains required. - -## Required findings - -P1 means fix before publishing the complete release because security, isolation, data integrity, or durable delivery is affected. P2 means a required correctness or reliability fix. These are engineering priorities, not CVSS scores. - -### R01 — P1: recursive values can terminate the worker - -**Reproduced.** `CachePayloadCodec::assertNativeValueSupported()` and `containsUnsupportedDecodedValue()` recurse through arrays without cycle detection or a traversal budget (`src/Cache/Adapter/CachePayloadCodec.php:114,144`). Native unserialization's depth limit does not prevent a shallow reference cycle from being traversed forever afterward. `CallableFingerprint::values()` has the same unbounded traversal pattern. - -A 156-byte native record containing a self-reference exhausted a 32 MB PHP process with `allowObjects=false`, `allowClosures=false`, and `maxPayloadBytes=1024`. Encoding the recursive value also exhausted memory. The decode precondition is a writable unsigned backend, or a trusted writer that can generate such data; integrity verification prevents an unsigned attacker from reaching decoding when signing is enabled. - -**Change:** make value traversal cycle-safe and bounded before recursive normalization. Preserve lossless supported values where practical; otherwise reject safely before process exhaustion. Apply the same invariant to fingerprint normalization. Keep byte limits and decompression limits; they solve different problems. - -**Acceptance:** isolated subprocess tests for direct/mutual references, very deep arrays, repeated references, and signed/unsigned/compressed records complete within explicit time and memory bounds. Ordinary nested data retains its exact type/value. No fatal error or unbounded output is acceptable. - -### R02 — P1: signatures do not bind values to their cache identity - -**Reproduced.** `CachePayloadCodec::attachSignature()` authenticates payload bytes, but the record contains neither the logical key nor the logical namespace (`CachePayloadCodec.php:77,133`; `AbstractCacheAdapter.php:153`). Copying Alice's signed SQLite payload onto Bob's row returned Alice's `['role' => 'admin']` under Bob's key while `hasPayloadIntegrity()` returned true. - -The attack requires backend write access and access to a valid payload signed with the same secret. This is payload substitution, not HMAC forgery. Signing also does not prevent replay of an older valid value under the same key. - -**Change:** introduce a versioned authenticated envelope bound to an unambiguous logical namespace/key and format purpose. Validate identity before value deserialization. Use logical identity that survives legitimate tier promotion. Define the authentication-state contract separately from ordinary cache integrity; do not imply replay resistance from HMAC alone. - -**Acceptance:** cross-key, cross-namespace, and cross-purpose copies are rejected; legitimate same-key tier promotion works. Wrong-key, unsigned, malformed, compressed, expired, and old-format cases have explicit behavior. Legacy unbound signed payloads must not silently satisfy the new bound-integrity contract. Document cold-cache migration and coordinated rollout. - -### R03 — P1: adapter reuse can silently replace a facade's security policy - -**Reproduced.** `AbstractCacheAdapter::configureOptions()` freezes policy only once the codec exists (`:50`). Constructing a strict, signed facade and then another facade over the same unused adapter replaces its options. The first facade still advertises payload integrity and accepts objects despite `allowObjects=false` in its own options. - -**Change:** establish immutable effective policy at initial binding, or reject conflicting adapter reuse before either facade is operational. Propagate this rule through tiered and Node adapters. Ensure capabilities report the policy actually enforced by storage; review weak-reference paths that do not instantiate the codec. - -**Acceptance:** two facades sharing an adapter cannot disagree about integrity, serialization policy, or backend behavior. Equal configurations remain usable if intentionally supported. Test configuration before first use and after scalar/object/weak-reference operations. - -### R04 — P1: file consume returns the value even when deletion fails - -**Reproduced for File; same code defect in PHP-files.** Both `atomicGetAndDelete()` methods ignore `deleteItemUnlocked()`'s boolean result (`FileCacheAdapter.php:51`; `PhpFilesCacheAdapter.php:50`). With a readable but non-writable data directory, two consumes returned `"usable"`, including with `failOpen=false`. - -**Change:** return a consumed value only after successful deletion while holding the key lock. Report backend failure through the configured policy. Audit other atomic mutations for unchecked write/delete results and incomplete writes. - -**Acceptance:** filesystem permission failure, failed unlink, disk-full/partial writes, and competing processes cannot yield multiple successful consumptions. Strict mode throws the backend exception; fail-open returns the defined miss/failure result, never an unconsumed value. Cover File and PHP-files independently. - -### R05 — P1: Node SQLite rolls back caller-owned transactions - -**Reproduced.** `NodeSqliteCacheAdapter::saveMany()` unconditionally starts a transaction and catches the already-active-transaction error by calling `rollBack()` (`:302`). The rollback helper rolls back any active transaction. A business insert made before `saveItems()` disappeared and `inTransaction()` became false. `deleteItems()` also rolls back on error without establishing ownership. - -**Change:** track transaction ownership explicitly. Prefer rejecting a caller-owned transaction before mutation, consistently with `PdoAtomicOperations`, unless savepoint participation has a concrete contract. Never commit or roll back work owned by the caller. - -**Acceptance:** a failed cache operation preserves the caller's transaction and business rows; independently owned cache transactions commit or roll back correctly. Include errors during batch preparation, execution, deletion, and commit. - -### R06 — P1: SQL outbox consumers can permanently miss late commits - -**Protocol reproduced on PostgreSQL.** `PdoInvalidationSchema` allocates `BIGSERIAL`/auto-increment IDs on insert. `PdoInvalidationTransport::consumeSql()` subsequently selects only `event_id > cursor` (`:149`). Transaction A allocated ID 1 and remained open; B allocated and committed ID 2; the consumer observed 2 and advanced; A committed, and the next query could never return 1. - -**Change:** make the publication/cursor protocol safe under commit reordering. Choose and document a concrete protocol: for example, per-cluster transactional serialization before allocation, or committed outbox relay into an ordered delivery log. Compare lock duration, crash recovery, idempotency, and sustained throughput before selecting. A larger integer, a fixed delay, or a fixed overlap window does not establish correctness. - -**Acceptance:** real PostgreSQL and MySQL tests with at least two independent connections cover reversed commit order, rollback, long-running transactions, process death, duplicate replay, retention, and consumer restart. No committed invalidation may be skipped. Provide schema, rollout, rollback, and mixed-version rules. - -### R07 — P1: cursor storage collides across namespaces on one node - -**Reproduced.** `SqliteCursorStore` keys cursors by `(cluster_name, node_id)` only (`:71,96`), while `InvalidationHandler` applies a single namespace. Two runtimes using the same SQLite file, node ID, and cluster but namespaces A/B produced `[A consumed=1, B consumed=0, B value="stale"]` for an invalidation addressed to B. - -**Change:** include the complete consumption scope in cursor identity, or use one consumer that handles every namespace before advancing a shared cursor. Document whether multiple concurrent consumers may share a scope and enforce the chosen model. Include recovery and administrative skip operations in that scope. - -**Acceptance:** independent namespaces, nodes, clusters, and transport identities cannot advance each other's progress. Test migration of existing cursor rows without skipping invalidations, concurrent consume, reset, restart, and lost/truncated history. - -### R08 — P1: memoizer identities can return another input's result - -**Reproduced.** `CallableFingerprint::closure()` hashes file/line/captures, which collides for distinct closures on the same source line (`:63`). Two functions returning A/B produced A/A. `value()` converts objects into ordinary strings (`:52`); an object and the matching literal string both returned the object's result. `OnceMemoizer` retains `spl_object_id()` in cache keys after the object dies (`:50,65`); a new object reusing the ID returned the previous object's `"alice"` instead of `"bob"`. - -**Change:** make normalized values explicitly type-tagged and closure/caller identity unambiguous. Use lifetime-safe weak identity tracking where object identity is intended. Retain a documented useful call-site contract for `once()`; do not accidentally turn it into a never-hitting cache. Bound normalization and retained state, and preserve `flush_memoizers()` as the request-boundary reset. - -**Acceptance:** same-line closures, object/string/resource lookalikes, reused object IDs, bound and static callbacks, reference captures, cyclic input, null results, and worker request resets remain isolated. Test collecting owner objects without retaining their results unintentionally. Security impact depends on applications memoizing user/tenant-dependent data. - -### R09 — P1: nested symlinks bypass filesystem hardening - -**Reproduced.** The File/PHP-files constructors inspect the base and `data`, `meta`, `locks` directories, but not the intervening `cache_` component (`FileCacheAdapter.php:202`; `PhpFilesCacheAdapter.php:202`). Pre-creating that component as a symlink allowed PHP-files construction and a write into its target. - -**Change:** verify each relevant path component and the intended ownership/trust boundary before reading or creating executable cache files. Account for trailing separators, existing files, SQLite targets, and lock/token paths. Reuse the existing filesystem helper where ownership is shared. Keep private trusted roots as a deployment requirement; do not claim portable PHP checks eliminate every filesystem race. - -**Acceptance:** final-component and ancestor symlinks, hostile pre-created namespace roots, permission changes, and ordinary legitimate private directories behave predictably. Test Linux and Windows/reparse behavior where supported. Document that PHP-files executes the file before payload HMAC validation and therefore requires a trusted executable-cache directory. The exploit precondition is control of a relevant filesystem path. - -### R10 — P2: Redis DSN errors disclose credentials - -**Reproduced.** `RedisCacheAdapter::connect()` (`:387`) and `AtomicCounters::connect()` (`:33`) embed the original DSN in exceptions. An invalid database path with synthetic credentials emitted `redis://user:audit-password@localhost/bad`. - -**Change:** use sanitized messages and redact secret-bearing public/constructor parameters with `#[SensitiveParameter]` where applicable. Audit passwords, integrity keys, signed-closure keys, MongoDB URIs, and nested previous exceptions. An attribute does not sanitize a message that already contains the secret. - -**Acceptance:** sentinel secrets never appear in error messages, exception chains, or rendered traces with argument capture enabled. Invalid scheme, port, database, and authentication failures remain diagnosable without leaking credentials. - -### R11 — P2: deferred writes violate read/delete/update ordering - -**Reproduced.** `AbstractCacheAdapter::saveDeferred()` stores an object in `$deferred`, but normal reads do not consult it and key deletions/immediate saves do not reconcile it (`:108`, plus adapter mutations). A deferred read returned a miss; delete followed by commit resurrected `"old"`; an immediate `"new"` save followed by commit restored `"old"`. - -**Change:** define one coherent deferred-state lifecycle across facade and direct PSR-6 pools. Reads must see pending state; deletion and later writes must supersede it; clear and failed/partial commit must preserve documented semantics. Decide snapshot behavior for caller mutation after queueing. Ensure pending entries eventually persist under the PSR-6 contract, while documenting crash durability limits. - -**Acceptance:** a common backend contract suite tests pending read/has/getItems, overwrite/delete/clear/commit ordering, expiration, null values, item mutation, failed commit and retry, and finalization. Preserve ownership validation for foreign items. - -### R12 — P2: numeric-string keys and tags break internal maps - -**Reproduced.** Public validation accepts `"123"`, but PHP converts numeric string array keys to integers. A numeric tag returned `setTagged=true` followed by a miss; tiered single-key get returned the value while `getMultiple(['123'])` returned a miss. Relevant owners are `Cache::setMultiple()`, `CacheTagSnapshots`, tag encoding/normalization, `TieredCacheAdapter::multiFetch()/saveIntoPool()`, and Node batch copying. - -**Change:** keep logical key/tag strings intact through mapping and batching. Use item keys or explicitly reversible internal encodings; do not tighten public validation to exclude previously valid PSR keys merely to avoid the problem. - -**Acceptance:** `0`, `123`, `-1`, `01`, 64-character keys, numeric tags, generators, multi-key operations, deferred writes, tier promotion, and atomic operations preserve supported semantics across adapters. - -### R13 — P2: skipping L1 write-through retains stale L1 values - -**Reproduced.** In `TieredCacheAdapter::save()/writeBatch()` (`:157,242`), `writeToL1=false` skips writing L1 but does not invalidate an already-promoted value. Write old, read/promote, write new, read returned old. - -**Change:** invalidate affected upper-tier entries when write-through is disabled. Handle partial tier failures and failed promotions without presenting stale values as authoritative. Apply to single, batch, and deferred writes. - -**Acceptance:** after a successful update, earlier promoted values cannot win a later read. Cover nulls, TTL changes, deletes, failed upper-tier invalidation, and concurrent promotion. - -### R14 — P1/P2: Redis counters are cleared with ordinary cache data and lose precision - -**Reproduced.** `RedisCacheAdapter::clear()` scans `:*` (`:183`), deleting `AtomicCounters` keys stored under `:counter:*`. A cache clear erased an existing counter. This can reset security-relevant rate-limit state when the same namespace is used. The Lua increment script returns an integer through Lua's numeric representation (`RedisAtomicCounterStore.php:13`): increment by `9007199254740993` returned `9007199254740992` while Redis stored the correct value. `get()` also saturated an out-of-range numeric string to `PHP_INT_MAX`. - -**Change:** separate ordinary cache clearing from the counter keyspace; return the exact decimal string from inside the same atomic Lua operation and validate its PHP integer range before conversion. Preserve first-creation TTL and overflow failure behavior. - -**Acceptance:** cache clear does not reset counters; boundaries around 2^53 and PHP integer limits are exact; malformed/out-of-range stored values fail safely. Concurrent initialization, decrement, expiration, and injected-client options are covered on Redis and Valkey. No outside-script GET may introduce a race into the returned increment result. - -### R15 — P2: Node APCu identity omits the SQLite store - -**Reproduced with CLI APCu enabled.** `NodeCache::createApcuAdapter()` uses only the namespace (`:56`). Two Node Cache instances with different SQLite files and the same namespace returned each other's L1 value. Documentation describes the namespace as a logical cache within the selected database. - -**Change:** scope APCu and coordination identity to the logical node store plus namespace, with a stable documented identity across intended workers. Expose the same effective namespace to facade locking. Account for independently running CLI/FPM/worker APCu domains when applying cluster invalidation; one CLI consumer does not automatically clear another SAPI's L1. - -Related source finding: `Cache` excludes only Tiered and Null adapters when calculating `isAuthoritative()`, so Node's L1/L2 adapter is currently classified as authoritative. The built-in Node factory does not enable signing, which prevents it from satisfying the complete documented authentication-state gate by default. Nevertheless, capability reporting must classify L1-backed/custom configurations honestly rather than relying on a different default option to make them ineligible. - -**Acceptance:** different stores remain isolated, intentionally shared stores coordinate correctly, and invalidation reaches every supported L1 domain. Test both APCu-enabled and disabled configurations and cross-process/SAPI topology. If a topology cannot maintain coherence, reject or explicitly constrain it rather than claiming node-wide invalidation. - -### R16 — P2: Memcached mishandles TTLs longer than 30 days - -**Reproduced.** `save()`, `saveItems()`, and atomic write paths pass relative seconds directly to Memcached. Setting a 31-day TTL returned true and immediately read as a miss. - -**Change:** centralize conversion of relative TTL to Memcached expiration semantics for ordinary, bulk, atomic, and lease paths. Preserve zero/forever and expired/no-op contracts and guard timestamp overflow. - -**Acceptance:** zero, one second, exactly 30 days, 30 days plus one second, 31 days, DateInterval, and absolute-date inputs have equivalent logical behavior in all applicable APIs. - -### R17 — P2: direct PSR-6 validation and APCu bulk delete are inconsistent - -**Reproduced.** Direct `ArrayCacheAdapter::getItem('invalid:key')` accepted a reserved PSR character, despite adapters being documented as directly usable PSR-6 pools. `Cache::apcu()->delete('absent')` returned true while `deleteMultiple(['absent'])` returned false (`ApcuCacheAdapter.php:66`). - -**Change:** validate every public PSR boundary consistently, preserving efficient internal already-validated paths. Normalize missing-key deletion success for bulk adapters, while distinguishing genuine backend failures. Include expired-item `get()/isHit()` consistency in the contract review. - -**Acceptance:** direct pools and facade pass a common PSR-6/PSR-16 interoperability matrix, including invalid keys, missing deletes, null values, expiration and deferred state. Use an independent standards test/consumer where practical. - -### R18 — P1, conditional source finding: SQL collation can collapse cache identities - -`PdoCacheSchema::install()` uses `VARCHAR(191)` identity columns on MySQL/MariaDB without an explicit case-sensitive/binary collation (`:21`). On a database whose default collation is case-insensitive, distinct supported namespaces/keys differing by case can compare equal. The invalidation schema's cluster/namespace/node columns also inherit database collation. This was not reproduced against MySQL in this audit. - -**Change:** use explicit byte-sensitive identity semantics appropriate to supported engines, and provide a migration for existing tables. Audit existing duplicate/collapsed identities before migration; changing only table-creation SQL leaves deployed tables unchanged. - -**Acceptance:** real MySQL/MariaDB with a case-insensitive default keeps `Tenant`/`tenant` and `Key`/`key` separate through set/get/bulk/delete/clear/tag/atomic/cluster paths. PostgreSQL and SQLite behavior remains consistent. Record the collation used by each test database. - -### R19 — P1 release blocker: verification does not cover the advertised contracts - -**Observed.** The baseline quality suite fails, the minimum declared PHP runtime is absent from the inherited matrix, and important backend tests rely on fakes or skipped prerequisites. Selecting ScyllaDB's Alternator service does not test this library's CQL adapter. MongoDB is absent from the workflow service list. Redis Cluster tests using a fake do not prove hash-slot behavior. The inherited Deptrac report leaves 677 dependencies uncovered. Benchmark code primarily measures component operations; some named hit benchmarks also include constructing/filling the cache. - -**Change:** repair test/helper structure and provision real backend prerequisites. Resolve Cassandra references with a verified driver/stub strategy that matches supported runtime APIs. Remove skip directives by arranging explicit applicable suites/jobs and hard prerequisite checks, not by disguising skips as returns or passing assertions. Add the full support matrix and meaningful architecture rules. Keep local host and prepared-service reports separate. - -**Acceptance:** every required detector runs with its intended scope and thresholds; no new suppressions, exclusions, expanded baselines, raised limits, or weakened assertions. Maintain complexity limits `function=12`, `class=80`, `dependency_tree=120`. A green gate must mean its intended behavior was actually exercised. - -**Resolution:** complete. Exact implementation head `9219b25a56a6c459b6a3c001c36e4b9564fddd2a` passed Security & Standards run #406 and Release Verification run #46 with the unchanged PHPForge thresholds, real MongoDB QA coverage, real Redis Cluster and Scylla CQL release jobs, clean consumers, independent PSR contracts, cross-platform core smoke, and warning-free documentation. - -## Implementation batches - -Batches 1-7 have historical implementation and CI completion evidence. Their relevant correctness/security gates are reopened by F01–F09 in the release re-audit; retain the completed work and add focused remediation before tagging. - -1. **Security and transaction containment — R01, R03, R04, R05, R09, R10.** - - [x] Add bounded adversarial subprocess and filesystem/transaction tests. - - [x] Correct traversal, policy binding, deletion-result handling, transaction ownership, directory checks, and secret redaction in their existing owners. - - [x] Deliver containment fixes in 4.0.0 and document any changed failure behavior; coordinate identity and counter fixes with their batches below. -2. **Authenticated storage and identity — R02, R15, R18.** - - [x] Specify the new envelope and logical store/key identity, then test it across all codec-using backends. - - [x] Bind Node L1/lock identity to its intended store; migrate SQL identity collation. - - [x] Add per-node serialization/integrity options if needed; new optional parameters must retain existing parameter names. - - [x] For 4.0, make executable/object deserialization an explicit policy choice and document the default. Include the changed default in the 3.x-to-4.0 migration guide. -3. **Durable invalidation — R06, R07 and R15 topology.** - - [x] Choose a commit-safe publication protocol with a written failure-state model and measured contention cost. - - [x] Scope cursors correctly, migrate stored progress, and handle empty/reset transport history conservatively. - - [x] Test real multi-connection delivery, retention, crash recovery, poison events, administrative skips, and SAPI/L1 coherence. -4. **Cache contracts and memoization — R08, R11, R12, R13, R16, R17.** - - [x] Add reusable cross-backend behavioral tests for keys, values, deferred operations, expiration, tagging, promotion, and atomic outcomes. - - [x] Fix memoizer identity/lifecycle and test persistent workers with request resets. - - [x] Correct numeric maps, upper-tier invalidation, Memcached expiration, and direct PSR pool boundaries. -5. **Counter and backend failure contracts — R14 plus targeted race review.** - - [x] Isolate counter clearing and make integer handling exact. - - [x] Audit stale-read cleanup so deleting an observed stale value cannot erase a concurrent replacement; use compare-delete or leave cleanup to bounded maintenance where appropriate. - - [x] Audit tag initialization races, clear versus write/consume, lease loss, partial bulk failure, and Redis/Memcached false/error status handling. These races need deterministic interleaving tests; source inspection alone is not a completed gate. -6. **Tooling, documentation, and release verification — R19.** - - [x] Resolve the recorded skip/reference findings and make architecture boundaries meaningful. - - [x] Review clone groups and centralize genuinely shared invariants in existing owners; keep backend-specific atomic protocols explicit. Do not perform a broad inheritance rewrite or consolidate only to reduce file count. - - [x] Update README, security/serialization/atomic/Node/Cluster docs and executable examples to the final behavior. - - [x] Complete migrations, benchmark/soak evidence, clean consumer tests, and exact-revision CI before tagging. - -7. **Runwire 2.1 integration — required for the 4.0 release.** - - [x] Implement automatic use of relevant active Runwire capabilities with the normal path as fallback, and demonstrate it in an executable invalidation-worker example after R06, R07, and R15 are resolved. - - [x] Implement bounded maintenance scheduling and persistent-request lifecycle integration where the current Runwire APIs support a safe ownership model. - - [x] Use Runwire where it materially improves isolated crash/concurrency verification without making it a core dependency. - - [x] Complete the compatibility, lifecycle, coherence, and performance gates below for every shipped integration capability. - -### Batch 7 tracker - -| Sub-batch | Scope | Status | Gate | -| --- | --- | --- | --- | -| 7A — Runtime/request lifecycle | Shared active RuntimeContext + request/task scope, capability-driven memoizer isolation/fallback | **Complete** | Exact head `a998ffaf0b952c7c751183a8a0652e25770472bc` passed Security & Standards #421 and Release Verification #61. Clean no-dev consumers confirm Runwire remains optional. | -| 7B — Worker-owned background integration | Bounded cluster invalidation polling and Node maintenance inside the host-provided task scope; no worker/loop ownership | **Complete** | Exact head `abae64c585998bc56327ab5792fef39f881ed875` passed Security & Standards #433 and Release Verification #73. Worker-owned cancellation/drain, bounded polling, maintenance cycles, capability fallback, and normal-path behavior are covered. | -| 7C — Consumer/docs/release integration | Executable example, optional consumer dependency, topology docs, PHP 8.4/8.5 integration matrix | **Complete** | Exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105. The release matrix includes Runwire 2.1 on PHP 8.4/8.5 with lowest/stable dependencies, matched certification, persistent-worker soak, and the normal no-Runwire consumers. | - -## Runwire 2.1 integration workstream (required release scope; optional dependency) - -### Scope and dependency decision - -The assessment inspected local Runwire tag `2.1` (`e6a954df1ec90aef98daf8248bd02f741f9324e3`). This establishes available APIs and platform requirements, not successful CacheLayer integration or a measured throughput benefit. Worker supervision, structured coroutines, and lifecycle support already existed before 2.1; the principal 2.1 additions concern adaptive HTTP scheduling. - -Runwire requires 64-bit PHP 8.4+, matching CacheLayer 4.0's minimum PHP version. The 4.0 release implements the smallest integration needed for automatic capability selection, with an executable example and an isolated integration-test Composer environment using `infocyph/runwire:^2.1`. Do not add Runwire to core `require`; it remains an optional consumer dependency even though this integration is required 4.0 release scope. Add a Composer suggestion only with usable integration documentation. Introduce a separate optional package or adapter only if tested consumers demonstrate substantial reusable behavior beyond the shipped bridge; do not introduce a generic runtime abstraction speculatively. - -The core must remain usable without Runwire installed, including ordinary PHP-FPM execution. No supervisor, listener, timer, connection, or worker may start during autoload or cache construction. A host that already owns its process pool retains that ownership. The 4.0 major-version decision does not change these dependency and runtime boundaries. - -### Automatic capability selection and normal fallback - -**Execution contract:** Runwire loaded as the active runtime → use the relevant supported capability; Runwire absent, inactive, or lacking that capability → use the existing normal path. Callers keep the same CacheLayer APIs and do not select a Runwire-specific cache backend or enable each capability manually. Installation or an autoloadable Runwire class alone does not establish an active runtime, event loop, or request scope. - -Use the active runtime's public context and supported lifecycle hooks. Runwire 2.1 exposes `RuntimeContext` and explicit coroutine scopes; do not assume a process-global current-runtime/current-scope lookup exists. Where context must be supplied by the hosting application, bind it once at the runtime bootstrap boundary and attach the actual request/task scope at its lifecycle boundary. Capability selection within CacheLayer is automatic after that binding. The executable example must make this wiring concrete rather than promise unsupported discovery. - -- Use lifecycle cleanup and isolated request/task state when the active host provides them. In concurrent execution, preserve request isolation; never silently substitute a process-global memoizer or global reset for unavailable request-local state. A safe normal path may bypass request memoization when isolation cannot be established. -- Use cooperative timer waits for existing retry/poll intervals only inside an appropriate active scope, preserving timeout, cancellation, lease, and atomicity contracts. Elsewhere retain the normal bounded wait path. Backend calls remain synchronous unless their actual client integration supports cooperative I/O. -- Schedule already-configured invalidation or maintenance work on the runtime's available worker/loop lifecycle where ownership and blocking behavior permit. Do not create new background jobs merely because Runwire is present. Without those capabilities, keep the existing explicit consume/maintenance execution path. -- Resolve stable capabilities at worker bootstrap; resolve request/task ownership at the current scope. Refresh bindings after fork, worker replacement, or runtime shutdown. Do not retain one request's scope in a process-global cache or repeatedly scan/reflection-probe dependencies on every cache hit. -- Fall back when a capability is unavailable before starting an operation. Do not catch operational failure or cancellation and replay a potentially completed mutation through the normal path. Preserve error policy, one-time consumption, distributed locks, payload integrity, TTL, and cursor semantics across both paths. - -### Planned uses and prerequisites - -1. **Supervised cluster invalidation — shipped integration.** Wrap existing `ClusterRuntime::consume()` calls in bounded scheduled work with explicit batch size, polling interval, backend timeouts, retry/backoff, and shutdown budgets. Preserve serial consumption within each complete cursor scope; independent scopes may run independently. Create backend connections in worker bootstrap after a fork. Expose consumed counts, failures, consumer lag, and restart behavior without unbounded metric labels. Resolve R06/R07 before relying on durable progress and R15 before claiming node-wide L1 coherence. A separate CLI consumer must not be described as clearing unrelated FPM/worker APCu domains automatically. -2. **Bounded maintenance — shipped integration.** Schedule existing `NodeCacheMaintenance::pruneExpired()`, `checkpoint()`, and `optimize()` at explicit operational intervals. Bound prune batches and prevent overlapping maintenance against the same store. Measure SQLite writer contention and choose heavier maintenance windows accordingly. Do not place full scans or maintenance on the request hot path. Supervision does not make an individual blocking database operation cancellable. -3. **Persistent request lifecycle — conditional on the host integration.** After R08 and the relevant deferred-state fixes, connect request-owned memoizer/state cleanup to Runwire's completion/reset lifecycle, including failure, cancellation, and deadline paths. `flush_memoizers()` is suitable only for a sequential lifecycle with an explicit ownership contract. Concurrent requests need isolated memoizer state, potentially through Runwire task-local context or an explicit request-owned instance; one request must not flush or observe another request's state. Preserve intentional cross-request cache data and resolve pending deferred writes under their documented contract. -4. **Crash and concurrency verification — usable during earlier batches.** Evaluate Runwire's bounded subprocess runner for recursive-payload probes and its worker supervision for real restart/concurrency tests. Set explicit PHP memory, execution-time, and output limits; execute validated argument vectors. `ProcessRunner` is synchronous and is not itself a parallel worker pool or OS sandbox. Retain a lightweight existing subprocess harness if adopting Runwire adds complexity without improving evidence. Core regression coverage must remain available independently of Runwire. - -Existing PDO, filesystem, and synchronous native-client calls remain blocking inside Runwire coroutines. Prefer existing backend bulk operations and bounded dedicated workers where suitable. Do not wrap each cache operation in a coroutine or process and claim asynchronous I/O or a speedup. Runwire's in-process coroutine synchronization also does not replace CacheLayer's cross-process/distributed lock and atomicity contracts. - -### Integration acceptance gates - -The Runwire workstream is required for 4.0. Every shipped Runwire capability must pass the applicable gates below before the release can be called ready: - -- [x] Keep the default core test environment working without Runwire. Test the optional environment on supported 64-bit PHP 8.4/8.5 with lowest and highest supported dependencies; record the resolved Runwire version and native extensions. -- [x] Verify automatic selection with Runwire absent, installed but inactive, active with each relevant capability, and active with partial capabilities. Cover calls outside a request/task scope and contexts after shutdown, fork, and worker replacement. Confirm the normal path remains usable without Runwire classes loaded. -- [x] Run the same cache contract cases through normal and runtime-assisted paths. Prove no duplicate mutation on failure/cancellation, no changed integrity or distributed-atomicity guarantees, no cross-request scope leakage, and no automatic job startup from package presence. Verify the bootstrap binding and lifecycle cleanup in the executable example. -- [x] Declare topology prerequisites. Native prefork supervision needs PCNTL/POSIX; portable single-process execution relies on external supervision for restarts. Test supported modes explicitly and fail clearly for unsupported requested capabilities. -- [x] Demonstrate no skipped committed invalidations through reversed commits, duplicate replay, worker death before/after application and cursor persistence, restart, retention, backend outage, and graceful shutdown. Prove cursor ownership and L1 coherence for each advertised deployment topology. -- [x] Bound batch work, queueing, retry frequency, backend wait time, shutdown duration, and retained memory. Test crash loops and verify that backoff does not starve lifecycle handling. Maintenance must not overlap unexpectedly or exceed the recorded SQLite contention budget. -- [x] Soak-test sequential and, if supported, concurrent requests with changing tenants, failures, cancellations, deadlines, and deferred writes. Require no memoizer leakage, cross-request resets, abandoned request state, or unbounded memory growth. -- [x] Compare representative host-application successful RPM with and without the integration under equivalent correctness guarantees, topology, resources, and workloads. Record invalidation lag, p95/p99 latency, errors/timeouts, CPU/RSS, backend calls, and maintenance contention using the release measurement method below. Set acceptable budgets before selecting an implementation. -- [x] Treat Runwire 2.1 adaptive HTTP scheduling as a separate host-level experiment. Begin with protocol defaults (`FIXED`), and evaluate `LATENCY`, `THROUGHPUT`, or `AUTO` only through repeated representative measurements, including load transitions and fairness. Do not attribute HTTP scheduling gains to CacheLayer storage or change protocol hard limits. -- [x] Run executable examples and integration jobs on the exact final revision, and document startup, shutdown, connection ownership, prerequisites, topology limits, recovery, and rollback. Keep integration evidence separate from core/backend gate results. - -**Batch 7 closure evidence:** exact implementation head `8dd980f1827f2272f70c83cefaf89c479e562b5c` passed Security & Standards #465 and Release Verification #105. The Runwire consumer matrix covered PHP 8.4/8.5 with lowest and stable dependencies while clean no-dev consumers proved the core remains usable without Runwire. `examples/runwire-invalidation-worker.php` and `tools/release/runwire-consumer/` exercise explicit bootstrap binding, host-owned worker/task scope, normal fallback, graceful shutdown, and operational prerequisites. - -The same release gate runs a bounded matched workload and persistent-worker soak. The certification records Runwire/PHP/native-extension resolution, baseline versus integrated RPM, p50/p95/p99 latency, errors, CPU, memory, cache-operation counts, invalidation lag, and maintenance p95 with explicit regression budgets. It is CI release evidence only and is not a claim of production throughput improvement. The soak covers sequential and concurrent request lifetimes, tenant variation, intentional failures, request-owned cancellation and deadlines, deferred writes, memoizer isolation, and bounded retained memory. - -Durable invalidation correctness remains owned by the existing R06/R07/R15 protocol: the Runwire worker invokes the same `ClusterRuntime::consume()`/cursor path and introduces no alternate progress protocol. Existing reversed-commit, replay, process-death, restart, retention-recovery, cursor-scope, and L1-coherence regressions therefore remain authoritative; Runwire-specific tests add worker cancellation/drain, backend failure, unsupported topology, stale runtime replacement, and non-replay fallback behavior. CacheLayer does not select Runwire HTTP adaptive scheduling modes; `FIXED`/other protocol policy remains a host-level Runwire concern and the integration documentation keeps that experiment separate. - -## Improvements that require measurement or a separate scope decision - -These are not substitutes for the required fixes: - -- Bound large Node SQLite read/delete/tag parameter lists and remote bulk payloads using verified backend limits. Test above the configured SQLite variable limit instead of assuming one universal limit. -- Bound Scylla's prepared-statement cache: variable-sized `IN`/batch shapes can grow its per-instance map in persistent workers. Choose a fixed chunking strategy or measured bounded cache. -- Add appropriate bounded expiry maintenance for file/PHP-files, in-memory stores, and MongoDB where physical retention can outlive logical expiration. Avoid request-path full scans. Never casually delete live lock files: replacing a locked inode can split the lock domain. -- Separate fixture construction/cold loading from warm cache hit measurements. Benchmark signed/plain/compressed records, batches, tagged hits, miss/fill, tier promotion, and contention. -- Measure default metrics overhead before adding a no-op option; keep observability hooks cheap. Do not claim a performance improvement from code shape alone. -- Review the moving PHPForge workflow reference and development dependency constraints for reproducible releases. Pin a reviewed workflow revision and record the resolved toolchain where compatible with project policy. Do not silently revert the user's MongoDB constraint edit. -- Track `doctrine/annotations` abandonment through PHPBench upstream. It is a development-maintenance warning, not a demonstrated runtime vulnerability; do not delete working benchmarks just to remove the warning. - -## Release acceptance - -### Correctness and security - -Historical checked gates below apply to the recorded earlier commits. For the current follow-up: - -- [x] Resolve V01 codec depth symmetry with boundary regressions. -- [x] Resolve V02 exceptional tier exits and verify false/throwing single/bulk operations and promotion. -- [x] Resolve V03 with documented coordinated identity rotation and cold clear before runtime exposure; verify overlapping IDs and failed-clear retry. -- [ ] Pass both configured workflows on the final committed revision, including the full service/dependency/platform matrix. - -- [x] Revalidate and remediate the reopened R01–R19 requirements against F01–F09 in the affected production paths/backends; final exact-head CI evidence remains pending. -- [x] Run real PHP 8.4 and 8.5 with highest supported and lowest supported dependency sets and `E_ALL`. Verify optional extensions and native-client versions explicitly. -- [x] Exercise SQLite, MySQL, MariaDB, PostgreSQL, Redis, Valkey, Memcached, MongoDB, Scylla CQL, and real Redis Cluster for their advertised features. Fakes supplement these gates. -- [x] Use separate processes/connections for one-winner claims, one-time consumption, tag initialization, invalidation, clear/write races, and lock expiration/ownership. An in-process fake cannot prove distributed atomicity. -- [x] Verify executable-file and ordinary-file behavior with OPcache enabled/disabled, Linux permissions, and Windows where supported. Test failure paths without granting the cache process excess permissions. -- [x] Run an independent PSR consumer/contract check. Keep cache/authentication-state topology and integrity/replay guarantees explicit. - -### Performance and worker stability follow-up (non-blocking) - -**4.0 disposition: separated from release acceptance.** CacheLayer 4.0 makes no host-application throughput-improvement claim. The PHPForge benchmark jobs pass on PHP 8.4/8.5, while the broader production-equivalent RPM/soak program below remains post-release measurement work rather than an unfinished 4.0 gate: - -- Before future hot-path optimization work, record a reproducible baseline on production-equivalent hardware; correctness fixes remain required even if they add necessary work. -- Measure both component operations and representative host-application **successful RPM**. Do not convert a PHPBench microbenchmark into an application-throughput claim. -- Cover cold/warm initialization, hits/misses/fill, invalid/tampered inputs, signed/compressed payloads, bulk/tagged reads, atomic/counter contention, and invalidation consumption at several concurrency levels. -- Use at least three warmed steady-state runs per important workload; compare median sustained successful RPM and variance. Record RPS/RPM, duration, counts, errors/timeouts, validation failures, p50/p95/p99, CPU, memory, queue/consumer lag, connections, cache hit rate, and backend calls where relevant. -- Set workload-specific latency, memory, connection, and throughput budgets from that baseline before accepting optimizations. A provisional 2% RPM regression budget may be used only in a matching stable environment with noise below the decision threshold; define exact capacity limits in the recorded benchmark configuration. -- Run persistent-worker soak tests with changing tenants, collected/reused objects, request resets, cache churn, and dependency failures. Require bounded memory and lag and no stale identity reuse. - -### Tooling and packaging - -```sh -composer ic:doctor -composer ic:list-config -composer ic:active-config -# During implementation, not during this read-only audit: -composer ic:process -composer ic:tests:details -composer ic:release:guard -git diff --check -``` - -- [x] Keep source-mutating processors sequential and review their diff. Parallelize only independent read-only checks with bounded concurrency. -- [x] Build documentation with warnings as errors and test the examples relevant to changed public contracts. -- [x] Install the candidate in a fresh consumer using `composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction`; verify optional adapters are lazy and runtime code does not depend on development packages. -- [x] Recheck advisories against both the resolved candidate and production-only dependencies. The current untracked development lockfile is evidence for this checkout, not every consumer resolution. -- [x] All configured CI checks passed on corrected head `97957893ab1459e365526dab2b913131021489fe` (#513/#153) and tracker-closure head `4f4e3912bccba11c2ba9e2ef6b1ee60a26c8a2c5` (#514/#154). -- [x] Final release sweep substantive head `909f73fb3b57525fcced5e0d024c5a4b92ea9d03` passed Security & Standards #516 and Release Verification #156 with corrected certification measurements and codec tag-budget regressions. - -### Migration and rollback - -- [x] Publish a consolidated 3.x-to-4.0 upgrade guide covering changed defaults, public behavior, payload/storage formats, cursor migration, and optional Runwire requirements. Record all intentional breaks in the 4.0.0 release notes. -- [x] Version and publish the authenticated record and cursor/schema changes. Preserve a clear distinction between disposable cache values and durable security/cursor state. -- [x] Use separate namespaces/storage versions or a coordinated cutover where old/new readers cannot safely coexist. In the new integrity mode, do not silently accept unbound legacy records for compatibility. -- [x] For cursor migration, clear/reconcile the affected local cache and establish a safe replay position; do not merely copy a shared cursor into several scopes and assume it proves delivery. -- [x] Migrate SQL collations with explicit old-data inspection and rollback instructions. Preserve counters and authoritative replay/authorization state; do not treat deleting that state as ordinary cache cleanup. -- [x] Rehearse rollback by activating the complete previous release and its compatible storage configuration. Record immutable commit/tag, PHP/extensions, tool versions, schema versions, and deployment assumptions. - -## Reproduction notes - -These short examples use an isolated test process. Do not run the deliberate exhaustion or filesystem fault probes in an application worker. - -```php -// Deferred deletion is undone by commit on the audited revision. -$cache = \Infocyph\CacheLayer\Cache\Cache::memory(); -$cache->saveDeferred($cache->getItem('x')->set('old')); -$cache->delete('x'); -$cache->commit(); -assert($cache->get('x') === 'old'); // Observed defect; fixed expectation is a miss. - -// A recursive input is tiny; a payload-byte limit cannot bound traversal. -$value = []; -$value['self'] = &$value; -$blob = 'cl2:' . serialize([ - 'format' => 2, 'encoding' => 'native', 'value' => $value, - 'expires' => null, 'tags' => [], 'namespace' => null, -]); -// Decode only in a subprocess with an OS timeout and PHP memory limit. - -// Distinct closures on one line collide in the audited memoizer. -$a = static fn() => 'A'; $b = static fn() => 'B'; -$memo = \Infocyph\CacheLayer\Memoize\Memoizer::instance(); -$memo->flush(); -assert([$memo->get($a), $memo->get($b)] === ['A', 'A']); -``` - -Local audit artifacts, useful while this workspace session remains available: - -- `/tmp/cachelayer-audit-quality.log`, `/tmp/cachelayer-audit-doctor.log` -- `/tmp/cachelayer-audit-advisories.json`, `/tmp/cachelayer-audit-release-audit.log` -- `/tmp/cachelayer-audit-integration.log` -- `/tmp/cachelayer-audit-probes.php`, `/tmp/cachelayer-audit-cycles.php` -- `/tmp/cachelayer-audit-network.php`, `/tmp/cachelayer-audit-pg.py` -- `/tmp/cachelayer-audit-scope.php`, `/tmp/cachelayer-audit-file-consume.php` - -The temporary network probe uses the audit's allocated ports; recreate disposable services and update ports before reuse. These artifacts are not permanent regression tests. Promote the relevant cases into the existing test layout during implementation. - -## Primary references - -- [PHP-FIG PSR-6](https://www.php-fig.org/psr/psr-6/) establishes deferred-read visibility, supported keys, miss behavior, and deletion semantics underlying R11/R17. -- [PHP unserialize documentation](https://www.php.net/manual/en/function.unserialize.php) describes deserialization risks and options; limits on unserialization do not bound subsequent application traversal in R01. -- [PostgreSQL transaction isolation](https://www.postgresql.org/docs/current/transaction-iso.html) explains committed-row visibility and sequence behavior. R06's delivery failure is an inference from those semantics plus the library query, independently reproduced with two transactions. -- [Redis Lua API conversion rules](https://redis.io/docs/latest/develop/programmability/lua-api/) explain numeric reply conversion relevant to the reproduced R14 precision failure. -- [PHP Memcached expiration rules](https://www.php.net/manual/en/memcached.expiration.php) specify the 30-day relative/absolute cutoff underlying R16. -- [PHP object ID lifetime](https://www.php.net/manual/en/function.spl-object-id.php) documents ID reuse after destruction, relevant to R08. -- [MySQL case sensitivity and collation](https://dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html) explains why inherited case-insensitive collations affect the identity columns in R18. The Batch 2 backend regression now covers the required byte-sensitive identity behavior; final backend-matrix coverage passed under R19 on Security & Standards #406 and Release Verification #46. diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst index 95512fd1..7bb3fb8a 100644 --- a/docs/release-4.0.rst +++ b/docs/release-4.0.rst @@ -71,6 +71,30 @@ Atomicity and counters a concurrent replacement. * Tag generation initialization is race-safe across supported backend families. +Release verification +==================== + +The 4.0 release gate covers: + +* PHP 8.4 and 8.5 with stable and lowest supported dependency resolution; +* Linux and Windows core smoke tests plus clean no-dev consumer installs; +* independent PSR-6 and PSR-16 contract consumers; +* documentation builds with warnings treated as errors; +* real Redis Cluster and Scylla CQL release jobs, with real MongoDB exercised + by the PHPForge service matrix; +* Runwire 2.1 consumer certification and persistent-worker soak coverage across + PHP 8.4/8.5 stable and lowest dependency sets. + +The Runwire certification measures 4,000 cache operations after a separate +250-iteration warmup and validates the expected 3,500 reads plus 500 writes in +both baseline and integrated runs. It is a bounded regression/correctness gate, +not a universal production-throughput claim. + +The supported cluster-history reset contract is explicit: recreated, restored, +or ID-reused invalidation histories require a new, never-used +``transportIdentity`` and coordinated reconciliation of every APCu/L1 domain. +Arbitrary same-identity history resets are not supported. + Upgrade ======= From 726e9458e3660089068726405db0452c5597a4b8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 18:30:26 +0600 Subject: [PATCH 433/434] :construction_worker: ci(workflows): add release workflow trigger and job - Add push triggers for version tags in security standards workflow :construction_worker: - Add conditional release workflow job for tagged commits :rocket: --- .github/workflows/security-standards.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 7db234bb..f309ced9 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,11 +5,13 @@ on: - cron: "0 0 * * 0" push: branches: [ "main", "master" ] + tags: [ "v*", "[0-9]*" ] pull_request: branches: [ "main", "master", "develop", "development" ] jobs: phpforge: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') uses: infocyph/phpforge/.github/workflows/security-standards.yml@main permissions: security-events: write @@ -20,3 +22,11 @@ jobs: fail_on_skipped_tests: true integration_services: '["mysql","mariadb","postgres","sqlite","mongodb","redis","valkey","memcached","scylladb"]' service_topologies: '{"mongodb":"replica-set"}' + + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: infocyph/phpforge/.github/workflows/release.yml@main + permissions: + contents: write + secrets: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} From b752add3483c97b9215722ada9bc8d3b1f56aa62 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 29 Sep 2026 18:31:47 +0600 Subject: [PATCH 434/434] =?UTF-8?q?=F0=9F=94=A5=20chore(ci):=20remove=20PH?= =?UTF-8?q?PForge=20Pint=20diagnostic=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cut GitHub Actions workflow for PHPForge Pint diagnostic :fire: --- .../workflows/phpforge-pint-diagnostic.yml | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 .github/workflows/phpforge-pint-diagnostic.yml diff --git a/.github/workflows/phpforge-pint-diagnostic.yml b/.github/workflows/phpforge-pint-diagnostic.yml deleted file mode 100644 index 6fa39b66..00000000 --- a/.github/workflows/phpforge-pint-diagnostic.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: PHPForge Pint Diagnostic - -on: - push: - branches: - - feature/improvements - -jobs: - pint-diff: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - coverage: none - - uses: ramsey/composer-install@v4 - with: - dependency-versions: highest - - name: Show exact PHPForge Pint changes - shell: bash - run: | - vendor/bin/pint --config=vendor/infocyph/phpforge/resources/pint.json src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php - git diff -- src/Cache/Adapter/CachePayloadCodec.php src/Cluster/Transport/Pdo/PdoInvalidationSchema.php