diff --git a/README.md b/README.md index 4d880e9..cb67f5b 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,19 @@ Runwire keeps ownership of listeners, workers, supervisors, and event loops. `Ru 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`. +A framework can also pass its runtime for one request/task without a global bind: + +```php +RunwireIntegration::share($request, $scope, function () { + // Normal cache and memoization operations use this task's context. +}, $runtime); +``` + +The fourth argument is optional. `runtime()` and capability checks reflect the +current task while the callback runs; nested sharing and exceptions restore the +previous context. This borrowing does not bind, release or replace the host's +global runtime. Concurrent requests receive separate memoizers. + ## 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. diff --git a/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php index 35dd4ac..3b5a2a2 100644 --- a/src/Cache/Adapter/AdapterValueNormalizer.php +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -72,6 +72,15 @@ public static function normalizeAssoc(array $value): array return $out; } + public static function pdoPayload(mixed $value): ?string + { + if (is_resource($value)) { + $value = stream_get_contents($value); + } + + return is_string($value) ? $value : null; + } + /** * @phpstan-return array|null * @param object $value The value argument. diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 3588679..5fac04b 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -128,6 +128,12 @@ public function encode( return $encoded; } + /** @phpstan-assert-if-true =string $value */ + private static function isGeneration(mixed $value): bool + { + return is_string($value) && strlen($value) === 32 && ctype_xdigit($value); + } + private function assertNativeValueSupported(mixed $value): void { if ($value instanceof Closure) { @@ -281,10 +287,7 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return null; } $namespaceGeneration = $decoded['namespace'] ?? null; - if ($namespaceGeneration !== null - && (!is_string($namespaceGeneration) - || strlen($namespaceGeneration) !== 32 - || !ctype_xdigit($namespaceGeneration))) { + if ($namespaceGeneration !== null && !self::isGeneration($namespaceGeneration)) { return null; } @@ -294,9 +297,7 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord } foreach ($tags as $generation) { - if (!is_string($generation) - || strlen($generation) !== 32 - || !ctype_xdigit($generation)) { + if (!self::isGeneration($generation)) { return null; } } diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index adf8292..175c902 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -259,17 +259,15 @@ public function multiFetch(array $keys): array foreach ($keys as $key) { $mapped = $this->mapData($key); $blob = $stored[$mapped] ?? null; - if ($blob === self::ATOMIC_TOMBSTONE) { + if (!is_string($blob) || $blob === self::ATOMIC_TOMBSTONE) { $items[$key] = $this->genericMiss($key); continue; } - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; + $record = $this->decodeRecordFromBlob($blob, $key); if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); - if (is_string($blob)) { - $stale[] = [$mapped, $blob]; - } + $stale[] = [$mapped, $blob]; continue; } diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index e755905..193f353 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -195,26 +195,7 @@ public function getTagGenerations(array $tags): array 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; - } - } + $this->initializeTagGeneration($tag); } $actual = $this->readTagGenerations($tags); @@ -247,9 +228,10 @@ public function multiFetch(array $keys): array $byId = []; foreach ($documents as $document) { $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); - if (is_array($row) && is_string($row['_id'] ?? null)) { - $byId[$row['_id']] = $row; + if (!is_string($row['_id'] ?? null)) { + continue; } + $byId[$row['_id']] = $row; } $items = []; @@ -282,8 +264,8 @@ public function readTagGenerations(array $tags): array $generations = []; foreach ($documents as $document) { $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); - $tag = is_array($row) ? ($row['tag'] ?? null) : null; - $generation = is_array($row) ? self::normalizeGeneration($row['generation'] ?? null) : null; + $tag = $row['tag'] ?? null; + $generation = self::normalizeGeneration($row['generation'] ?? null); if (is_string($tag) && $generation !== null) { $generations[$tag] = $generation; } @@ -406,13 +388,12 @@ private function binaryString(mixed $value): ?string if (is_string($value)) { return $value; } - if (is_object($value) && is_callable([$value, 'getData'])) { - $data = $value->getData(); - - return is_string($data) ? $data : null; + if (!is_object($value) || !is_callable([$value, 'getData'])) { + return null; } + $data = $value->getData(); - return null; + return is_string($data) ? $data : null; } private function binaryValue(string $value): mixed @@ -424,6 +405,30 @@ private function binaryValue(string $value): mixed return $value; } + private function initializeTagGeneration(string $tag): void + { + $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; + } + } + } + private function isDuplicateKeyFailure(Throwable $failure): bool { return in_array(AdapterValueNormalizer::intOrZero($failure->getCode()), [11000, 11001, 12582], true) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 63cb1c2..6c13d4f 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -318,21 +318,8 @@ private function fetchRows(string $kind, array $keys): array . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", ); $statement->execute([$this->namespace, $kind, ...$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)) { - continue; - } - $rows[$row['cache_key']] = [ - 'payload' => $payload, - 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, - ]; + foreach ($this->normalizeRows($statement->fetchAll(PDO::FETCH_ASSOC)) as $key => $row) { + $rows[$key] = $row; } } @@ -351,6 +338,30 @@ private function hydrate(string $key, array $row): ?CacheItem return $record === null ? null : $this->genericItemFromRecord($key, $record); } + /** + * @param array $records + * @return array + */ + private function normalizeRows(array $records): array + { + $rows = []; + foreach ($records as $row) { + if (!is_array($row) || !is_string($row['cache_key'] ?? null)) { + continue; + } + $payload = AdapterValueNormalizer::pdoPayload($row['payload'] ?? null); + if (!is_string($payload)) { + continue; + } + $rows[$row['cache_key']] = [ + 'payload' => $payload, + 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, + ]; + } + + return $rows; + } + /** @param list $rows */ private function upsertChunk(array $rows): bool { diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php index 9122ac7..90bdc21 100644 --- a/src/Cache/Adapter/PdoCacheSchema.php +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -41,6 +41,21 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): self::hardenMysqlIdentityColumns($pdo, $table); } + self::installExpiryIndex($pdo, $table, $driver); + } + + 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', + ); + } + + private static function installExpiryIndex(PDO $pdo, string $table, string $driver): void + { $index = $table . '_expires_idx'; if (in_array($driver, ['mysql', 'mariadb'], true)) { @@ -59,16 +74,6 @@ 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', - ); - } - private static function mysqlIdentityColumnsAreBinary(PDO $pdo, string $table): bool { $statement = $pdo->prepare( diff --git a/src/Cache/Adapter/PdoTagGenerationStore.php b/src/Cache/Adapter/PdoTagGenerationStore.php index eda8f7b..f0f7e2e 100644 --- a/src/Cache/Adapter/PdoTagGenerationStore.php +++ b/src/Cache/Adapter/PdoTagGenerationStore.php @@ -83,10 +83,7 @@ private static function fetch(PDO $pdo, string $table, string $namespace, array if (!is_array($row) || !is_string($row['cache_key'] ?? null)) { continue; } - $payload = $row['payload'] ?? null; - if (is_resource($payload)) { - $payload = stream_get_contents($payload); - } + $payload = AdapterValueNormalizer::pdoPayload($row['payload'] ?? null); if (is_string($payload)) { $stored[$row['cache_key']] = $payload; } diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index d452b4c..aa16235 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -296,21 +296,19 @@ public function multiFetch(array $keys): array $stale = []; foreach ($keys as $idx => $k) { $v = $rawVals[$idx] ?? null; - if ($v !== null && $v !== false) { - if (!is_string($v)) { - $items[$k] = new CacheItem($this, $k); + if (!is_string($v)) { + $items[$k] = new CacheItem($this, $k); - continue; - } + continue; + } - $record = $this->decodeRecordFromBlob($v, $k); - if ($record !== null) { - $items[$k] = $this->genericItemFromRecord($k, $record); + $record = $this->decodeRecordFromBlob($v, $k); + if ($record !== null) { + $items[$k] = $this->genericItemFromRecord($k, $record); - continue; - } - $stale[] = [$this->map($k), $v]; + continue; } + $stale[] = [$this->map($k), $v]; $items[$k] = new CacheItem($this, $k); } diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 4a0d028..343c9af 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -89,24 +89,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface $mapped = $this->map($key); - return $this->withExclusiveLock(function () use ($key, $mapped): CacheItemInterface { - $store = $this->loadStore(); - $blob = $store[$mapped] ?? null; - if (!is_string($blob)) { - return $this->genericMiss($key); - } - - $record = $this->decodeRecordFromBlob($blob, $key); - unset($store[$mapped]); - if (!$this->store($store)) { - throw new RuntimeException('Unable to persist shared-memory atomic consume.'); - } - if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record, $store)) { - return $this->genericMiss($key); - } - - return $this->genericItemFromRecord($key, $record); - }); + return $this->withExclusiveLock(fn(): CacheItemInterface => $this->consumeRecord($key, $mapped)); } public function atomicSetIfAbsent(CacheItemInterface $item): bool @@ -173,14 +156,7 @@ public function deleteItems(array $keys): bool $mappedKeys[] = $this->map($key); } - return $this->withExclusiveLock(function () use ($mappedKeys): bool { - $store = $this->loadStore(); - foreach ($mappedKeys as $mapped) { - unset($store[$mapped]); - } - - return $this->store($store); - }); + return $this->withExclusiveLock(fn(): bool => $this->updateStore([], $mappedKeys)); } public function getItem(string $key): CacheItem @@ -208,26 +184,7 @@ function () use ($mapped): ?string { #[\Override] public function getTagGenerations(array $tags): array { - 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.'); - } - - return $generations; - }); + return $this->withExclusiveLock(fn(): array => $this->initializeTagGenerations($tags)); } public function hasItem(string $key): bool @@ -241,20 +198,7 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - return $this->withSharedLock(function () use ($keys): array { - $store = $this->loadStore(); - $items = []; - foreach ($keys as $key) { - $mapped = $this->map($key); - $blob = $store[$mapped] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; - $items[$key] = $record === null - ? $this->genericMiss($key) - : $this->genericItemFromRecord($key, $record); - } - - return $items; - }); + return $this->withSharedLock(fn(): array => $this->fetchStoredItems($keys)); } /** @param list $tags */ @@ -320,17 +264,7 @@ public function saveItems(array $items): bool $records[$this->map($item->getKey())] = $this->encodeItem($item, $expiration['expiresAt']); } - return $this->withExclusiveLock(function () use ($records, $expired): bool { - $store = $this->loadStore(); - foreach ($expired as $key) { - unset($store[$key]); - } - foreach ($records as $key => $blob) { - $store[$key] = $blob; - } - - return $this->store($store); - }); + return $this->withExclusiveLock(fn(): bool => $this->updateStore($records, $expired)); } /** @param array $generations */ @@ -384,6 +318,26 @@ private function cachedRecord(array $store, string $key, string $mapped): ?Cache : null; } + private function consumeRecord(string $key, string $mapped): CacheItemInterface + { + $store = $this->loadStore(); + $blob = $store[$mapped] ?? null; + if (!is_string($blob)) { + return $this->genericMiss($key); + } + + $record = $this->decodeRecordFromBlob($blob, $key); + unset($store[$mapped]); + if (!$this->store($store)) { + throw new RuntimeException('Unable to persist shared-memory atomic consume.'); + } + if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record, $store)) { + return $this->genericMiss($key); + } + + return $this->genericItemFromRecord($key, $record); + } + private function createTokenFile(): string { $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) @@ -411,6 +365,52 @@ private function createTokenFile(): string return $tokenFile; } + /** + * @param list $keys + * @return array + */ + private function fetchStoredItems(array $keys): array + { + $store = $this->loadStore(); + $items = []; + foreach ($keys as $key) { + $mapped = $this->map($key); + $blob = $store[$mapped] ?? null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; + $items[$key] = $record === null + ? $this->genericMiss($key) + : $this->genericItemFromRecord($key, $record); + } + + return $items; + } + + /** + * @param list $tags + * @return array + */ + private function initializeTagGenerations(array $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.'); + } + + return $generations; + } + /** * @phpstan-return array */ @@ -502,6 +502,23 @@ private function store(array $store): bool return shm_put_var($this->segment, self::VAR_ID, $store); } + /** + * @param array $records + * @param list $expired + */ + private function updateStore(array $records, array $expired): bool + { + $store = $this->loadStore(); + foreach ($expired as $key) { + unset($store[$key]); + } + foreach ($records as $key => $blob) { + $store[$key] = $blob; + } + + return $this->store($store); + } + /** * @template T * @param callable $operation The operation to run while locked. diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index cedbcfb..35a6b61 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -177,8 +177,7 @@ public function multiFetch(array $keys): array $staleScalar = []; foreach ($keys as $key) { $mapped = $this->map($key); - $reference = $this->weakRefs[$mapped] ?? null; - $object = $reference instanceof WeakReference ? $reference->get() : null; + $object = ($this->weakRefs[$mapped] ?? null)?->get(); $expiresAt = $this->weakExpires[$mapped] ?? null; if (is_object($object) && !CachePayloadCodec::isExpired($expiresAt)) { $items[$key] = new CacheItem( diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 047c095..b95f8c4 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -544,11 +544,7 @@ public function remember( $generations = $this->captureTagGenerations($tags); $this->metric('remember_unlocked_compute'); $value = $resolver(); - if ($generations !== null && $this->tagGenerationsUnchanged($generations)) { - $this->storeResolved($key, $value, $ttl, $generations); - } elseif ($tags !== []) { - $this->metric('remember_discarded_after_tag_change'); - } + $this->storeRemembered($key, $value, $ttl, $generations, $tags !== []); return $value; } @@ -573,14 +569,7 @@ public function remember( return $value; } - if ($generations === null || !$this->tagGenerationsUnchanged($generations)) { - if ($tags !== []) { - $this->metric('remember_discarded_after_tag_change'); - } - - return $value; - } - $this->storeResolved($key, $value, $ttl, $generations); + $this->storeRemembered($key, $value, $ttl, $generations, $tags !== []); return $value; } finally { @@ -858,6 +847,24 @@ private function stampedeLockKey(string $key): string return 'cachelayer:remember:' . hash('xxh128', $this->namespace . "\0" . $key); } + /** @param array|null $generations */ + private function storeRemembered( + string $key, + mixed $value, + ?int $ttl, + ?array $generations, + bool $tagged, + ): void { + if ($generations !== null && $this->tagGenerationsUnchanged($generations)) { + $this->storeResolved($key, $value, $ttl, $generations); + + return; + } + if ($tagged) { + $this->metric('remember_discarded_after_tag_change'); + } + } + /** @param array $generations */ private function storeResolved(string $key, mixed $value, ?int $ttl, array $generations): void { diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php index cfa12f8..959ac73 100644 --- a/src/Integration/Runwire/RunwireIntegration.php +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -85,7 +85,8 @@ public static function flushMemoizers(): void public static function memoizer(): ?Memoizer { - $request = self::current()?->request; + $context = self::current(); + $request = $context?->request; if ($request !== null) { $memoizer = $request->attribute(self::MEMOIZER_ATTRIBUTE); if ($memoizer instanceof Memoizer) { @@ -98,7 +99,8 @@ public static function memoizer(): ?Memoizer return $memoizer; } - if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + $runtime = $context->runtime ?? self::$runtime; + if ($runtime?->persistent === true && $runtime->concurrent) { return null; } @@ -107,7 +109,8 @@ public static function memoizer(): ?Memoizer public static function onceMemoizer(): ?OnceMemoizer { - $request = self::current()?->request; + $context = self::current(); + $request = $context?->request; if ($request !== null) { $memoizer = $request->attribute(self::ONCE_MEMOIZER_ATTRIBUTE); if ($memoizer instanceof OnceMemoizer) { @@ -120,7 +123,8 @@ public static function onceMemoizer(): ?OnceMemoizer return $memoizer; } - if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + $runtime = $context->runtime ?? self::$runtime; + if ($runtime?->persistent === true && $runtime->concurrent) { return null; } @@ -140,15 +144,17 @@ public static function release(?RuntimeContext $runtime = null): void public static function runtime(): ?RuntimeContext { - return self::$runtime; + return self::current()->runtime ?? self::$runtime; } + /** Borrow an explicit host runtime for this callback without global lifecycle ownership. */ public static function share( ?RequestContext $request, ?CoroutineScope $scope, callable $callback, + ?RuntimeContext $runtime = null, ): mixed { - $runtime = self::$runtime; + $runtime ??= self::runtime(); if ($runtime === null) { return $callback(); } @@ -207,7 +213,7 @@ public static function sleep(float $seconds): void public static function supports(RuntimeCapability $capability): bool { - return self::$runtime?->supports($capability) ?? false; + return self::runtime()?->supports($capability) ?? false; } private static function flushGlobalMemoizers(): void diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index bc0e859..6f340a8 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -323,6 +323,19 @@ private function attempt(callable $operation, mixed $fallback, string $failureMe } } + private function copyItemTo(InternalCachePoolInterface $pool, CacheItemInterface $item): CacheItemInterface + { + $target = $pool->createItem($item->getKey())->set($item->get()); + if ($item instanceof CacheItem) { + $target->expiresAfter($item->ttlSeconds()); + if ($target instanceof CacheItem) { + $target->setTagGenerations($item->getTagGenerations()); + } + } + + return $target; + } + private function disableL1(): void { if (!$this->l1Readable) { @@ -383,9 +396,10 @@ private function readL1(array $keys): array $item = $l1Items[$key] ?? null; if ($item instanceof CacheItemInterface && $item->isHit()) { $results[$key] = $this->nodeItem($item); - } else { - $misses[] = $key; + + continue; } + $misses[] = $key; } $this->metric('l1_batch_hit', count($keys) - count($misses)); $this->metric('l1_batch_miss', count($misses)); @@ -437,14 +451,7 @@ private function saveInto( $targets = []; foreach ($items as $item) { $key = $item->getKey(); - $target = $pool->createItem($key)->set($item->get()); - if ($item instanceof CacheItem) { - $target->expiresAfter($item->ttlSeconds()); - if ($target instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); - } - } - $targets[$key] = $target; + $targets[$key] = $this->copyItemTo($pool, $item); } return $this->attempt(fn(): bool => $pool->saveItems($targets), false, $failureMetric); @@ -455,13 +462,7 @@ private function saveOneInto( CacheItemInterface $item, string $failureMetric, ): bool { - $target = $pool->createItem($item->getKey())->set($item->get()); - if ($item instanceof CacheItem) { - $target->expiresAfter($item->ttlSeconds()); - if ($target instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); - } - } + $target = $this->copyItemTo($pool, $item); return $this->attempt(fn(): bool => $pool->save($target), false, $failureMetric); } diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index 9b6863f..3531956 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -340,12 +340,12 @@ public function saveMany(array $items): bool try { $this->connection->beginTransaction(); $this->ownsTransaction = true; - if ($expired !== [] && !$this->deleteItems($expired)) { + if (!$this->deleteItems($expired)) { $this->rollBack(); return false; } - if ($rows !== [] && !$this->upsertRows($rows)) { + if (!$this->upsertRows($rows)) { $this->rollBack(); return false; diff --git a/src/functions.php b/src/functions.php index 40d626d..7393470 100644 --- a/src/functions.php +++ b/src/functions.php @@ -56,9 +56,11 @@ function once(callable $callback): mixed { $memoizer = RunwireIntegration::onceMemoizer(); - return $memoizer === null - ? $callback() - : $memoizer->once($callback, 1); + if ($memoizer === null) { + return $callback(); + } + + return $memoizer->once($callback, 1); } } diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 4e0c990..7200660 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -217,3 +217,39 @@ 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' => [[]]]); + + +test('payload codec rejects malformed namespace and tag generations', function (mixed $invalid) { + $codec = new CachePayloadCodec(); + $record = [ + 'format' => 2, + 'encoding' => 'native', + 'value' => null, + 'expires' => null, + 'tags' => [], + 'namespace' => null, + ]; + + expect($codec->decode('cl2:' . serialize(array_replace($record, ['namespace' => $invalid])))) + ->toBeNull() + ->and($codec->decode('cl2:' . serialize(array_replace($record, ['tags' => ['group' => $invalid]])))) + ->toBeNull(); +})->with([ + 'empty' => [''], + 'short hex' => [str_repeat('a', 31)], + 'long hex' => [str_repeat('a', 33)], + 'non-hex' => [str_repeat('z', 32)], + 'integer' => [123], + 'array' => [[]], +]); + +test('payload codec preserves valid uppercase generations and null values', function () { + $codec = new CachePayloadCodec(); + $generation = str_repeat('AB', 16); + $record = $codec->decode($codec->encode(null, null, ['group' => $generation], $generation)); + + expect($record)->not->toBeNull() + ->and($record->value)->toBeNull() + ->and($record->namespaceGeneration)->toBe($generation) + ->and($record->tags)->toBe(['group' => $generation]); +}); diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php index 28078c5..b0786e2 100644 --- a/tests/Integration/RunwireIntegrationTest.php +++ b/tests/Integration/RunwireIntegrationTest.php @@ -267,3 +267,64 @@ static function (): void { expect($result)->toBe([1, 1, 1]); }); + +it('borrows an explicit runtime without global binding and restores nested task contexts', function (): void { + $runtime = \Infocyph\Runwire\RuntimeContext::standalone(); + $request = \Infocyph\Runwire\RequestContext::create($runtime); + $nested = \Infocyph\Runwire\RequestContext::create($runtime); + $before = \Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::runtime(); + try { + \Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::share($request, null, static function () use ($runtime, $request, $nested): void { + expect(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::runtime())->toBe($runtime) + ->and(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::current()?->request)->toBe($request); + expect(fn() => \Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::share($nested, null, static function (): void { + throw new RuntimeException('nested failure'); + }))->toThrow(RuntimeException::class, 'nested failure'); + expect(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::current()?->request)->toBe($request); + }, $runtime); + expect(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::runtime())->toBe($before) + ->and(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::current())->toBeNull(); + } finally { + $request->complete(); + $nested->complete(); + } +}); + +it('keeps explicitly passed request scopes separate across overlapping Fibers', function (): void { + $runtime = \Infocyph\Runwire\RuntimeContext::standalone(); + $one = \Infocyph\Runwire\RequestContext::create($runtime); + $two = \Infocyph\Runwire\RequestContext::create($runtime); + $work = static fn($request) => \Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::share($request, null, static function () use ($request): object { + $memoizer = \Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::memoizer(); + Fiber::suspend($memoizer); + expect(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::current()?->request)->toBe($request); + + return $request; + }, $runtime); + $first = new Fiber(static fn() => $work($one)); + $second = new Fiber(static fn() => $work($two)); + try { + $firstMemoizer = $first->start(); + $secondMemoizer = $second->start(); + expect($firstMemoizer)->not->toBe($secondMemoizer); + $second->resume(); + $first->resume(); + expect($first->getReturn())->toBe($one) + ->and($second->getReturn())->toBe($two) + ->and(\Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration::current())->toBeNull(); + } finally { + $one->complete(); + $two->complete(); + } +}); + +it('bypasses global memoizers for an explicitly passed concurrent runtime without a request', function (): void { + $runtime = cacheLayerRunwireContext(true, true); + RunwireIntegration::share(null, null, static function () use ($runtime): void { + expect(RunwireIntegration::runtime())->toBe($runtime) + ->and(RunwireIntegration::supports(RuntimeCapability::RUNWIRE_COROUTINES))->toBeTrue() + ->and(RunwireIntegration::memoizer())->toBeNull() + ->and(RunwireIntegration::onceMemoizer())->toBeNull(); + }, $runtime); + expect(RunwireIntegration::runtime())->toBeNull(); +}); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 8b77588..77f52db 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -242,3 +242,19 @@ public function next(): int expect($first->get($callback))->toBe(1) ->and($runs)->toBe(1); }); + + +it('once() caches null results without repeating callback side effects', function () { + $runs = 0; + $callback = static function () use (&$runs): mixed { + $runs++; + + return null; + }; + + for ($attempt = 0; $attempt < 3; $attempt++) { + expect(once($callback))->toBeNull(); + } + + expect($runs)->toBe(1); +});