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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<namespace>:` 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.
Expand Down
9 changes: 9 additions & 0 deletions src/Cache/Adapter/AdapterValueNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|null
* @param object $value The value argument.
Expand Down
15 changes: 8 additions & 7 deletions src/Cache/Adapter/CachePayloadCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand Down
8 changes: 3 additions & 5 deletions src/Cache/Adapter/MemcachedCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
63 changes: 34 additions & 29 deletions src/Cache/Adapter/MongoDbCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
41 changes: 26 additions & 15 deletions src/Cache/Adapter/PdoCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -351,6 +338,30 @@ private function hydrate(string $key, array $row): ?CacheItem
return $record === null ? null : $this->genericItemFromRecord($key, $record);
}

/**
* @param array<mixed> $records
* @return array<string, array{payload:string, expires:int|null}>
*/
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<array{0:string, 1:string, 2:string, 3:int|null}> $rows */
private function upsertChunk(array $rows): bool
{
Expand Down
25 changes: 15 additions & 10 deletions src/Cache/Adapter/PdoCacheSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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(
Expand Down
5 changes: 1 addition & 4 deletions src/Cache/Adapter/PdoTagGenerationStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 9 additions & 11 deletions src/Cache/Adapter/RedisCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading