diff --git a/docs/plans/2026-07-03-swoole-store-interval-cache-refresh.md b/docs/plans/2026-07-03-swoole-store-interval-cache-refresh.md new file mode 100644 index 000000000..26fb1bd47 --- /dev/null +++ b/docs/plans/2026-07-03-swoole-store-interval-cache-refresh.md @@ -0,0 +1,803 @@ +# Swoole Store Interval Cache Refresh + +## Goal + +Make Swoole interval caches refresh correctly across workers and from the manager timer, without adding work to normal cache misses or hot reads. + +The final code should read as if interval caches were always designed as shared Swoole cache state: + +- `interval()` registers interval metadata in the shared table. +- A shared interval index lets the manager process discover registered intervals. +- The manager process refreshes due intervals on a dedicated short timer. +- Normal `get()` does not scan or consult the shared interval index. +- The registering store instance can still resolve a local interval before the first timer tick. +- Resolver callbacks never run while a row lock is held. +- Resolver failures are reported and do not permanently suppress future refreshes. +- Hard failures during refresh do not freeze an interval forever; stale claims are reclaimable. + +This plan depends on the row-concurrency plan's shared `SwooleTableState`, seeded physical table-key mapping, raw helper methods, local interval metadata migration, and row-lock invariants. + +## Research + +Files checked: + +- `src/cache/src/SwooleStore.php` +- `src/cache/src/SwooleTableManager.php` +- `src/cache/src/CacheManager.php` +- `src/cache/src/CacheServiceProvider.php` +- `src/cache/src/Listeners/CreateSwooleTable.php` +- `src/cache/src/Listeners/CreateTimer.php` +- `src/cache/src/Listeners/BaseListener.php` +- `src/foundation/config/cache.php` +- `src/boost/docs/cache.md` +- `src/boost/docs/octane.md` +- `src/contracts/src/Debug/ExceptionHandler.php` +- `src/foundation/src/Bootstrap/HandleExceptions.php` +- `tests/Cache/CacheSwooleStoreTest.php` + +Previous interval behavior at the start of this work: + +```php +protected array $intervals = []; + +public function interval(string $key, Closure $resolver, int $seconds): void +{ + if (! is_null($this->getInterval($key))) { + $this->intervals[] = $key; + + return; + } + + $this->forever('interval-' . $key, serialize([ + 'resolver' => new SerializableClosure($resolver), + 'lastRefreshedAt' => null, + 'refreshInterval' => $seconds, + ])); + + $this->intervals[] = $key; +} + +public function refreshIntervalCaches(): void +{ + foreach ($this->intervals as $key) { + // refresh due local intervals + } +} +``` + +Current timer behavior: + +```php +Timer::tick($config['eviction_interval'] ?? 10000, function () use ($name) { + $store = Cache::store($name)->getStore(); + + $store->evictRecords(); +}); +``` + +The timer runs in the manager process on `OnManagerStart`, but it only calls `evictRecords()`. It never calls `refreshIntervalCaches()`. + +Even if the timer did call `refreshIntervalCaches()`, the manager process's `SwooleStore` instance has an empty local `$intervals` list. Workers register intervals after resolving their own store instances; that local PHP array is not shared with the manager process. + +## Defects + +### Interval registrations are not discoverable by the manager process + +The metadata row is shared, but the list of keys to refresh is local process memory. The manager process cannot know which intervals exist. + +### No timer refreshes interval caches + +`CreateTimer` currently creates only an eviction timer. Interval caches are documented as automatically refreshed, but no timer calls `refreshIntervalCaches()`. + +### Current metadata keys leak into the user key namespace + +Internal rows use `interval-{$key}`. `flush()` skips every key starting with `interval-`, so a user cache key with that prefix is accidentally preserved. + +The row-concurrency plan fixes the immediate key leak by moving today's local interval metadata to the seeded `i:` control-key namespace. This plan builds on that by adding shared `x:` interval index rows and manager-driven refresh. + +### Interval registration can duplicate local keys + +Calling `interval('foo', ...)` more than once appends `foo` to `$intervals` each time. + +### Resolver exceptions can break refresh behavior + +The current refresh path updates `lastRefreshedAt` before running the resolver. If the resolver throws, the timestamp remains fresh even though the value was not refreshed. + +## Decisions + +### Use a shared internal interval index + +Add an internal index of registered interval cache keys in the Swoole table. The manager timer reads this index to know which interval metadata rows to evaluate. + +Use sharded index rows rather than one large row: + +```php +protected const INTERVAL_INDEX_SHARDS = 64; +protected const INTERVAL_INDEX_PREFIX = 'x:'; + +protected function intervalIndexKey(string $metadataKey): string +{ + return self::INTERVAL_INDEX_PREFIX . (crc32($metadataKey) % self::INTERVAL_INDEX_SHARDS); +} +``` + +Each shard stores an associative array of bounded interval metadata table keys: + +```php +[ + 'i:27a2...' => true, + 'i:91bf...' => true, +] +``` + +Why sharded rows: + +- The timer reads at most 64 small rows instead of scanning the whole cache table. +- It avoids a single index row growing too quickly in the table's fixed-size string column. +- It stores bounded `i:` metadata table keys rather than unbounded logical cache keys, so shard size does not depend on application key length. +- It adds no work to normal `get()` or `put()`. +- It does not require a second Swoole table whose memory size would need separate tuning. + +The index is for refresh discovery only. Normal `get()` misses must not read it. + +### Store interval metadata once, under seeded control keys + +Use the row-concurrency plan's `intervalKey($key)` helper for one metadata row per interval: + +```php +protected function intervalKey(string $key): string +{ + return $this->hashedTableKey(self::INTERVAL_PREFIX, $key); +} +``` + +Metadata shape: + +```php +[ + 'key' => $key, + 'metadataKey' => $this->intervalKey($key), + 'resolver' => serialize(new SerializableClosure($resolver)), + 'lastRefreshedAt' => null, + 'refreshingAt' => null, + 'refreshInterval' => $seconds, +] +``` + +Store it with the raw serialized row helper: + +```php +$this->rawPutSerialized( + $this->intervalKey($key), + serialize($metadata), + $this->expiration(static::ONE_YEAR), +); +``` + +Do not double-serialize metadata by passing an already serialized string through public `forever()`. + +Why: + +- The metadata key is bounded, seeded, and does not collide with ordinary `interval-*` user keys. +- Storing the original key inside metadata lets refresh code write the public cache value without depending on index row keys. +- Storing the resolver as serialized bytes keeps metadata serialization inside the row lock to scalar/string data only. Any captured-object `__sleep()` or `__wakeup()` work happens outside the lock when the resolver is registered or invoked. +- `lastRefreshedAt` records the last successful refresh. `refreshingAt` is a short-lived claim that prevents overlapping refresh work. +- Keeping the metadata row live for `ONE_YEAR` matches existing SwooleStore forever semantics. + +### `interval()` registers only; it does not eagerly compute the value + +`interval()` should write metadata and update the shared index. It should not run the resolver during service-provider boot. + +```php +public function interval(string $key, Closure $resolver, int $seconds): void +{ + $metadata = [ + 'key' => $key, + 'metadataKey' => $this->intervalKey($key), + 'resolver' => serialize(new SerializableClosure($resolver)), + 'lastRefreshedAt' => null, + 'refreshingAt' => null, + 'refreshInterval' => $seconds, + ]; + + $metadataKey = $this->intervalKey($key); + + $metadataWritten = $this->state->withRowLock($metadataKey, function () use ($metadataKey, $metadata) { + $existing = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($existing !== null) { + $metadata['lastRefreshedAt'] = $existing['lastRefreshedAt']; + $metadata['refreshingAt'] = $existing['refreshingAt']; + } + + return $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + }); + + if (! $metadataWritten) { + throw new RuntimeException("Unable to register Swoole interval cache [{$key}]."); + } + + try { + $this->registerIntervalIndex($metadataKey); + } catch (Throwable $e) { + $this->state->withRowLock($metadataKey, fn () => $this->rawForget($metadataKey)); + + throw $e; + } + + $this->registerLocalInterval($key); +} +``` + +All registration writes must be checked. If writing metadata or the index shard fails, `interval()` should throw a `RuntimeException` instead of silently registering a cache that the timer cannot refresh. If the metadata write succeeds and the index write fails, delete the metadata row before throwing so there is no unreachable interval metadata left behind. + +Index registration stores the bounded metadata table key: + +```php +protected function registerIntervalIndex(string $metadataKey): void +{ + $indexKey = $this->intervalIndexKey($metadataKey); + + $result = $this->state->withRowLock($indexKey, function () use ($indexKey, $metadataKey) { + $record = $this->rawGet($indexKey); + $index = $this->recordIsFalseOrExpired($record) ? [] : unserialize($record['value']); + $index[$metadataKey] = true; + + return $this->rawPutSerialized($indexKey, serialize($index), $this->expiration(static::ONE_YEAR)); + }); + + if (! $result) { + throw new RuntimeException("Unable to register Swoole interval index row [{$indexKey}]."); + } +} +``` + +Why: + +- Boot-time resolver work can be expensive and surprising. +- The first manager timer tick seeds the shared value. +- The registering worker still has same-instance fallback through its local interval list before the first tick. + +Tradeoff: + +- A non-registering worker may return `null` for the interval key until the first interval refresh timer tick seeds the value. With the default 1000ms refresh tick this window is short and avoids expensive boot-time resolver execution in every worker. + +### Keep the local interval list, but only for same-instance fallback + +`$intervals` should remain a local set, not a list: + +```php +/** @var array */ +protected array $intervals = []; + +protected function registerLocalInterval(string $key): void +{ + $this->intervals[$key] = true; +} + +protected function hasLocalInterval(string $key): bool +{ + return isset($this->intervals[$key]); +} +``` + +Why: + +- Existing same-instance behavior is useful: immediately after registration, `get($key)` can compute the value without waiting for the timer. +- The local set should not drive manager refresh. +- A set prevents duplicate local refresh attempts. + +### Add a dedicated interval refresh timer + +Rename `CreateTimer` to `CreateSwooleTimers` or update it so the class name and comments accurately reflect both timers. Because churn is not a constraint, prefer the clearer class name. + +```php +class CreateSwooleTimers extends BaseListener +{ + public function handle(OnManagerStart $event): void + { + $this->swooleStores()->each(function (array $config, string $name) { + Timer::tick($config['eviction_interval'] ?? 10000, function () use ($name) { + $this->store($name)->evictRecords(); + }); + + Timer::tick($config['interval_refresh_interval'] ?? 1000, function () use ($name) { + $this->store($name)->refreshIntervalCaches(); + }); + }); + } + + protected function store(string $name): SwooleStore + { + /** @var SwooleStore */ + return Cache::store($name)->getStore(); + } +} +``` + +Update `CacheServiceProvider` to listen with the renamed class. + +Why a separate timer: + +- `eviction_interval` defaults to 10 seconds. +- Docs show intervals as short as 5 seconds and the first seed should happen quickly. +- Reusing the eviction timer makes interval caches stale by design. + +Add config: + +```php +'swoole' => [ + 'driver' => 'swoole', + 'table' => 'default', + 'memory_limit_buffer' => 0.05, + 'eviction_policy' => SwooleStore::EVICTION_POLICY_LRU, + 'eviction_proportion' => 0.05, + 'eviction_interval' => 10000, // milliseconds + 'interval_refresh_interval' => 1000, // milliseconds +], +``` + +### Refresh due intervals through the shared index + +`refreshIntervalCaches()` should read the shared interval index, then attempt each interval independently. + +```php +public function refreshIntervalCaches(): void +{ + foreach ($this->registeredIntervalMetadataKeys() as $metadataKey) { + $this->refreshIntervalCache($metadataKey); + } +} +``` + +Index reading: + +```php +protected function registeredIntervalMetadataKeys(): array +{ + $metadataKeys = []; + + for ($i = 0; $i < self::INTERVAL_INDEX_SHARDS; ++$i) { + $indexKey = self::INTERVAL_INDEX_PREFIX . $i; + $record = $this->rawGet($indexKey); + + if ($this->recordIsFalseOrExpired($record)) { + continue; + } + + foreach (array_keys(unserialize($record['value'])) as $metadataKey) { + $metadataKeys[$metadataKey] = true; + } + + $this->touchInternalRow($indexKey); + } + + return array_keys($metadataKeys); +} +``` + +`touchInternalRow()` extends internal row expiration without calling public `touch()`: + +```php +protected function touchInternalRow(string $key): void +{ + $this->state->withRowLock($key, function () use ($key) { + if ($this->rawGet($key) !== false) { + $this->table->set($key, ['expiration' => $this->expiration(static::ONE_YEAR)]); + } + }); +} +``` + +Why: + +- The index should not silently expire one year after boot if intervals remain active. +- Touching 64 small index rows once per second is negligible compared to scanning the cache table. + +### Claim refresh work before running the resolver + +Refreshing one interval should: + +1. Lock the metadata row. +2. Read metadata. +3. If missing or not due, return. +4. If claimed and the claim is fresh, return. +5. If claimed and stale, reclaim it. +6. Set `refreshingAt` to the current microsecond timestamp as the claim token. +7. Release the lock. +8. Run the resolver outside locks. +9. Write the public cache value. +10. Lock metadata again and set `lastRefreshedAt` to the claim timestamp and `refreshingAt` to `null`. +11. If resolver throws, report or rethrow according to the caller and clear the claim if it is still current. + +Sketch: + +```php +protected const INTERVAL_REFRESH_CLAIM_TIMEOUT = 300.0; + +protected function refreshIntervalCache(string $metadataKey, bool $force = false, bool $rethrow = false): mixed +{ + $now = $this->getCurrentTimestamp(); + + $claim = $this->state->withRowLock($metadataKey, function () use ($metadataKey, $now, $force) { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if (! $metadata) { + return null; + } + + if (! $force && ! $this->intervalShouldBeRefreshed($metadata, $now)) { + return null; + } + + if ($metadata['refreshingAt'] !== null + && ! $this->intervalClaimIsStale($metadata['refreshingAt'], $now, $metadata['refreshInterval'])) { + return null; + } + + $metadata['refreshingAt'] = $now; + $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + + return [$metadata, $now]; + }); + + if ($claim === null) { + return null; + } + + [$metadata, $claimedAt] = $claim; + + try { + /** @var SerializableClosure $resolver */ + $resolver = unserialize($metadata['resolver']); + $value = $resolver(); + + if (! $this->forever($metadata['key'], $value)) { + throw new RuntimeException("Unable to refresh Swoole interval cache [{$metadata['key']}]."); + } + + $this->completeIntervalRefresh($metadataKey, $claimedAt); + + return $value; + } catch (Throwable $e) { + $this->clearIntervalClaim($metadataKey, $claimedAt); + + if ($rethrow) { + throw $e; + } + + $this->reportIntervalException($e); + + return null; + } +} +``` + +Completion and claim clearing: + +```php +protected function completeIntervalRefresh(string $metadataKey, float $claimedAt): void +{ + $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt) { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if (! $metadata || $metadata['refreshingAt'] !== $claimedAt) { + return; + } + + $metadata['lastRefreshedAt'] = $claimedAt; + $metadata['refreshingAt'] = null; + $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + }); +} + +protected function clearIntervalClaim(string $metadataKey, float $claimedAt): void +{ + $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt) { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if (! $metadata || $metadata['refreshingAt'] !== $claimedAt) { + return; + } + + $metadata['refreshingAt'] = null; + $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + }); +} +``` + +`$claimedAt` is a microsecond-precision float from `getCurrentTimestamp()`. The snippets use it as a token; strict comparison should compare the exact stored float value read from and written to the table payload, not recompute it. + +Stale claim helper: + +```php +protected function intervalClaimIsStale(float $refreshingAt, float $now, int $refreshInterval): bool +{ + $timeout = max(static::INTERVAL_REFRESH_CLAIM_TIMEOUT, $refreshInterval * 2); + + return ($now - $refreshingAt) >= $timeout; +} +``` + +Exception reporting: + +```php +protected function reportIntervalException(Throwable $e): void +{ + if (Container::getInstance()->bound(ExceptionHandler::class)) { + Container::getInstance()->make(ExceptionHandler::class)->report($e); + + return; + } + + file_put_contents('php://stderr', (string) $e . PHP_EOL); +} +``` + +Use the repository's normal container access pattern when implementing; do not introduce a constructor dependency that makes `SwooleStore` hard to instantiate in unit tests. This is an intentional narrow error-path dependency on `Container::getInstance()`, guarded by `bound()` and backed by stderr if the manager process somehow has no exception handler binding. + +Why: + +- Claiming prevents overlapping slow refreshes from multiple timer callbacks or processes. +- Stale-claim reclamation prevents a hard crash after claim acquisition from freezing an interval until server restart. +- Running the resolver outside the lock prevents user code from blocking cache writes. +- A resolver that runs longer than the stale-claim timeout could have its late public-value write race with a newer refresh. The generous timeout floor makes this unrealistic for intended interval resolvers and the value self-corrects on the next refresh. Keep a source comment near `INTERVAL_REFRESH_CLAIM_TIMEOUT` so the timeout is not lowered casually. +- A refresh is not completed unless writing the public cache value succeeds. +- Completing or clearing only if the claim still matches avoids undoing newer work. +- Exceptions should be visible to the application's exception handling pipeline. + +### `get()` should preserve same-instance fallback only + +Normal `get()` should not consult the shared interval index. It should only attempt local fallback when the current store instance registered that key locally. + +```php +if ($this->hasLocalInterval($key)) { + return $this->refreshIntervalCache($this->intervalKey($key), force: true, rethrow: true); +} +``` + +Why: + +- A generic miss must stay cheap. +- Checking the shared index on every miss would add overhead to all applications for a feature used by few keys. +- Same-instance fallback preserves the useful immediate-read behavior after registration. +- Local fallback uses the same `refreshingAt` claim as the timer, so it does not overlap an in-progress manager refresh. +- If a timer refresh is already in progress, same-instance fallback returns `null` rather than blocking or running the resolver a second time. The next read sees the refreshed value after the in-flight refresh writes it. + +### `flush()` preserves interval metadata and index rows + +The row-concurrency plan changes `flush()` to delete only `u:` user rows and preserve control rows. That must include: + +- interval metadata rows, +- interval index shard rows, +- lock rows. + +`flush()` should delete the actual public cached value for an interval key. The next timer refresh or same-instance fallback can recreate it. + +### Control rows should not be evicted as normal cache data + +Stale cleanup and eviction should skip interval metadata and index rows. + +Why: + +- Interval metadata is control-plane state, not user cache data. +- Active interval rows are kept alive by registration and refresh touches. +- Expired lock rows may be cleaned by lock-aware paths, but general user-data eviction should not choose control rows as victims. + +## Implementation Steps + +1. Apply the row-concurrency plan first. + + Why: this plan relies on `SwooleTableState`, seeded physical key helpers, raw row helpers, and row-lock invariants. + + How: do not start interval implementation while `SwooleStore` still uses public methods recursively for internal writes. + +2. Add interval index key helpers. + + Why: row plan already provides seeded `i:` metadata keys; this plan needs bounded `x:` index shard keys for shared timer discovery. + + How: add `INTERVAL_INDEX_PREFIX = 'x:'`, `INTERVAL_INDEX_SHARDS`, and `intervalIndexKey()`. Keep using the row plan's `intervalKey()` for metadata. + +3. Convert `$intervals` to a local set. + + Why: avoid duplicate local fallback work. + + How: change the property docblock and replace `in_array()` / append usage with `isset()` / assignment. + +4. Rewrite interval metadata accessors. + + Why: avoid public `get()` recursion, double serialization, and duplicated plan-1 helpers. + + How: replace plan 1's `getInterval()` with one metadata accessor vocabulary centered on `getIntervalMetadataByInternalKey()` and `putIntervalMetadataByInternalKey()`. Store the resolver as serialized bytes so these accessors only serialize scalar/string metadata inside row locks. Add logical-key wrappers only if they have real call sites; do not keep an unused `getIntervalMetadata()` helper. + +5. Implement shared index registration. + + Why: manager process needs cross-worker discovery. + + How: lock the index shard row, decode or start an empty array, set `$index[$key] = true`, write it back with `ONE_YEAR` expiration, and throw if the write fails. + +6. Rewrite `interval()`. + + Why: registration should be shared and cheap. + + How: serialize the `SerializableClosure` before acquiring the metadata row lock, write metadata under the metadata row lock, preserve an existing `lastRefreshedAt` / `refreshingAt` if the same key is re-registered, register the index shard, register the local set, do not run the resolver. + +7. Rewrite `refreshIntervalCaches()`. + + Why: manager timer should refresh all shared registrations, not just local registrations. + + How: read registered metadata table keys from index shards with `registeredIntervalMetadataKeys()` and call `refreshIntervalCache($metadataKey)` for each. + +8. Add claim / resolver / completion refresh flow. + + Why: prevent overlapping refreshes and avoid poisoning successful-refresh timestamps after exceptions. + + How: claim with `refreshingAt` under metadata row lock, unserialize and run the resolver outside locks, write public value through `forever()`, complete by setting `lastRefreshedAt` and clearing `refreshingAt`, and clear/report or clear/rethrow on failure. + +9. Replace plan 1's local interval fallback in `get()`. + + Why: plan 1 directly calls the resolver after `getInterval()`. Plan 2 must use the same claim path as the timer so fallback and timer refresh do not overlap. + + How: replace the fallback with `refreshIntervalCache($this->intervalKey($key), force: true, rethrow: true)`. Document that a concurrent non-stale claim returns `null` rather than blocking or duplicating resolver work. + +10. Update `intervalShouldBeRefreshed()` signature. + + Why: plan 1's method takes only `array $interval`; plan 2 due checks need the current timestamp passed in so claim decisions use one consistent time sample. + + How: change it to `intervalShouldBeRefreshed(array $metadata, float $now): bool`. Store `lastRefreshedAt` as the successful claim's microsecond float and compare `($now - $metadata['lastRefreshedAt']) >= $metadata['refreshInterval']`. + +11. Update timer listener. + + Why: interval caches currently have no automatic refresh timer. + + How: rename `CreateTimer` to `CreateSwooleTimers` or otherwise make name/comments accurate; create both eviction and interval refresh timers from `OnManagerStart`. + +12. Update config and docs. + + Why: users need the new interval timer setting and docs should match first-tick behavior. + + How: add `interval_refresh_interval` to framework config and docs; update Octane interval docs to state that the manager refresh timer seeds and refreshes interval values, so a non-registering worker may not see a value until the first tick. + +13. Remove stale interval comments and docs. + + Why: final code should not preserve old "minutes" wording or imply eager refresh. + + How: change the `interval()` docblock to seconds, delete obsolete comments, and avoid mentioning the old `interval-` key scheme. + +## Testing Plan + +Run each touched test file immediately after updating it, then run the relevant package suite. + +Commands: + +```bash +vendor/bin/phpunit tests/Cache/CacheSwooleStoreTest.php +vendor/bin/phpunit tests/Cache/CacheSwooleStoreIntervalTest.php +vendor/bin/phpunit tests/Cache +``` + +### Store-level interval tests + +Add a dedicated `tests/Cache/CacheSwooleStoreIntervalTest.php` or expand `CacheSwooleStoreTest.php` if the file remains readable. Prefer a dedicated file if row-concurrency tests make the existing file large. + +Required coverage: + +- `interval()` writes metadata under the seeded `i:` control key, not `interval-{$key}`. +- `interval()` writes the original public key into metadata. +- `interval()` registers the bounded `i:` metadata table key in exactly one `x:` shared index shard, not the raw logical key. +- Calling `interval('foo', ...)` twice does not duplicate local registration or index entries. +- `get('foo')` on the same store instance can resolve through local fallback before the first timer tick. +- `get('foo')` on a different store instance sharing the same state returns `null` before the first timer tick. +- `refreshIntervalCaches()` on a different store instance with an empty local interval set discovers `foo` through the shared index and writes the public cached value. +- After refresh, any store instance sharing the state can read the public value. +- Refresh does not run the resolver again before `refreshInterval` seconds have elapsed. +- Refresh runs the resolver again after the interval is due. +- Metadata keeps `lastRefreshedAt` as the last successful refresh and clears `refreshingAt` after success. +- A second refresh attempt returns without running the resolver while `refreshingAt` is set. +- A stale `refreshingAt` claim older than `max(INTERVAL_REFRESH_CLAIM_TIMEOUT, refreshInterval * 2)` can be reclaimed and refreshed. +- `refreshingAt` uses microsecond float precision, and completion/clearing does not clear a newer claim. +- Same-instance fallback during a non-stale in-flight timer claim returns `null` and does not run the resolver a second time. +- `flush()` deletes the public value but preserves metadata and index rows. +- After `flush()`, `refreshIntervalCaches()` can recreate the public value from preserved metadata. +- Generic `get('missing')` does not consult the shared interval index. +- Index shard rows are touched during refresh discovery so they do not expire while intervals remain active. +- Stale cleanup and eviction skip interval metadata and index rows. + +Use two stores sharing one state: + +```php +$state = $this->createState(); +$workerStore = $this->createStore($state); +$managerStore = $this->createStore($state); + +$workerStore->interval('foo', fn () => 'bar', 5); + +$this->assertNull($managerStore->get('foo')); + +$managerStore->refreshIntervalCaches(); + +$this->assertSame('bar', $workerStore->get('foo')); +$this->assertSame('bar', $managerStore->get('foo')); +``` + +### Resolver exception tests + +Required coverage: + +- A throwing resolver is reported through `ExceptionHandler`. +- A throwing resolver does not write a new public value. +- A failed public cache write is treated like a refresh failure: the claim is cleared, `lastRefreshedAt` is unchanged, and the failure is reported or rethrown according to caller. +- A throwing timer resolver clears `refreshingAt` and leaves `lastRefreshedAt` unchanged so the next refresh attempt can retry immediately. +- A throwing same-instance fallback resolver clears `refreshingAt` and rethrows to preserve current direct-resolver behavior. +- Claim clearing does not overwrite a newer claim if metadata changed after the failed claim. + +Use a Mockery spy bound in the container instead of a hand-written anonymous class: + +```php +$handler = m::spy(ExceptionHandler::class); +$this->app->instance(ExceptionHandler::class, $handler); + +// trigger failed refresh + +$handler->shouldHaveReceived('report')->with($exception); +``` + +Do not add a brittle stderr-capture test for the no-handler fallback. Cover it with a narrow unit test only if the suite already has a stable stderr-capture helper; otherwise leave that fallback covered by code review. + +### Timer wiring tests + +Add or update listener tests for the timer class. + +Required coverage: + +- For each configured Swoole store, one eviction timer is registered with `eviction_interval`. +- For each configured Swoole store, one interval refresh timer is registered with `interval_refresh_interval`. +- Defaults are `10000` ms for eviction and `1000` ms for interval refresh. +- The eviction callback calls `evictRecords()`. +- The interval callback calls `refreshIntervalCaches()`. + +If `Swoole\Timer::tick()` is hard to fake directly, use the existing repository's pattern for facade/static timer tests. If no pattern exists, keep this as a narrow integration-style test around the listener with a test double introduced through the smallest clean abstraction, such as an injectable timer callback registrar. Do not leave production code with a test-only seam; the abstraction should make the timer listener cleaner. + +### Documentation tests + +No generated docs tests are expected, but update: + +- `src/foundation/config/cache.php` +- `src/boost/docs/cache.md` +- `src/boost/docs/octane.md` + +Review docs manually to ensure they no longer say interval registration refreshes values eagerly or that the `interval()` parameter is minutes. + +## Performance Expectations + +Normal cache operations: + +- `get()` for ordinary misses does not read interval index rows. +- `get()` for ordinary hits is unchanged from the row-concurrency plan. +- `put()`, `add()`, `increment()`, `touch()`, and locks do not interact with interval index rows. + +Interval operations: + +- `interval()` does two tiny locked writes: one metadata row and one index shard row. +- `refreshIntervalCaches()` reads at most 64 index shard rows and then touches only registered intervals. +- Resolver callbacks run outside locks. +- Due checks are per registered interval, not per cache table row. +- The dedicated 1-second timer adds a small fixed read cost per Swoole store and no work to request hot paths. + +CPU load at scale is bounded by the number of registered interval caches, not the number of cache entries or application requests. The design avoids a full Swoole Table scan every second. + +## Self-Review Checklist + +Before implementation starts, verify these points against the codebase one more time: + +- `OnManagerStart` really runs in the manager process and is the right place for both timers. +- `CreateSwooleTable` still creates the state before fork. +- `refreshIntervalCaches()` no longer depends on local `$intervals`. +- Same-instance fallback still works before the first timer tick. +- Cross-instance refresh works with a fresh `SwooleStore` sharing the same state. +- No resolver can run while a row lock or all-row lock is held. +- Resolver exceptions are reported without suppressing future refresh attempts. +- Interval metadata and index control rows cannot be flushed or evicted as user data. +- Index shard row writes fit through the same value-size validation as other Swoole rows, and tests cover a realistic multi-key shard. +- The row-concurrency plan's `flush()` and eviction helpers preserve the `i:` and `x:` control rows used here. +- Timer docs and config comments mention milliseconds for timer options and seconds for `interval()`. diff --git a/docs/plans/2026-07-03-swoole-store-row-concurrency-and-locks.md b/docs/plans/2026-07-03-swoole-store-row-concurrency-and-locks.md new file mode 100644 index 000000000..65de01056 --- /dev/null +++ b/docs/plans/2026-07-03-swoole-store-row-concurrency-and-locks.md @@ -0,0 +1,1403 @@ +# Swoole Store Row Concurrency and Locks + +## Goal + +Make the Swoole cache store correct under cross-worker access while preserving its intended role as the very fast in-process shared-memory cache driver. + +The final code should read as if the store was designed around Swoole Table's actual guarantees from the beginning: + +- Ordinary cache reads stay lock-free on the hot path. +- Single-key read-modify-write operations are protected by shared, pre-fork striped atomics. +- `add()` follows the cache contract: exactly one live writer can win for a key, and expired rows are logically absent. +- `increment()`, `decrement()`, `touch()`, `forget()`, stale cleanup, eviction deletes, and cache locks do not clobber concurrent writes. +- Swoole cache stores support the framework lock API through a real `LockProvider`. +- Every Swoole Table row key is a short deterministic table key, so Swoole's key-size limit cannot truncate or collide long user keys. +- Physical table keys use seeded `xxh128`, with the seed created in pre-fork shared state, to keep hashing fast while preventing offline collision crafting against untrusted logical keys. +- Control rows for locks and interval metadata are clearly separated from user cache entries. +- Production timestamp reads avoid Carbon object construction while preserving `Carbon::setTestNow()` behavior for tests. +- `SwooleTable::set()` keeps its string-size guard without per-write Collection or closure allocation. +- No method calls a public locking method while it already holds a row lock. + +## Research + +Files checked: + +- `src/cache/src/SwooleStore.php` +- `src/cache/src/SwooleTableManager.php` +- `src/cache/src/SwooleTable.php` +- `src/cache/src/CacheManager.php` +- `src/cache/src/CacheServiceProvider.php` +- `src/cache/src/Listeners/CreateSwooleTable.php` +- `src/cache/src/Listeners/CreateTimer.php` +- `src/cache/src/Lock.php` +- `src/cache/src/CacheLock.php` +- `src/cache/src/ArrayLock.php` +- `src/cache/src/RedisLock.php` +- `src/cache/src/FileStore.php` +- `src/cache/src/DatabaseStore.php` +- `src/cache/src/Repository.php` +- `src/contracts/src/Cache/LockProvider.php` +- `src/contracts/src/Cache/CanFlushLocks.php` +- `src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php` +- `src/foundation/config/cache.php` +- `src/boost/docs/cache.md` +- `tests/Cache/CacheSwooleStoreTest.php` +- `tests/Cache/CacheArrayStoreCoroutineIsolationTest.php` +- `tests/Cache/CacheWorkerArrayStoreTest.php` +- `tests/ServerProcess/AbstractProcessTest.php` + +Relevant current behavior: + +```php +public function add(string $key, mixed $value, int $seconds): bool +{ + if ($this->table->exists($key)) { + return false; + } + + return $this->put($key, $value, $seconds); +} +``` + +That is a physical-row check followed by a write. It is not atomic, and it treats expired rows as still present until some later lazy cleanup removes them. + +Current `getRecord()` also reads a row, mutates `last_used_at` and `used_count` in PHP, and writes the whole row back: + +```php +$record = $this->table->get($key); + +if (! $record) { + return false; +} + +$record['last_used_at'] = $this->getCurrentTimestamp(); +$record['used_count'] = ($record['used_count'] ?? 0) + 1; + +$this->table->set($key, $record); +``` + +That full-row write can clobber a concurrent `put()`, `touch()`, or `increment()` because the stale `value` and `expiration` read by `getRecord()` are written back along with the metadata. + +Reverb already has the right primitive for Swoole Table row lifecycle races: shared `Swoole\Atomic` striped locks created before fork and acquired with `cmpset(0, 1)`. Its critical sections only contain Swoole Table operations, so the spin lock stays cheap. + +Swoole 6.2.0 does not expose a PHP constant for the table-key length limit in this environment. An empirical check shows keys at and beyond 64 bytes emit warnings but `set()` still returns `true`; stored row keys are truncated to 63 bytes, so distinct long keys can collide. The final store must never pass raw user keys to Swoole Table. + +PHP's `hash('xxh128', $key, false, ['seed' => $seed])` works in this environment, so the store can seed xxh128 without adding a cryptographic hash to the hot path. + +## Defects + +### `add()` is not atomic + +Two workers can both observe that a key does not exist and then both write the key. Both callers can return `true`, which violates the cache `add()` contract used by dedupe, replay protection, and lock-like workflows. + +### `add()` ignores logical expiration + +`Swoole\Table::exists($key)` checks physical row presence. SwooleStore expires rows lazily, so a key can be logically expired while the physical row is still present. In that state, `get($key)` returns `null`, but `add($key, ...)` returns `false`. + +### `getRecord()` can clobber concurrent writes + +The store currently writes a whole stale row back after every hit. That makes `get()` a hidden writer and creates avoidable data loss windows. + +### `increment()`, `decrement()`, and `touch()` are read-modify-write races + +Each method reads the row, calculates a new value or expiration in PHP, then writes the result. Two workers can lose updates or overwrite each other's changes. + +### `forget()`, stale cleanup, eviction, and `flush()` can race with writers + +Deletes are separately atomic at the Swoole Table operation level, but not coordinated with the store's multi-step logical operations. A stale cleanup or eviction delete can remove a key that another worker just recreated unless the delete rechecks under the key's stripe lock. + +### SwooleStore does not support framework locks + +`RedisStore`, `DatabaseStore`, `FileStore`, and `AbstractArrayStore` implement `LockProvider`. SwooleStore does not. This leaves applications without `Cache::store('swoole')->lock(...)` even though the store is shared across workers. + +### Raw table keys can be truncated by Swoole + +Current SwooleStore passes user cache keys directly into `Swoole\Table`. Distinct long keys can be truncated to the same physical row key. This is a correctness bug independent of the locking work, and this plan rewrites table access enough that the final design should fix it now. + +## Decisions + +### Use a shared state object, not a raw table constructor + +Introduce a cache-specific shared state object owned by `SwooleTableManager`. It contains the `SwooleTable` and pre-fork `Swoole\Atomic` stripes. + +Why: + +- The table and atomics must be created before the Swoole server forks workers. +- Passing a raw `Table` to `SwooleStore` hides the fact that row lifecycle operations require shared locks. +- Reverb already proves the pattern inside this repository. + +Shape: + +```php +namespace Hypervel\Cache; + +use Swoole\Atomic; + +class SwooleTableState +{ + protected const STRIPE_COUNT = 64; + + /** @var list */ + protected array $rowLocks; + + public function __construct( + protected SwooleTable $table, + protected int $hashSeed = 0, + ) { + $this->hashSeed = $hashSeed ?: random_int(1, PHP_INT_MAX); + + $this->rowLocks = array_map( + fn () => new Atomic(0), + range(0, self::STRIPE_COUNT - 1), + ); + } + + public function table(): SwooleTable + { + return $this->table; + } + + public function hashSeed(): int + { + return $this->hashSeed; + } + + /** + * Run the callback while holding the row lock for the given table key. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withRowLock(string $key, callable $callback): mixed + { + $lock = $this->lockFor($key); + $this->acquire($lock); + + try { + return $callback(); + } finally { + $this->release($lock); + } + } + + /** + * Run the callback while holding every row-lock stripe. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withAllRowLocks(callable $callback): mixed + { + $acquired = []; + + foreach ($this->rowLocks as $lock) { + $this->acquire($lock); + $acquired[] = $lock; + } + + try { + return $callback(); + } finally { + while ($lock = array_pop($acquired)) { + $this->release($lock); + } + } + } + + protected function lockFor(string $key): Atomic + { + return $this->rowLocks[crc32($key) % self::STRIPE_COUNT]; + } + + protected function acquire(Atomic $lock): void + { + while (! $lock->cmpset(0, 1)) { + // Intentionally empty: critical sections must stay short and non-yielding. + } + } + + protected function release(Atomic $lock): void + { + $lock->cmpset(1, 0); + } +} +``` + +`STRIPE_COUNT = 64` matches the existing Reverb precedent. It keeps unrelated key contention low without adding configuration or meaningful memory cost. If implementation benchmarks show unrelated write contention under unusually high worker counts, the constant can be increased before merge; it should not be a public config option unless there is a demonstrated need. + +### SwooleTableManager manages states + +`SwooleTableManager` should cache named `SwooleTableState` instances instead of raw tables. + +```php +class SwooleTableManager +{ + /** @var array */ + protected array $states = []; + + public function get(string $name): SwooleTableState + { + return $this->states[$name] ??= $this->resolve($name); + } + + public function createState(int $rows, int $bytes, float $conflictProportion, int $hashSeed = 0): SwooleTableState + { + return new SwooleTableState( + $this->createTable($rows, $bytes, $conflictProportion), + $hashSeed, + ); + } + + public function createTable(int $rows, int $bytes, float $conflictProportion): SwooleTable + { + // Existing column-definition body stays here. + } + + protected function resolve(string $name): SwooleTableState + { + $config = $this->getConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Swoole table [{$name}] is not defined."); + } + + return $this->createState( + $config['rows'] ?? 1024, + $config['bytes'] ?? 10240, + $config['conflict_proportion'] ?? 0.2, + ); + } +} +``` + +The optional `createState(..., $hashSeed)` parameter exists for deterministic unit tests only. `resolve()` should not read a seed from application config; production should use the generated pre-fork seed. + +`CreateSwooleTable` keeps calling the manager before fork. `CacheManager::createSwooleDriver()` retrieves the state and passes it to `SwooleStore`. + +```php +$state = $this->app->make(SwooleTableManager::class)->get($config['table']); + +$store = new SwooleStore( + $state, + $config['memory_limit_buffer'] ?? 0.05, + $config['eviction_policy'] ?? SwooleStore::EVICTION_POLICY_LRU, + $config['eviction_proportion'] ?? 0.05, +); +``` + +### Map every logical key to a bounded seeded table key + +Replace raw Swoole Table keys with short deterministic table keys. User cache entries and control rows all use separate physical prefixes plus a 128-bit hash or a small shard number. + +```php +protected const USER_PREFIX = 'u:'; +protected const INTERVAL_PREFIX = 'i:'; +protected const INTERVAL_INDEX_PREFIX = 'x:'; +protected const LOCK_PREFIX = 'l:'; + +protected function userKey(string $key): string +{ + return $this->hashedTableKey(self::USER_PREFIX, $key); +} + +protected function intervalKey(string $key): string +{ + return $this->hashedTableKey(self::INTERVAL_PREFIX, $key); +} + +protected function lockKey(string $name): string +{ + return $this->hashedTableKey(self::LOCK_PREFIX, $name); +} + +protected function hashedTableKey(string $prefix, string $key): string +{ + return $prefix . hash('xxh128', $key, false, [ + 'seed' => $this->state->hashSeed(), + ]); +} + +protected function isUserKey(string $tableKey): bool +{ + return str_starts_with($tableKey, self::USER_PREFIX); +} + +protected function isControlKey(string $tableKey): bool +{ + return str_starts_with($tableKey, self::INTERVAL_PREFIX) + || str_starts_with($tableKey, self::INTERVAL_INDEX_PREFIX) + || $this->isLockKey($tableKey); +} + +protected function isLockKey(string $key): bool +{ + return str_starts_with($key, self::LOCK_PREFIX); +} +``` + +Why: + +- Swoole Table truncates long physical keys, so raw user keys are not safe. +- `flush()` currently skips every user key beginning with `interval-`; that is accidental control-key leakage. +- Lock rows must survive normal `flush()` just like other stores with separate lock stores. +- Control-key detection is explicit. Unknown raw rows are treated as user/cache data so stale legacy rows cannot be preserved forever. +- Hashed user and control keys keep every physical key comfortably below Swoole's observed 63-byte storage width. +- Seeded xxh128 keeps per-operation hashing fast while preventing an attacker who controls logical cache keys from precomputing chosen collisions offline. The seed lives on `SwooleTableState`, is generated before fork, and is inherited consistently by workers that share the table state. + +Hash alternatives considered: + +- Unseeded `xxh128`: fastest, but non-cryptographic and deterministic across applications. Reject it because Swoole cache keys can be security-relevant. +- Truncated `sha256`: collision-resistant, but slower on every cache operation. Keep it as the fallback if seeded xxh128 becomes unavailable in supported PHP versions. +- Seeded `xxh128`: chosen design. It avoids Swoole truncation, keeps the hot path fast, and blocks practical offline collision crafting because the seed is not exposed to clients. + +Decision: use seeded `xxh128` as the physical-key hash. It gives the smallest performance hit while addressing adversarial key-collision concerns much better than unseeded `xxh128`. + +This plan must include the current interval metadata key migration even though the shared interval index and refresh redesign are in the interval-cache plan. Otherwise plan 1 alone would leave `interval()` writing `interval-foo` while `flush()` only preserves new control keys. Plan 1 therefore changes the existing local-interval methods to use `intervalKey($key)` and the local interval set; plan 2 builds the shared index and manager refresh flow on top. + +### Keep `get()` lock-free for live hits + +Do not lock normal live reads. Reads are the dominant Swoole cache path and should remain as close to raw table speed as possible. + +The read path should: + +1. Read the row. +2. If the row is live, return the unserialized value. +3. Record hit metadata only when the configured eviction policy needs it. +4. If the row is expired, acquire the row lock, recheck, and delete only if it is still expired. + +```php +public function get(string $key): mixed +{ + $tableKey = $this->userKey($key); + $record = $this->rawGet($tableKey); + + if (! $this->recordIsFalseOrExpired($record)) { + $this->recordHit($tableKey); + + return unserialize($record['value']); + } + + if ($this->hasLocalInterval($key) && ! is_null($interval = $this->getInterval($key))) { + return $interval['resolver'](); + } + + if ($record !== false) { + $this->forgetExpiredRecord($tableKey); + } + + return null; +} + +protected function recordHit(string $tableKey): void +{ + if ($this->evictionPolicy === static::EVICTION_POLICY_LRU) { + $this->table->set($tableKey, ['last_used_at' => $this->getCurrentTimestamp()]); + + return; + } + + if ($this->evictionPolicy === static::EVICTION_POLICY_LFU) { + $this->table->incr($tableKey, 'used_count', 1); + } +} +``` + +Why: + +- `ttl` and `noeviction` do not use hit metadata, so those reads become cheaper. +- `lru` only needs `last_used_at`; a partial set must not rewrite `value` or `expiration`. +- `lfu` should use Swoole Table's numeric `incr()` for the counter. +- LRU/LFU metadata is intentionally approximate under races; cache eviction metadata does not need to be as strong as cache value correctness. +- If a metadata write races with a delete, Swoole may leave a shell row with default `value` / `expiration` columns. That shell row is expired and self-cleaning on the next read or stale cleanup. It is acceptable because the alternative is locking every read, which is the performance cost this design avoids. +- Local interval fallback is checked before deleting an expired public value, preserving today's same-instance interval behavior. The interval-cache plan will replace direct fallback resolution with the shared claim/write path. + +### Use `microtime()` for production timestamps + +`getCurrentTimestamp()` is on the hot path for reads, writes, expiration checks, locks, and interval claims. Building a Carbon object for every production timestamp adds more work than the store needs. + +Keep Carbon only when tests have frozen time: + +```php +protected function getCurrentTimestamp(): float +{ + return Carbon::hasTestNow() + ? Carbon::now()->getPreciseTimestamp(6) / 1000000 + : microtime(true); +} +``` + +Why: + +- `microtime(true)` returns the same float seconds shape the store already stores in Swoole Table rows. +- Production cache operations avoid constructing a Carbon object on every timestamp read. +- `Carbon::setTestNow()` continues to control expiration, LRU metadata, lock lifetime, and interval timing tests. + +### Keep the Swoole table write guard allocation-free + +`SwooleTable::set()` must keep checking string column sizes before writing, but it should not allocate a Collection and closure for every table write. + +Use a direct loop: + +```php +public function set(string $key, array $values): bool +{ + foreach ($values as $column => $value) { + if (! isset($this->columns[$column])) { + continue; + } + + [$type, $size] = $this->columns[$column]; + + if ($type !== Table::TYPE_STRING) { + continue; + } + + $length = strlen($value); + + if ($length > $size) { + throw new ValueTooLargeForColumnException(sprintf( + 'Value [%s...] is too large for [%s] column. Should be less than %d characters but got %d characters.', + substr($value, 0, 20), + $column, + $size, + $length + )); + } + } + + return parent::set($key, $values); +} +``` + +Why: + +- Every cache write goes through this guard. +- The guard behavior and exception stay the same. +- Removing the Collection and closure cuts avoidable allocation from `put()`, `add()`, `increment()`, `touch()`, lock writes, interval writes, and LRU metadata writes. + +### Public mutating methods lock; raw helpers do not + +Public methods that change a single key acquire that key's row lock. Internal helpers assume the caller has already chosen the right lock boundary. + +Core helper shape: + +```php +protected function rawGet(string $key): array|false +{ + return $this->table->get($key); +} + +protected function rawPutSerialized(string $key, string $serialized, float $expiration): bool +{ + return $this->table->set($key, [ + 'value' => $serialized, + 'expiration' => $expiration, + ]); +} + +protected function rawForget(string $key): bool +{ + return $this->table->del($key); +} + +protected function expiration(int $seconds): float +{ + return $this->getCurrentTimestamp() + $seconds; +} +``` + +`put()`: + +```php +public function put(string $key, mixed $value, int $seconds): bool +{ + $tableKey = $this->userKey($key); + $serialized = serialize($value); + $expiration = $this->expiration($seconds); + + $result = $this->state->withRowLock( + $tableKey, + fn () => $this->rawPutSerialized($tableKey, $serialized, $expiration), + ); + + $this->evictRecordsIfNeeded(); + + return $result; +} +``` + +`add()`: + +```php +public function add(string $key, mixed $value, int $seconds): bool +{ + $tableKey = $this->userKey($key); + $serialized = serialize($value); + $expiration = $this->expiration($seconds); + + return $this->state->withRowLock($tableKey, function () use ($tableKey, $serialized, $expiration) { + $record = $this->rawGet($tableKey); + + if (! $this->recordIsFalseOrExpired($record)) { + return false; + } + + return $this->rawPutSerialized($tableKey, $serialized, $expiration); + }); +} +``` + +`increment()` and `decrement()`: + +```php +public function increment(string $key, int $value = 1): int +{ + $tableKey = $this->userKey($key); + + return $this->state->withRowLock($tableKey, function () use ($tableKey, $value) { + $record = $this->rawGet($tableKey); + + if ($this->recordIsFalseOrExpired($record)) { + $this->rawPutSerialized($tableKey, serialize($value), $this->expiration(static::ONE_YEAR)); + + return $value; + } + + $incremented = (int) (unserialize($record['value']) + $value); + + $this->rawPutSerialized($tableKey, serialize($incremented), $record['expiration']); + + return $incremented; + }); +} + +public function decrement(string $key, int $value = 1): int +{ + return $this->increment($key, $value * -1); +} +``` + +`touch()`: + +```php +public function touch(string $key, int $seconds): bool +{ + $tableKey = $this->userKey($key); + + return $this->state->withRowLock($tableKey, function () use ($tableKey, $seconds) { + $record = $this->rawGet($tableKey); + + if ($this->recordIsFalseOrExpired($record)) { + if ($record !== false) { + $this->rawForget($tableKey); + } + + return false; + } + + return $this->table->set($tableKey, [ + 'expiration' => $this->expiration($seconds), + ]); + }); +} +``` + +`forget()`: + +```php +public function forget(string $key): bool +{ + $tableKey = $this->userKey($key); + + return $this->state->withRowLock( + $tableKey, + fn () => $this->rawForget($tableKey), + ); +} +``` + +`forever()` stays a thin delegate to public `put()`: + +```php +public function forever(string $key, mixed $value): bool +{ + return $this->put($key, $value, static::ONE_YEAR); +} +``` + +Do not add a separate lock in `forever()`. `put()` already locks the user table key. + +### Migrate the existing local interval rows in this plan + +The shared interval index and manager refresh redesign stay in the interval-cache plan. The basic interval metadata key migration cannot wait, because `flush()` will switch to preserving control rows by physical key prefix in this plan. + +Plan 1 should update today's local interval implementation to use `intervalKey($key)` and a local set: + +```php +/** @var array */ +protected array $intervals = []; + +protected function hasLocalInterval(string $key): bool +{ + return isset($this->intervals[$key]); +} + +protected function getInterval(string $key): ?array +{ + $record = $this->rawGet($this->intervalKey($key)); + + return $this->recordIsFalseOrExpired($record) + ? null + : unserialize($record['value']); +} + +public function interval(string $key, Closure $resolver, int $seconds): void +{ + $intervalKey = $this->intervalKey($key); + + $this->state->withRowLock($intervalKey, function () use ($intervalKey, $resolver, $seconds) { + if (! $this->recordIsFalseOrExpired($this->rawGet($intervalKey))) { + return; + } + + $this->rawPutSerialized($intervalKey, serialize([ + 'resolver' => serialize(new SerializableClosure($resolver)), + 'lastRefreshedAt' => null, + 'refreshInterval' => $seconds, + ]), $this->expiration(static::ONE_YEAR)); + }); + + $this->intervals[$key] = true; +} + +public function refreshIntervalCaches(): void +{ + foreach (array_keys($this->intervals) as $key) { + $interval = $this->getInterval($key); + + if ($interval === null || ! $this->intervalShouldBeRefreshed($interval)) { + continue; + } + + $intervalKey = $this->intervalKey($key); + + $this->state->withRowLock($intervalKey, function () use ($intervalKey, $interval) { + $this->rawPutSerialized($intervalKey, serialize(array_merge( + $interval, + ['lastRefreshedAt' => Carbon::now()->getTimestamp()], + )), $this->expiration(static::ONE_YEAR)); + }); + + /** @var SerializableClosure $resolver */ + $resolver = unserialize($interval['resolver']); + + $this->forever($key, $resolver()); + } +} +``` + +This keeps existing same-instance interval behavior and `testIntervalsAreNotFlushed` / `testIntervalsCanBeRefreshed` passing while avoiding the old `interval-` user-key collision. Plan 2 will replace this metadata shape and local-only refresh loop with the richer shared-index metadata shape. + +### Do not run eviction while holding a row lock or on every write + +`put()` should release the row lock before considering eviction. Eviction scans arbitrary rows and deletes candidates. It must not be nested inside a per-key lock. + +Writes should not run a full-table stale scan unconditionally. The current store calls `evictRecords()` after every `put()`, which means every write scans the whole table through `flushStaleRecords()`. After candidate deletes become locked, inheriting that behavior would make write cost scale with table size and expired-row count. + +Use a cheap memory-pressure gate on the write path: + +```php +protected function evictRecordsIfNeeded(): void +{ + if (! $this->memoryLimitIsReached()) { + return; + } + + $this->evictRecords(); +} +``` + +Keep `evictRecords()` as the full maintenance operation used by the timer and by the pressure gate: + +```php +public function evictRecords(): void +{ + $this->flushStaleRecords(); + + while ($this->memoryLimitIsReached()) { + $this->removeRecordsByEvictionPolicy(); + } +} +``` + +Why: + +- The row spinlock is non-reentrant. +- Eviction may need to acquire the lock for the same stripe or a different stripe. +- Holding a row lock while attempting to acquire all locks or another row lock creates deadlock risk. +- The common write path should stay O(1) unless the table is under memory pressure. +- Expired rows that are not on a read/write path can be removed by the existing maintenance timer. + +### Delete stale and evicted records under candidate row locks + +`flushStaleRecords()` and eviction candidate removal should scan without locks, collect keys, then lock and recheck each candidate before deleting. + +```php +protected function flushStaleRecords(): void +{ + $now = $this->getCurrentTimestamp(); + $tableKeys = []; + $lockKeys = []; + + foreach ($this->table as $tableKey => $row) { + if ($this->isLockKey($tableKey)) { + if ($this->rawLockPayloadIsExpired($row)) { + $lockKeys[] = $tableKey; + } + + continue; + } + + if ($this->isControlKey($tableKey)) { + continue; + } + + if ($row['expiration'] < $now) { + $tableKeys[] = $tableKey; + } + } + + foreach ($tableKeys as $tableKey) { + $this->forgetExpiredRecord($tableKey); + } + + foreach ($lockKeys as $tableKey) { + $this->forgetExpiredLockRecord($tableKey); + } +} + +protected function forgetExpiredRecord(string $tableKey): void +{ + $this->state->withRowLock($tableKey, function () use ($tableKey) { + $record = $this->rawGet($tableKey); + + if ($this->recordIsFalseOrExpired($record)) { + $this->rawForget($tableKey); + } + }); +} +``` + +Eviction-by-policy should also call a raw candidate delete that rechecks the selected row under its row lock: + +```php +protected function forgetEvictionCandidate(string $tableKey): void +{ + $this->state->withRowLock($tableKey, function () use ($tableKey) { + if (! $this->isControlKey($tableKey)) { + $this->rawForget($tableKey); + } + }); +} +``` + +Why: + +- Candidate selection is advisory because eviction metadata is approximate. +- The delete itself must not race a concurrent recreate on the same key. +- Internal rows for locks and interval metadata must not be evicted by normal cache pressure. +- Stale cleanup may prune expired lock rows, but only after decoding the lock payload and rechecking under that lock key's row lock. Live locks must never be removed by stale cleanup. +- Stale cleanup no longer runs on every successful write. It runs through `evictRecords()` from the timer and from the write path only when the memory-pressure gate is tripped. + +### `flush()` is an all-stripes operation + +Normal cache flush should delete user rows only. It should preserve internal lock and interval rows. + +```php +public function flush(): bool +{ + return $this->state->withAllRowLocks(function () { + foreach ($this->table as $tableKey => $record) { + if ($this->isControlKey($tableKey)) { + continue; + } + + $this->rawForget($tableKey); + } + + return true; + }); +} +``` + +`flush()` does not lock readers. A concurrent lock-free `get()` can read a row just before it is flushed. That is acceptable and matches cache-store expectations: `flush()` is a writer barrier, not a global read memory barrier. + +### Implement `LockProvider` with a Swoole-specific lock + +`SwooleStore` should implement `LockProvider` and `CanFlushLocks`. + +```php +use Hypervel\Contracts\Cache\CanFlushLocks; +use Hypervel\Contracts\Cache\LockProvider; + +class SwooleStore implements CanFlushLocks, LockProvider, Store +{ + public function lock(string $name, int $seconds = 0, ?string $owner = null): SwooleLock + { + return new SwooleLock($this, $name, $seconds, $owner); + } + + public function restoreLock(string $name, string $owner): SwooleLock + { + return $this->lock($name, 0, $owner); + } + + public function hasSeparateLockStore(): bool + { + return true; + } +} +``` + +Do not use `CacheLock` for Swoole. `CacheLock::release()` does a separate owner read and delete; Swoole needs release to be protected by the same row lock as acquire. + +Store-facing lock helpers: + +```php +public function acquireLock(string $name, string $owner, int $seconds): bool +{ + $key = $this->lockKey($name); + $expiresAt = $seconds > 0 ? $this->expiration($seconds) : null; + + return $this->state->withRowLock($key, function () use ($key, $owner, $expiresAt) { + $lock = $this->rawLockRecord($key); + + if ($lock !== null && ! $this->lockIsExpired($lock)) { + return false; + } + + return $this->rawPutSerialized($key, serialize([ + 'owner' => $owner, + 'expiresAt' => $expiresAt, + ]), $this->expiration(static::ONE_YEAR)); + }); +} + +public function releaseLock(string $name, string $owner): bool +{ + $key = $this->lockKey($name); + + return $this->state->withRowLock($key, function () use ($key, $owner) { + $lock = $this->rawLockRecord($key); + + if ($lock === null || $this->lockIsExpired($lock)) { + if ($lock !== null) { + $this->rawForget($key); + } + + return false; + } + + if ($lock['owner'] !== $owner) { + return false; + } + + return $this->rawForget($key); + }); +} + +public function getLockOwner(string $name): ?string +{ + $lock = $this->rawLockRecord($this->lockKey($name)); + + return $lock !== null && ! $this->lockIsExpired($lock) + ? $lock['owner'] + : null; +} + +public function refreshLock(string $name, string $owner, int $seconds): bool +{ + $key = $this->lockKey($name); + + return $this->state->withRowLock($key, function () use ($key, $owner, $seconds) { + $lock = $this->rawLockRecord($key); + + if ($lock === null || $this->lockIsExpired($lock) || $lock['owner'] !== $owner) { + return false; + } + + $lock['expiresAt'] = $this->expiration($seconds); + + return $this->rawPutSerialized($key, serialize($lock), $this->expiration(static::ONE_YEAR)); + }); +} + +public function getLockRemainingLifetime(string $name): ?float +{ + $lock = $this->rawLockRecord($this->lockKey($name)); + + if ($lock === null || $lock['expiresAt'] === null || $this->lockIsExpired($lock)) { + return null; + } + + return max(0.0, $lock['expiresAt'] - $this->getCurrentTimestamp()); +} + +public function forceReleaseLock(string $name): void +{ + $key = $this->lockKey($name); + + $this->state->withRowLock($key, fn () => $this->rawForget($key)); +} + +protected function rawLockRecord(string $key): ?array +{ + $record = $this->rawGet($key); + + return $record === false ? null : unserialize($record['value']); +} + +protected function lockIsExpired(array $lock): bool +{ + return $lock['expiresAt'] !== null && $lock['expiresAt'] <= $this->getCurrentTimestamp(); +} + +protected function rawLockPayloadIsExpired(array $row): bool +{ + $lock = unserialize($row['value']); + + return $this->lockIsExpired($lock); +} + +protected function forgetExpiredLockRecord(string $key): void +{ + $this->state->withRowLock($key, function () use ($key) { + $lock = $this->rawLockRecord($key); + + if ($lock !== null && $this->lockIsExpired($lock)) { + $this->rawForget($key); + } + }); +} +``` + +`SwooleLock`: + +```php +namespace Hypervel\Cache; + +use Hypervel\Contracts\Cache\RefreshableLock; +use InvalidArgumentException; + +class SwooleLock extends Lock implements RefreshableLock +{ + public function __construct( + protected SwooleStore $store, + string $name, + int $seconds, + ?string $owner = null, + ) { + parent::__construct($name, $seconds, $owner); + } + + public function acquire(): bool + { + return $this->store->acquireLock($this->name, $this->owner, $this->seconds); + } + + public function release(): bool + { + return $this->store->releaseLock($this->name, $this->owner); + } + + public function forceRelease(): void + { + $this->store->forceReleaseLock($this->name); + } + + protected function getCurrentOwner(): ?string + { + return $this->store->getLockOwner($this->name); + } + + public function refresh(?int $seconds = null): bool + { + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + return $this->store->refreshLock($this->name, $this->owner, $seconds); + } + + public function getRemainingLifetime(): ?float + { + return $this->store->getLockRemainingLifetime($this->name); + } +} +``` + +Also add: + +```php +public function flushLocks(): bool +{ + if (! $this->hasSeparateLockStore()) { + throw new RuntimeException('Flushing locks is only supported when the lock store is separate from the cache store.'); + } + + return $this->state->withAllRowLocks(function () { + foreach ($this->table as $key => $record) { + if ($this->isLockKey($key)) { + $this->rawForget($key); + } + } + + return true; + }); +} +``` + +Why: + +- Locks should survive normal cache flush. +- `cache:clear --locks` should work for Swoole just like other lock-capable stores. +- Expired lock takeover is handled by the same logical-expiration check used by cache rows. + +### Critical-section invariants + +These rules must hold in the final implementation: + +- Public mutating methods may acquire row locks. +- Raw helper methods never acquire row locks. +- Methods called from inside `withRowLock()` must not call public mutating methods. +- A row lock must never be held while calling `evictRecords()`, `flush()`, `flushLocks()`, or user code. +- `withAllRowLocks()` must only be entered when no row lock is already held. +- Eviction and stale cleanup may lock one candidate key at a time. + +These invariants are more important than minimizing line churn. If a helper's name does not make its lock expectation obvious, rename it. + +## Implementation Steps + +1. Add `src/cache/src/SwooleTableState.php`. + + Why: centralizes the shared table and shared atomics. + + How: copy the Reverb spin-lock shape, expose `table()`, `withRowLock()`, and `withAllRowLocks()`. Include `declare(strict_types=1)`, normal Laravel-style method docblocks, and generic PHPDoc on callback helpers. + +2. Refactor `SwooleTableManager` to return `SwooleTableState`. + + Why: every resolved Swoole cache table needs its matching shared locks. + + How: replace `$tables` with `$states`, add `createState()`, narrow `createTable()`'s return type to `SwooleTable`, keep table column creation inside the manager, and update the missing-config exception unchanged. + +3. Update `CacheManager::createSwooleDriver()`. + + Why: `SwooleStore` needs the state object, not just the table. + + How: retrieve `$state = $manager->get($config['table'])` and pass it to the new constructor. + +4. Update `SwooleStore` constructor and properties. + + Why: the store should not own synchronization; it should use the shared state. + + How: accept `SwooleTableState $state`, set `$this->table = $state->table()`, and keep current config properties. + +5. Replace `getRecord()` with raw and hit-metadata helpers. + + Why: remove the full-row clobber path. + + How: add `rawGet()`, `rawPutSerialized()`, `rawForget()`, `recordHit()`, `forgetExpiredRecord()`. Delete the old comment and method once all call sites are gone. + +6. Rewrite `get()` around lock-free live reads. + + Why: fixes clobbering while keeping read performance. + + How: raw read, return live value, best-effort metadata update for LRU/LFU only, locked recheck-delete for expired rows, then local interval fallback. + +7. Change `getCurrentTimestamp()` to use `microtime(true)` unless Carbon has frozen test time. + + Why: timestamp reads are on the store hot path, and production code does not need Carbon object construction. + + How: branch on `Carbon::hasTestNow()`, keep the existing precise Carbon path for tests, and use `microtime(true)` otherwise. + +8. Change `SwooleTable::set()` to check string column sizes with a plain loop. + + Why: every write goes through this wrapper, so the old Collection and closure allocation added avoidable write-path cost. + + How: remove `collect($values)->each($this->ensureColumnsSize())`, inline the guard as a `foreach`, preserve the `ValueTooLargeForColumnException`, and delete the now-unused helper/import. + +9. Rewrite single-key mutators around row locks. + + Why: fixes check-then-write and read-modify-write races. + + How: update `put()`, `add()`, `increment()`, `decrement()`, `touch()`, and `forget()` so public mutators lock their physical user table key and internal work uses raw helpers. Keep `forever()` as a thin delegate to public `put()`. + +10. Rewrite cleanup and eviction deletes. + + Why: scans can be lock-free, deletes cannot. + + How: scans collect candidate user keys, collect expired lock keys separately, skip non-lock internal keys, then recheck/delete under each candidate row lock. + +11. Migrate current local interval methods enough for plan 1 to stand alone. + + Why: `flush()` and all-key hashing change the physical key namespace in this plan, so today's interval rows cannot keep using `interval-{$key}` until plan 2. + + How: convert `$intervals` to an associative set, update `getInterval()` / `interval()` / `refreshIntervalCaches()` to use `intervalKey($key)`, iterate local intervals with `array_keys($this->intervals)`, and use logical expiration checks for interval metadata dedupe. + +12. Gate write-path eviction. + + Why: adding locks to candidate deletes makes the inherited full-table scan after every write too expensive at scale. + + How: replace `put()`'s unconditional `evictRecords()` call with `evictRecordsIfNeeded()`, which checks `memoryLimitIsReached()` first. Keep `evictRecords()` as the full stale-cleanup plus eviction operation for the timer and pressure path. + +13. Rewrite `flush()` as an all-stripes user-row delete. + + Why: `flush()` must not interleave with writers and must not delete internal lock/interval rows. + + How: call `withAllRowLocks()`, iterate table, delete only `isUserKey()` rows with raw deletes. + +14. Add `SwooleLock` and `LockProvider` support. + + Why: Swoole is a shared cache store and should support the framework's shared lock API. + + How: add lock helpers on `SwooleStore`, implement `SwooleLock`, implement `CanFlushLocks`, add `flushLocks()`. + +15. Update docs where Swoole store capabilities are listed. + + Why: docs should not lag the final codebase. + + How: update Swoole cache docs to mention `lock()` support and `cache:clear --locks` if lock support is documented for other stores. + +14. Remove stale comments and old method names. + + Why: the final code should read as designed this way from the start. + + How: delete comments about "write used info" and avoid leaving `getRecord()` as a compatibility alias. + +## Testing Plan + +Run each touched test file immediately after updating it, then run the relevant package suite. + +Commands: + +```bash +vendor/bin/phpunit tests/Cache/CacheSwooleStoreTest.php +vendor/bin/phpunit tests/Cache/CacheSwooleStoreConcurrencyTest.php +vendor/bin/phpunit tests/Cache +composer analyse +``` + +### Unit and sequential regression tests + +Add or update `tests/Cache/CacheSwooleStoreTest.php`. + +Because the store no longer uses raw logical keys as Swoole Table keys, migrate every direct table seed/assertion in the existing test file. Add helpers instead of scattering reflection: + +```php +use ReflectionMethod; + +private function logicalTableKey(SwooleStore $store, string $key): string +{ + $method = new ReflectionMethod($store, 'userKey'); + $method->setAccessible(true); + + return $method->invoke($store, $key); +} + +private function setLogicalRow(SwooleTableState $state, SwooleStore $store, string $key, mixed $value, float $expiration): void +{ + $state->table()->set($this->logicalTableKey($store, $key), [ + 'value' => serialize($value), + 'expiration' => $expiration, + ]); +} + +private function getLogicalRow(SwooleTableState $state, SwooleStore $store, string $key): array|false +{ + return $state->table()->get($this->logicalTableKey($store, $key)); +} +``` + +The important rule is that tests seed and inspect the same physical table key the store uses. + +Existing tests that must be migrated away from raw `$table->set('foo', ...)` / `$table->get('foo')` include: + +- `testCanRetrieveItemsFromStore` +- `testExpiredItemsReturnNull` +- `testManyMethodCanReturnManyValues` +- `testCanRemoveExpiredRecordFromTable` +- `testFlushStaleRecords` +- Any new LRU/LFU metadata assertions that inspect raw table rows + +Rewrite the old `testCanRetrieveItemsFromStore` metadata assertions. The existing helper creates a TTL-policy store, and TTL reads should no longer update `last_used_at` or `used_count`. Replace the old "every get bumps metadata" assertion with explicit policy-aware tests listed below. + +Required coverage: + +- `add()` returns `true` and overwrites an expired physical row. +- `add()` returns `false` and preserves the existing value for a live row. +- `add()` does not update LRU/LFU hit metadata. +- `get()` under `ttl` does not update `last_used_at` or `used_count`. +- `get()` under `noeviction` does not update `last_used_at` or `used_count`. +- `get()` under `lru` updates only `last_used_at` and preserves `value`, `expiration`, and `used_count`. +- `get()` under `lfu` increments only `used_count` and preserves `value`, `expiration`, and `last_used_at`. +- Expiration behavior remains controllable with `Carbon::setTestNow()` after the production timestamp path switches to `microtime(true)`. +- `SwooleTable::set()` still throws `ValueTooLargeForColumnException` when a string column value exceeds its configured size. +- Expired `get()` deletes the row after a locked recheck. +- `touch()` preserves the cached value while changing only expiration. +- `touch()` deletes an expired physical row and returns `false`. +- `increment()` preserves the original expiration. +- `decrement()` uses the same locked path as `increment()`. +- `flush()` deletes user keys but preserves internal interval rows and lock rows. +- `flushLocks()` deletes lock rows but preserves user keys and interval rows. +- `evictRecords()` never deletes control rows. +- `put()` does not scan the full table when the memory limit is not reached. +- `put()` runs `evictRecords()` when the memory limit is reached. +- `evictRecords()` still flushes stale records when called directly by the maintenance timer path. +- `forever()`, `putMany()`, `many()`, and `forget()` still behave normally. +- Distinct long logical cache keys that share the same first 63 bytes do not collide, because both are hashed through `userKey()` before reaching Swoole Table. +- Internal lock and interval table keys are shorter than Swoole's observed 63-byte storage width. +- Seeded xxh128 changes the physical table key when the state seed changes, and two stores sharing one state compute the same physical table key. +- `SwooleStore` implements `LockProvider` and `CanFlushLocks`. +- `tests/Cache/FunnelUnsupportedStoresTest.php` no longer asserts Swoole is unsupported; keep `StackStore` and `SessionStore` as unsupported. +- Add positive Swoole funnel coverage proving `Repository::funnel()` works with Swoole now that it is a `LockProvider`. +- `lock()->acquire()` succeeds once and fails for a second owner while live. +- Expired locks can be acquired by a new owner. +- `release()` only releases for the owner. +- `forceRelease()` releases regardless of owner. +- `restoreLock()` uses the supplied owner. +- `refresh()` extends a live owned lock, returns `true` as a no-op for permanent locks when no explicit TTL is provided, and rejects non-positive explicit TTLs. +- `getRemainingLifetime()` returns seconds for expiring locks and `null` for missing, expired, or permanent locks. + +Use a helper that creates a full state, not a bare table: + +```php +private function createState(): SwooleTableState +{ + return (new SwooleTableManager(m::mock(Container::class))) + ->createState(128, 10240, 0.2); +} + +private function createStore(?SwooleTableState $state = null, string $policy = SwooleStore::EVICTION_POLICY_TTL): SwooleStore +{ + return new SwooleStore($state ?? $this->createState(), 0.05, $policy, 0.05); +} +``` + +### Process-level concurrency regression tests + +Add `tests/Cache/CacheSwooleStoreConcurrencyTest.php`. + +Set: + +```php +protected bool $runTestsInCoroutine = false; +``` + +Why: existing process tests opt out because Swoole process creation is not reliable inside the coroutine test wrapper under parallel test workers. + +Use `Swoole\Process` children sharing a pre-created `SwooleTableState`. Each child should start from a synchronization barrier so operations overlap. + +Required coverage: + +- Many processes call `add('same-key', owner, 60)` at once; exactly one process reports success and the final value is that winner. +- Many processes call `add()` for the same expired physical row; exactly one process reports success. +- Many processes call `increment('counter')` repeatedly; the final value equals the total increments. +- Many processes try `lock('same-lock', 60)->acquire()` at once; exactly one process reports success. + +Sketch: + +```php +public function testConcurrentAddHasExactlyOneWinner(): void +{ + $state = $this->createState(); + $ready = new Atomic(0); + $start = new Atomic(0); + + $processes = []; + + for ($i = 0; $i < 16; ++$i) { + $processes[] = new Process(function (Process $process) use ($state, $ready, $start, $i) { + $store = $this->createStore($state); + + $ready->add(1); + + while ($start->get() === 0) { + usleep(100); + } + + $process->write(serialize([ + 'id' => $i, + 'won' => $store->add('key', $i, 60), + ])); + }, false, SOCK_STREAM); + } + + foreach ($processes as $process) { + $process->start(); + } + + while ($ready->get() < count($processes)) { + usleep(100); + } + + $start->set(1); + + $results = []; + + foreach ($processes as $process) { + $results[] = unserialize($process->read()); + } + + while (Process::wait(false)) { + // Reap all children after all pipes have been read. + } + + $this->assertCount(1, array_filter($results, fn ($result) => $result['won'])); +} +``` + +The final implementation should factor the process orchestration into helpers so the test file stays readable. Do not call `Process::wait()` inside the same loop that reads a specific process pipe; `Process::wait()` can reap any child and make result-to-child association flaky. + +## Performance Expectations + +This design keeps the fastest path fast: + +- Live `get()` for `ttl` and `noeviction` becomes one table `get()` plus unserialize, with no metadata write. +- Live `get()` for `lru` performs one table `get()` plus one partial metadata `set()`. +- Live `get()` for `lfu` performs one table `get()` plus one numeric `incr()`. +- `add()`, `put()`, `increment()`, `decrement()`, `touch()`, `forget()`, lock acquire/release, and candidate deletes pay one uncontended shared-memory CAS acquire/release around a tiny critical section. +- Hot-key writes serialize by design. That is the correctness cost of a single shared key without a native Swoole Table compare-and-swap or set-if-absent primitive. +- Best-effort LRU/LFU metadata writes can recreate an expired shell row if they race with a delete. Under read-only load, that shell may remain until a later timer-driven `evictRecords()` pass. That is an accepted property of keeping reads lock-free. + +The design intentionally avoids: + +- Locking normal reads. +- Holding a lock while running user code. +- Holding a lock while scanning the table. +- A global lock for ordinary single-key writes. +- Using `CacheLock`, because it cannot release atomically on Swoole. + +## Self-Review Checklist + +Before implementation starts, verify these points against the codebase one more time: + +- `SwooleTableManager` is resolved before fork through `CreateSwooleTable`. +- The manager instance is shared enough for the pre-fork state to be inherited by workers. +- `CacheManager::createSwooleDriver()` has no other raw-table assumptions. +- Every old `getRecord()` call is either removed or intentionally replaced. +- No internal method calls public `put()`, `forget()`, `forever()`, `touch()`, or `increment()` while a row lock is held. +- `flush()` and `flushLocks()` do not call public `forget()`. +- Eviction skips control rows, and stale cleanup only removes lock control rows when their lock payload is expired. +- Interval-plan control keys use the same short physical key helpers. +- Tests can inspect the raw table through `SwooleTableState::table()`; raw row assertions should use table iteration, reflection on key helpers, or store behavior rather than raw logical user keys. +- All comments describe current invariants; no comments remain from the old full-row metadata update design. diff --git a/docs/plans/2026-07-04-swoole-store-review-follow-up-hardening.md b/docs/plans/2026-07-04-swoole-store-review-follow-up-hardening.md new file mode 100644 index 000000000..086394252 --- /dev/null +++ b/docs/plans/2026-07-04-swoole-store-review-follow-up-hardening.md @@ -0,0 +1,1128 @@ +# Swoole Store Review Follow-Up Hardening + +## Goal + +Harden the Swoole cache store changes from PR #414 after reviewer feedback, keeping the same overall design and preserving the store's intended performance profile. + +The final code should read as if it was designed this way from the start: + +- Swoole cache timers run in a worker process that has coroutine/runtime access, not in the manager process. +- Periodic interval refresh and periodic stale cleanup / eviction are registered together on a single elected non-task worker. +- Periodic stale cleanup keeps running even when the table is below the memory threshold, so expired cache rows and expired lock rows do not accumulate indefinitely. +- Policy eviction honors `eviction_proportion` and stores compact eviction candidates instead of copying serialized values into PHP heap. +- Eviction deletes a row only when the row is still the same logical candidate selected during the scan. +- A stale interval refresher cannot overwrite a newer refresher's public value after its claim has been reclaimed. +- Row-lock contention has a tiny backoff after a short spin burst, avoiding sustained CPU burn under hot-key contention without adding cost to uncontended locks. +- Cache-hit metadata remains lock-free on the read hot path, with the known Swoole Table phantom-row behavior documented in the code. +- Multi-key writes report per-key write failures correctly, including empty batches as successful no-ops. +- Dead helpers introduced during the earlier design are removed. + +## Research + +Files checked: + +- `src/cache/src/SwooleStore.php` +- `src/cache/src/SwooleTableState.php` +- `src/cache/src/LimitedMaxHeap.php` +- `src/cache/src/Listeners/CreateSwooleTimers.php` +- `src/cache/src/CacheServiceProvider.php` +- `src/cache/src/RetrievesMultipleKeys.php` +- `src/cache/src/DatabaseStore.php` +- `src/cache/src/MemoizedStore.php` +- `src/cache/src/FailoverStore.php` +- `src/cache/src/StackStore.php` +- `src/cache/src/SwooleTimer.php` +- `src/boost/docs/cache.md` +- `src/core/src/Bootstrap/WorkerStartCallback.php` +- `src/core/src/Events/AfterWorkerStart.php` +- `src/core/src/Events/OnManagerStart.php` +- `src/server/src/Listeners/AfterWorkerStartListener.php` +- `src/server/src/Listeners/InitProcessTitleListener.php` +- `tests/Cache/CacheSwooleStoreTest.php` +- `tests/Cache/CacheSwooleStoreIntervalTest.php` +- `tests/Cache/CreateSwooleTimersTest.php` +- `Symfony\Component\Process\Process` test usage in `tests/Filesystem/FilesystemNonCoroutineTest.php` +- `docs/plans/2026-07-03-swoole-store-row-concurrency-and-locks.md` +- `docs/plans/2026-07-03-swoole-store-interval-cache-refresh.md` +- `docs/reviews/swoole-store-concurrency-intervals-review.md` + +Runtime probes confirmed: + +- `OnManagerStart` timer callbacks run in the Swoole manager process with `Coroutine::getCid() === -1`. +- Coroutine primitives used by pooled clients fail from the manager process. +- `AfterWorkerStart` callbacks run in worker processes where coroutine/runtime APIs are available. +- `WorkerStartCallback` dispatches `AfterWorkerStart` for both normal workers and task workers, so the cache timer listener must explicitly skip task workers. +- `Swoole\Server` should be mocked in timer unit tests. Constructing real `Swoole\Server` instances is one-per-process and breaks repeated test cases, while `m::mock(Swoole\Server::class)` satisfies `AfterWorkerStart`'s `readonly Server $server` property and allows direct `$server->taskworker` setup. +- `Swoole\Table` exposes `set`, `get`, `del` / `delete`, `exists` / `exist`, `incr`, `decr`, table sizing/stat methods, and iteration. It does not expose full-row CAS, set-if-absent, delete-if-current, or update-existing-only primitives. +- Swoole Table partial `set()` and `incr()` on a missing row create an empty row with `value = ''` and `expiration = 0.0`. In SwooleStore that row is logically expired, reads as `null`, and is pruned by stale cleanup or a later miss. +- `src/boost/docs/cache.md` describes interval refreshes as manager-process work. That sentence must change when the timers move to the elected worker. + +Relevant current code: + +```php +// src/cache/src/Listeners/CreateSwooleTimers.php +public function handle(OnManagerStart $event): void +{ + $this->swooleStores()->each(function (array $config, string $name) { + $this->timer->tick( + $config['eviction_interval'] ?? 10000, + fn () => $this->store($name)->evictRecords(), + ); + + $this->timer->tick( + $config['interval_refresh_interval'] ?? 1000, + fn () => $this->store($name)->refreshIntervalCaches(), + ); + }); +} +``` + +```php +// src/cache/src/LimitedMaxHeap.php +public function insert(mixed $value): true +{ + if ($this->count() < $this->limit) { + parent::insert($value); + return true; + } + + if ($this->compare($value, $this->top()) < 0) { + $this->extract(); + } + + parent::insert($value); + + return true; +} +``` + +```php +// src/cache/src/SwooleStore.php +protected function handleRecordsEviction(string $column): int +{ + $quantity = (int) round($this->table->getSize() * $this->evictionProportion); + + // ... + + foreach ($this->table as $key => $record) { + if ($this->isControlKey($key)) { + continue; + } + + $value = $record[$column]; + + $heap->insert(compact('key', 'record', 'value')); + } + + // ... + + if ($this->forgetEvictionCandidate($candidate['key'], $candidate['record'])) { + ++$deleted; + } +} +``` + +```php +// src/cache/src/SwooleStore.php +protected function refreshIntervalCache(string $metadataKey, bool $force = false, bool $rethrow = false): mixed +{ + $claimedAt = null; + + try { + // Claim metadata row under row lock. + + [$metadata, $claimedAt] = $claim; + + /** @var SerializableClosure $resolver */ + $resolver = unserialize($metadata['resolver']); + $value = $resolver(); + + if (! $this->forever($metadata['key'], $value)) { + throw new RuntimeException("Unable to refresh Swoole interval cache [{$metadata['key']}]."); + } + + $this->completeIntervalRefresh($metadataKey, $claimedAt); + + return $value; + } catch (Throwable $e) { + // ... + } +} +``` + +```php +// src/cache/src/SwooleTableState.php +protected function acquire(Atomic $lock): void +{ + while (! $lock->cmpset(0, 1)) { + // Critical sections must stay short, non-yielding, and fatal-free so finally can release the stripe. + // A hard process death while holding a stripe leaves it locked until the Swoole table state is recreated. + } +} +``` + +```php +// src/cache/src/SwooleStore.php +public function putMany(array $values, int $seconds): bool +{ + foreach ($values as $key => $value) { + $this->put($key, $value, $seconds); + } + + return true; +} +``` + +```php +// src/cache/src/RetrievesMultipleKeys.php +public function putMany(array $values, int $seconds): bool +{ + $manyResult = null; + + foreach ($values as $key => $value) { + $result = $this->put((string) $key, $value, $seconds); + + $manyResult = is_null($manyResult) ? $result : $result && $manyResult; + } + + return $manyResult ?: false; +} +``` + +## Confirmed Problems + +### Timers run in the wrong Swoole process + +The timer listener currently listens for `OnManagerStart`. The manager process is not a normal worker and does not have a request/coroutine execution context. Interval resolvers are user callbacks and commonly use pooled DB, Redis, or HTTP clients. Those pooled clients depend on coroutine primitives, so proactive interval refreshes can fail in the manager process. + +The fix is to register the cache timers on `AfterWorkerStart`, restricted to non-task worker `0`. That worker already has access to the normal runtime. Registering on exactly one worker preserves single-timer behavior. + +Both timers should move together: + +- The interval refresh timer must move because it runs user resolvers. +- The stale cleanup / eviction timer should move because it touches the same cache store and should not keep doing table work in the manager process. + +The eviction timer callback must continue to call `evictRecords()` directly. It must not be guarded by `memoryLimitIsReached()` in the listener, because `evictRecords()` begins with unconditional `flushStaleRecords()`. Gating the timer at the listener would stop periodic stale-row and expired-lock cleanup whenever the table is below the memory threshold. + +### `LimitedMaxHeap` does not enforce its limit + +When the heap is full and the incoming value is not better than the current top, the current method still inserts the value. The heap grows beyond its intended size. `SwooleStore::handleRecordsEviction()` expects the heap to retain only the lowest-ranked `round(table_size * eviction_proportion)` rows; instead it can retain and evict far more rows. + +This also causes avoidable PHP heap pressure because each candidate currently includes the full Swoole row, including the serialized cache value. + +### Eviction candidate recheck copies too much and compares too much + +The existing PR rechecks eviction candidates under the row lock by strict-comparing the current raw row to the row captured during the scan. That is correct in spirit, but it stores full rows in the heap. Under memory pressure this copies serialized values into PHP memory precisely when the table is already near capacity. + +The final design should keep the recheck but store a compact fingerprint: + +- `hash('xxh128', $record['value'])` +- `expiration` +- `last_used_at` +- `used_count` + +The value hash is necessary. A metadata-only fingerprint would miss value changes from `put()` and `increment()` when expiration and hit metadata happen to stay the same. + +### A reclaimed stale interval refresher can overwrite a newer value + +The interval claim protects metadata updates, but the public cache value write currently happens before rechecking ownership of the claim. If refresher A runs longer than the stale-claim timeout, refresher B can reclaim the interval and write a newer value. When A eventually returns, A can still call `forever()` and overwrite B's value, even though A's later metadata completion is correctly ignored. + +The public value write must be gated by a fresh ownership check after the resolver returns. + +### Row-lock contention can busy-spin too aggressively + +The striped `Swoole\Atomic` lock is still the right primitive for Swoole Table multi-step operations because Swoole Table has no native full-row CAS, set-if-absent, delete-if-current, or update-existing-only operation. The current acquire loop is a pure busy spin. That is optimal for the uncontended case and acceptable for very short waits, but it can burn CPU under hot-key contention. + +The final design should keep the same shared stripe locks and add a tiny backoff after a short burst of failed spins. This does not add any operation on the uncontended path. + +Stale-owner stealing should not be added. With `Swoole\Atomic` there is no reliable owner-death detection. Stealing based only on elapsed time can break correctness if the owner is alive but paused by CPU scheduling, GC, extension work, or debugging. The existing class-level `@TODO` should remain because native Swoole Table CAS / set-if-absent / delete-if-current primitives would be a better long-term primitive. + +### `recordHit()` is intentionally lock-free but needs a WHY comment + +`recordHit()` updates LRU/LFU metadata after an unlocked `get()`. Locking the read-hit path would add overhead to the hottest operation in the store. Swoole Table does not provide update-existing-only, so a concurrent delete can cause partial `set()` or `incr()` to recreate an expired empty row. That phantom row is logically expired, reads as `null`, and is cleaned by stale cleanup or a later miss. + +This is a deliberate performance trade-off and should be recorded near the method. + +### `putMany()` masks failed writes + +`SwooleStore::putMany()` currently ignores the result from each `put()` call and always returns `true`. `put()` can fail if the table cannot allocate a row. A multi-key write should return `false` if any item fails, and an empty input should return `true`. + +The shared fallback trait has the same empty-input problem in a different form: it aggregates results but returns `false` for an empty array. That should be corrected so stores using the fallback have consistent no-op semantics. + +The trait is used by `AbstractArrayStore`, `FileStore`, `NullStore`, and `SessionStore`, so trait-level tests should cover the contract once. Other native `putMany()` implementations should be audited at the same time: + +- `DatabaseStore::putMany()` should return `true` for an empty batch and should not treat a successful no-op upsert as a write failure. +- `DatabaseStore::put()` delegates to `putMany()`, so the same fix also makes identical-value single-key writes report success when the database reports zero affected rows. +- `StackStore::putMany()` already returns `true` for an empty batch but should aggregate failures while still attempting every key, matching the shared fallback. +- `MemoizedStore::putMany()` delegates to its repository after invalidating memoized keys, so it inherits the underlying store's result once the underlying stores are fixed. +- `FailoverStore::putMany()` delegates to the first available configured store, so its empty-batch semantics follow the selected store. + +### `isUserKey()` is dead code + +`isUserKey()` is not called by the store or tests. It should be removed. + +## Decisions + +### Keep PR #414's row-locking design + +The earlier row-locking design is the right foundation. It fixed the important SwooleStore correctness gaps: + +- atomic `add()` +- logical expiration in `add()` +- lost updates in `increment()`, `decrement()`, and `touch()` +- stale delete races in cleanup and eviction +- lock API support +- raw Swoole table key truncation / collision risk + +The follow-up work should harden that design rather than replace it. + +### Register timers from `AfterWorkerStart` on non-task worker `0` + +Use the same event style as other Hypervel listeners: + +```php +// src/cache/src/CacheServiceProvider.php +use Hypervel\Core\Events\AfterWorkerStart; + +// ... + +$events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event) { + $this->app->make(CreateSwooleTimers::class)->handle($event); +}); +``` + +The listener should type `AfterWorkerStart` and guard all timer registration: + +```php +// src/cache/src/Listeners/CreateSwooleTimers.php +use Hypervel\Core\Events\AfterWorkerStart; + +class CreateSwooleTimers extends BaseListener +{ + // ... + + /** + * Create timers for all configured Swoole cache stores. + */ + public function handle(AfterWorkerStart $event): void + { + if (! $this->shouldRegisterTimers($event)) { + return; + } + + $this->swooleStores()->each(function (array $config, string $name) { + $this->timer->tick( + $config['eviction_interval'] ?? 10000, + fn () => $this->store($name)->evictRecords(), + ); + + $this->timer->tick( + $config['interval_refresh_interval'] ?? 1000, + fn () => $this->store($name)->refreshIntervalCaches(), + ); + }); + } + + /** + * Determine if this worker should own Swoole cache timers. + */ + protected function shouldRegisterTimers(AfterWorkerStart $event): bool + { + return $event->workerId === 0 && ! $event->server->taskworker; + } +} +``` + +Do not extract a separate task-worker seam. Tests can construct a real `AfterWorkerStart` event around a Mockery `Swoole\Server` mock and set `$server->taskworker` to `true` or `false` directly. + +Rejected alternatives: + +- Keep timers on `OnManagerStart`: interval resolvers can use coroutine-only resources, so the manager process is the wrong place. +- Wrap manager timer callbacks in `Coroutine::run()` or `go()`: probes showed manager-process coroutine/runtime behavior is not a reliable fit for these pooled operations, and the framework already has a worker event. +- Register on every worker: that would multiply timer executions and make refresh/cleanup frequency depend on worker count. +- Move only interval refresh: stale cleanup and eviction also operate on worker-owned cache services, and a single worker owner is cleaner. +- Guard the eviction timer with `memoryLimitIsReached()`: this would stop the unconditional stale cleanup inside `evictRecords()`. +- Extract `isTaskWorker()` only for tests: the guard is a one-line production rule, and Mockery can model the server property without a test-only production seam. + +### Fix `LimitedMaxHeap` by discarding non-better full-heap values + +The heap should insert directly while below the limit. Once full, it should only replace the current top when the incoming value is better. + +```php +public function insert(mixed $value): true +{ + if ($this->count() < $this->limit) { + parent::insert($value); + + return true; + } + + if ($this->compare($value, $this->top()) < 0) { + $this->extract(); + parent::insert($value); + } + + return true; +} +``` + +Validate the heap limit in the constructor so the helper cannot be used in an invalid state: + +```php +use InvalidArgumentException; + +class LimitedMaxHeap extends SplMaxHeap +{ + public function __construct(protected int $limit) + { + if ($limit < 1) { + throw new InvalidArgumentException('Heap limit must be at least 1.'); + } + } + + public function insert(mixed $value): true + { + if ($this->count() < $this->limit) { + parent::insert($value); + + return true; + } + + if ($this->compare($value, $this->top()) < 0) { + $this->extract(); + parent::insert($value); + } + + return true; + } +} +``` + +`SwooleStore::handleRecordsEviction()` should keep returning before constructing the heap when quantity is `<= 0`; constructor validation is a guardrail for the helper itself. + +### Use compact eviction fingerprints + +Add a helper that captures exactly the row fields relevant to whether a scanned candidate is still the same row: + +```php +/** + * Get the compact eviction fingerprint for a raw table record. + * + * @return array{value_hash: string, expiration: float, last_used_at: float, used_count: int} + */ +protected function evictionFingerprint(array $record): array +{ + return [ + 'value_hash' => hash('xxh128', $record['value']), + 'expiration' => $record['expiration'], + 'last_used_at' => $record['last_used_at'], + 'used_count' => $record['used_count'], + ]; +} +``` + +Use it while building candidates: + +```php +foreach ($this->table as $key => $record) { + if ($this->isControlKey($key)) { + continue; + } + + $heap->insert([ + 'key' => $key, + 'fingerprint' => $this->evictionFingerprint($record), + 'value' => $record[$column], + ]); +} +``` + +Recheck under the row lock before deleting: + +```php +/** + * Forget an eviction candidate by table key. + * + * @param array{value_hash: string, expiration: float, last_used_at: float, used_count: int} $fingerprint + */ +protected function forgetEvictionCandidate(string $key, array $fingerprint): bool +{ + return $this->state->withRowLock($key, function () use ($key, $fingerprint): bool { + $record = $this->rawGet($key); + + if ($record === false + || $this->isControlKey($key) + || $this->evictionFingerprint($record) !== $fingerprint) { + return false; + } + + return $this->rawForget($key); + }); +} +``` + +Use non-cryptographic `xxh128` because this is an in-process change detector, not a trust boundary. + +Rejected alternatives: + +- Keep storing full rows: correct but wasteful under memory pressure. +- Use metadata-only fingerprints: misses value changes from `put()` and `increment()`. +- Re-read and compare only the ranked column: misses almost every meaningful mutation. + +### Guard interval public value commits with the current claim + +After the resolver returns, re-acquire the metadata row lock and confirm this refresher still owns the claim. If it still owns the claim, restamp `refreshingAt` to a fresh timestamp and release the metadata lock. Then write the public value outside the metadata lock and complete the refresh using the new claim timestamp. + +The restamp matters because if the value write fails after the commit check, the catch path should clear only the claim owned by this refresher. It must not clear a newer claim created after this refresher released the metadata lock. + +`lastRefreshedAt` should intentionally use the commit timestamp after this change, not the original claim timestamp. That makes the interval cadence "N seconds after the last successful refresh completed" instead of "N seconds after the refresh started." Completion-time cadence is the cleaner invariant for slow resolvers: a resolver that takes most of its interval does not immediately become due again right after it finishes. + +Add a helper shaped like: + +```php +/** + * Prepare a claimed interval refresh for public value commit. + * + * @return null|array{0: array, 1: float} + */ +protected function prepareIntervalRefreshCommit(string $metadataKey, float $claimedAt): ?array +{ + return $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt): ?array { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($metadata === null || $metadata['refreshingAt'] !== $claimedAt) { + return null; + } + + $commitClaimedAt = $this->getCurrentTimestamp(); + $metadata['refreshingAt'] = $commitClaimedAt; + + if (! $this->putIntervalMetadataByInternalKey($metadataKey, $metadata)) { + throw new RuntimeException("Unable to prepare Swoole interval cache refresh [{$metadata['key']}]."); + } + + return [$metadata, $commitClaimedAt]; + }); +} +``` + +Update `refreshIntervalCache()`: + +```php +[$metadata, $claimedAt] = $claim; + +/** @var SerializableClosure $resolver */ +$resolver = unserialize($metadata['resolver']); +$value = $resolver(); + +$commit = $this->prepareIntervalRefreshCommit($metadataKey, $claimedAt); + +if ($commit === null) { + return null; +} + +[$metadata, $claimedAt] = $commit; + +if (! $this->forever($metadata['key'], $value)) { + throw new RuntimeException("Unable to refresh Swoole interval cache [{$metadata['key']}]."); +} + +$this->completeIntervalRefresh($metadataKey, $claimedAt); + +return $value; +``` + +Keep the public `forever()` call outside the metadata lock. `forever()` locks the user row, serializes arbitrary user values, and may trigger eviction. Holding the metadata lock while doing that would create a metadata-to-user nested lock path and increase deadlock risk against all-stripe operations. The current pattern keeps user code and user value serialization outside metadata-row critical sections. + +The catch block should keep using the current `$claimedAt` value. After `prepareIntervalRefreshCommit()` succeeds, `$claimedAt` is the restamped claim and failed public writes clear only that restamped claim. If `prepareIntervalRefreshCommit()` returns `null`, no catch path runs and no claim is cleared, because this refresher no longer owns the metadata row. + +Tests should assert the completion-time behavior anywhere the clock advances between claim and commit. Existing frozen-time tests can continue asserting exact equality because claim and commit timestamps are the same under a frozen `Carbon::setTestNow()`. + +Rejected alternatives: + +- Write the public value under the metadata lock: correct for the stale-overwrite race but creates a wider and nested critical section. +- Compare only after writing: still allows stale values to be visible. +- Skip the restamp: a failed public value write could clear a newer claim that appears between the ownership check and catch cleanup. +- Preserve start-time cadence: this allows slow resolvers to become due again immediately after completion, which is less useful than spacing refreshes from the last successful commit. + +### Add a failed-CAS backoff to row-lock acquisition + +Keep the same lock primitive and add a small backoff only after repeated failed CAS attempts: + +```php +protected const SPINS_BEFORE_BACKOFF = 64; + +/** + * Acquire a striped lock. + */ +protected function acquire(Atomic $lock): void +{ + $spins = 0; + + while (! $lock->cmpset(0, 1)) { + if (++$spins >= self::SPINS_BEFORE_BACKOFF) { + $spins = 0; + usleep(1); + } + } +} +``` + +Use raw `usleep(1)` deliberately: + +- It is skipped entirely on uncontended locks. +- It avoids a dependency on framework sleep fakes in low-level cache infrastructure. +- In Swoole workers with runtime hooks, it can yield cooperatively. +- In non-coroutine contexts, it remains valid PHP. + +Do not use `Hypervel\Support\Sleep` here. Lock acquisition is low-level infrastructure and should not be fakeable by tests outside cache row-lock behavior. + +Do not add owner-token stale reclaim. `Swoole\Atomic` does not prove owner death, and time-based stealing can let two live workers enter the same critical section. + +The existing class docblock should keep the `@TODO` requested by the owner: + +```php +/** + * Coordinates multi-step Swoole table row mutations across workers. + * + * Swoole Table does not currently provide full-row compare-and-swap, + * set-if-absent, or delete-if-current primitives, so cache operations that need + * atomic read-check-write behavior use striped shared Atomics around tiny + * critical sections. + * + * @TODO Revisit this if Swoole Table adds full-row CAS / set-if-absent / + * delete-if-current primitives so these operations can use native table atomics + * instead of external stripe locks. + */ +``` + +### Keep `recordHit()` lock-free and document the phantom-row behavior + +Add a concise WHY comment near the method: + +```php +/** + * Record a cache hit. + */ +protected function recordHit(string $key): void +{ + // Hit metadata stays lock-free for the read hot path. If a concurrent delete wins, + // Swoole can create an expired shell row that stale cleanup later prunes. + if ($this->evictionPolicy === static::EVICTION_POLICY_LRU) { + $this->table->set($key, ['last_used_at' => $this->getCurrentTimestamp()]); + + return; + } + + if ($this->evictionPolicy === static::EVICTION_POLICY_LFU) { + $this->table->incr($key, 'used_count', 1); + } +} +``` + +Do not lock `recordHit()`. That would add row-lock overhead to every LRU/LFU cache hit and would compromise the store's fastest path to prevent a harmless, self-pruning shell row. + +### Aggregate `putMany()` results and treat empty input as success + +Fix `SwooleStore`: + +```php +public function putMany(array $values, int $seconds): bool +{ + $result = true; + + foreach ($values as $key => $value) { + $result = $this->put((string) $key, $value, $seconds) && $result; + } + + return $result; +} +``` + +Fix the shared fallback trait with the same semantics: + +```php +public function putMany(array $values, int $seconds): bool +{ + $result = true; + + foreach ($values as $key => $value) { + $result = $this->put((string) $key, $value, $seconds) && $result; + } + + return $result; +} +``` + +The `&& $result` order preserves execution of every write while still returning `false` if any write fails. + +### Remove `isUserKey()` + +Delete the method and do not replace it. The store only needs `isControlKey()` and `isLockKey()`. + +## Implementation Plan + +### 1. Update timer event wiring + +Edit `src/cache/src/CacheServiceProvider.php`: + +- Replace the `OnManagerStart` import with `AfterWorkerStart`. +- Register `CreateSwooleTimers` on `AfterWorkerStart`. +- Keep `CreateSwooleTable` on `BeforeServerStart`. + +Edit `src/cache/src/Listeners/CreateSwooleTimers.php`: + +- Replace `OnManagerStart` with `AfterWorkerStart`. +- Add the `shouldRegisterTimers()` guard. +- Inline the task-worker check as `$event->server->taskworker`. +- Leave the callbacks as `evictRecords()` and `refreshIntervalCaches()`. + +Do not add `memoryLimitIsReached()` to the listener. + +### 2. Fix `LimitedMaxHeap` + +Edit `src/cache/src/LimitedMaxHeap.php`: + +- Reject limits below `1` in the constructor. +- Move `parent::insert($value)` inside the full-heap replacement branch. +- Return `true` after discarding non-better values. + +### 3. Compact eviction candidates + +Edit `src/cache/src/SwooleStore.php`: + +- Add `evictionFingerprint()` beside the eviction helpers, near `handleRecordsEviction()` and `forgetEvictionCandidate()`. +- Change `handleRecordsEviction()` to insert `key`, `value`, and `fingerprint`. +- Change the drain loop to pass the fingerprint to `forgetEvictionCandidate()`. +- Change `forgetEvictionCandidate()` to re-read under the row lock, skip missing/control/mutated rows, and delete only unchanged rows. + +### 4. Guard interval refresh commits + +Edit `src/cache/src/SwooleStore.php`: + +- Add `prepareIntervalRefreshCommit()` beside the interval refresh helpers, between `refreshIntervalCache()` and `completeIntervalRefresh()`. +- Update `refreshIntervalCache()` to: + - claim as it does today; + - run the resolver outside the lock; + - call `prepareIntervalRefreshCommit()`; + - return `null` if the claim has been lost; + - update `$metadata` and `$claimedAt` from the commit helper; + - write the public value outside the metadata lock; + - call `completeIntervalRefresh()` using the restamped claim timestamp. +- Keep `completeIntervalRefresh()` and `clearIntervalClaim()` guarded by exact `refreshingAt === $claimedAt`. +- Ensure `completeIntervalRefresh()` keeps stamping `lastRefreshedAt` from the current `$claimedAt`, which is the commit timestamp after `prepareIntervalRefreshCommit()` succeeds. + +### 5. Add lock-acquire backoff + +Edit `src/cache/src/SwooleTableState.php`: + +- Add `SPINS_BEFORE_BACKOFF`. +- Add the failed-spin counter and raw `usleep(1)`. +- Keep the class-level `@TODO`. +- Keep release as `cmpset(1, 0)`. + +### 6. Clarify `recordHit()` and remove dead helper + +Edit `src/cache/src/SwooleStore.php`: + +- Add the concise WHY comment to `recordHit()`. +- Remove `isUserKey()`. + +### 7. Fix multi-key write aggregation + +Edit `src/cache/src/SwooleStore.php`: + +- Aggregate per-key `put()` results. +- Cast array keys to string before passing to `put()`. +- Return `true` for empty arrays. + +Edit `src/cache/src/RetrievesMultipleKeys.php`: + +- Use the same aggregate shape. +- Return `true` for empty arrays. +- This changes the fallback semantics for `AbstractArrayStore`, `FileStore`, `NullStore`, and `SessionStore`. + +Edit `src/cache/src/DatabaseStore.php`: + +- Return `true` immediately for empty input. +- Treat a successful non-empty `upsert()` call as success even when the affected-row count is `0`, because an upsert that writes identical values can be a no-op at the database row-count level without being a cache write failure. +- Implement that as `upsert(); return true;` after the empty-input guard. Do not use `>= 0`; the database exception path is the failure signal, and a comparison that is always true obscures the intent. +- Cover `put()` too, because it delegates directly to `putMany([$key => $value], $seconds)`. + +Edit `src/cache/src/StackStore.php`: + +- Aggregate per-key `put()` results while still attempting every key. +- Keep returning `true` for empty input. + +Audit `src/cache/src/MemoizedStore.php` and `src/cache/src/FailoverStore.php`: + +- Confirm their `putMany()` semantics follow their selected/delegated store after the direct stores above are fixed. +- Add tests where needed; do not add source changes unless the audit exposes incorrect behavior. + +### 8. Update Swoole cache documentation + +Edit `src/boost/docs/cache.md`: + +- Change the `interval_refresh_interval` prose from manager-process refreshes to refreshes by the elected worker. +- Keep the config comments unchanged; they describe intervals, not process ownership. + +### 9. Rename stale interval test language + +Edit `tests/Cache/CacheSwooleStoreIntervalTest.php`: + +- Rename methods and variables that call the refresher instance `managerStore`. +- Use names such as `refresherStore`, `readerStore`, or `timerStore` to match the final worker-timer design. +- Keep the assertions unchanged unless the implementation change requires a more precise assertion. + +## Testing Plan + +Run each focused test file after editing it, then run `composer fix`. + +### Timer tests + +Update `tests/Cache/CreateSwooleTimersTest.php`: + +- Replace `OnManagerStart` with `AfterWorkerStart`. +- Add a helper to create real `AfterWorkerStart` events with a Mockery `Swoole\Server` mock. +- Add a test proving timers are registered on non-task worker `0`. +- Add a test proving timers are not registered on worker `1`. +- Add a test proving timers are not registered on task workers by setting `$server->taskworker = true` on the mock server. +- Keep the callback test proving one tick calls `evictRecords()` and the other calls `refreshIntervalCaches()`. +- Keep the interval values test for configured and default timer intervals. + +Useful test shape: + +```php +use Hypervel\Core\Events\AfterWorkerStart; +use Swoole\Server as SwooleServer; + +private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart +{ + $server = m::mock(SwooleServer::class); + $server->taskworker = $taskworker; + + return new AfterWorkerStart($server, $workerId); +} +``` + +Focused command: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/CreateSwooleTimersTest.php +``` + +### Heap tests + +Add `tests/Cache/LimitedMaxHeapTest.php`: + +- Invalid limits below `1` throw `InvalidArgumentException`. +- Full heap plus smaller value replaces the current max and retains the expected smallest values. +- Full heap plus larger value is discarded. +- Ascending input retains exactly `k` smallest values. +- Descending input retains exactly `k` smallest values. +- Shuffled input retains exactly `k` smallest values. + +Focused command: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/LimitedMaxHeapTest.php +``` + +### Eviction tests + +Extend `tests/Cache/CacheSwooleStoreTest.php`: + +- Add single-pass policy eviction tests for LRU, LFU, and TTL with `eviction_proportion < 1.0`. Invoke `removeRecordsByEvictionPolicy()` through a probe subclass instead of calling `evictRecords()`, because `evictRecords()` loops until memory pressure clears and can legitimately remove several batches in one call. +- Assert the single pass removes exactly the expected lowest-ranked records and preserves higher-ranked records. +- Add mutated-candidate recheck tests using a test subclass that exposes the protected fingerprint and candidate-forget helpers: + - capture a candidate fingerprint; + - perform one real mutation; + - call the candidate-forget helper with the stale fingerprint; + - assert the helper returns `false` and the row survives. +- Cover mutations from: + - `put()`, where the value hash changes; + - `increment()`, where the value hash changes while expiration and hit metadata may remain unchanged; + - an LRU/LFU hit, where hit metadata changes. +- Add `recordHit()` phantom-row behavior tests for both LRU and LFU: + - simulate a row read by calling the protected method through a test subclass or reflection after deleting the row; + - assert Swoole may create a row with empty value and `expiration = 0.0`; + - assert `get()` returns `null` and prunes the row. +- Add `putMany()` tests: + - successful multi-write still stores all values; + - empty input returns `true`; + - partial failure returns `false` while still attempting every item. Use a SwooleStore test subclass whose `put()` fails for one selected key and records all attempted keys. + +Useful candidate-recheck test shape: + +```php +class EvictionCandidateProbeSwooleStore extends SwooleStore +{ + public function removeOnePolicyBatch(): int + { + return $this->removeRecordsByEvictionPolicy(); + } + + public function userTableKey(string $key): string + { + return $this->userKey($key); + } + + public function fingerprintFor(array $record): array + { + return $this->evictionFingerprint($record); + } + + public function forgetCandidate(string $tableKey, array $fingerprint): bool + { + return $this->forgetEvictionCandidate($tableKey, $fingerprint); + } +} +``` + +Then each mutation test should capture `fingerprintFor($row)`, mutate once through the public behavior being covered, and assert `forgetCandidate($tableKey, $staleFingerprint)` returns `false`. + +Focused command: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/CacheSwooleStoreTest.php +``` + +### Interval tests + +Extend `tests/Cache/CacheSwooleStoreIntervalTest.php`: + +- Rename existing `managerStore` test methods and variables to avoid describing the refresher as manager-owned after timers move to worker `0`. +- Add stale-overwriter regression: + - refresher A claims and resolver returns slowly; + - after the stale timeout, refresher B reclaims and writes value `B`; + - A returns value `A`; + - final public value remains `B`; + - metadata remains completed for B, not A; + - `lastRefreshedAt` records B's commit timestamp. +- Add a test where the first claim is lost before commit and the resolver result is not written. +- Add a test proving slow successful refreshes use commit-time cadence: advance `Carbon::setTestNow()` between claim and commit, then assert `lastRefreshedAt` equals the commit timestamp. +- Add a test for the `intervalClaimIsStale()` branch where `refreshInterval * 2` is greater than the 300-second floor. +- Add a stderr fallback test for `reportIntervalException()` when no `ExceptionHandler` binding exists. Use a temporary PHP script launched with `Symfony\Component\Process\Process`, following the subprocess style in `tests/Filesystem/FilesystemNonCoroutineTest.php`, so stderr can be asserted through `$process->getErrorOutput()` without trying to intercept `php://stderr` inside the PHPUnit process. +- Keep existing tests for first-tick refresh, shared index discovery, same-instance fallback, exception reporting, claim clearing, and metadata/index preservation. + +Useful stale-overwriter test shape: + +```php +Carbon::setTestNow('2000-01-01 00:00:00'); + +$state = $this->createState(); +$workerStore = $this->createStore($state); +$refresherStore = $this->createStore($state); + +IntervalReentryProbe::$attempts = 0; +IntervalReentryProbe::$refresherStore = $refresherStore; + +$workerStore->interval('foo', function () { + ++IntervalReentryProbe::$attempts; + + if (IntervalReentryProbe::$attempts === 1) { + Carbon::setTestNow('2000-01-01 00:05:01'); + IntervalReentryProbe::$refresherStore->refreshIntervalCaches(); + + return 'A'; + } + + return 'B'; +}, 5); + +$workerStore->refreshIntervalCaches(); + +$this->assertSame('B', $workerStore->get('foo')); +$this->assertSame(Carbon::parse('2000-01-01 00:05:01')->getPreciseTimestamp(6) / 1000000, $this->metadata($state, $this->metadataKey($workerStore, 'foo'))['lastRefreshedAt']); +``` + +The static probe avoids serializing the refresher store inside the interval closure. + +```php +class IntervalReentryProbe +{ + public static int $attempts = 0; + + public static ?SwooleStore $refresherStore = null; + + public static function reset(): void + { + self::$attempts = 0; + self::$refresherStore = null; + } +} +``` + +Call `IntervalReentryProbe::reset()` before and after the stale-overwriter test so the static store reference does not persist between tests. + +Useful stderr fallback test shape: + +```php +$scriptPath = $tempDir . '/interval-stderr.php'; + +file_put_contents($scriptPath, <<<'PHP' +reportIntervalException($e); + } +} + +Container::setInstance(new Container); + +$state = (new SwooleTableManager(new Container))->createState(8, 1024, 0.2, 12345); +$store = new ReportIntervalExceptionProbeStore($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); +$store->report(new RuntimeException('refresh failed')); +PHP); + +$process = new Process([PHP_BINARY, $scriptPath, dirname(__DIR__, 2) . '/vendor/autoload.php']); +$process->mustRun(); + +$this->assertStringContainsString('refresh failed', $process->getErrorOutput()); +``` + +Focused command: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/CacheSwooleStoreIntervalTest.php +``` + +### Shared fallback `putMany()` tests + +Add or extend the cache tests covering `RetrievesMultipleKeys`: + +- Empty array returns `true`. +- One failed write returns `false`. +- Later writes are still attempted after an earlier failure. +- The tested behavior applies to `AbstractArrayStore`, `FileStore`, `NullStore`, and `SessionStore` because they use the trait. + +Use a tiny inline test store that uses `RetrievesMultipleKeys` and records `put()` calls. + +Useful fallback store shape: + +```php +class RetrievesMultipleKeysPutManyProbe +{ + use RetrievesMultipleKeys; + + public array $calls = []; + + public function __construct(private array $failures = []) + { + } + + public function put(string $key, mixed $value, int $seconds): bool + { + $this->calls[] = $key; + + return ! in_array($key, $this->failures, true); + } +} +``` + +Focused command depends on placement: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/CacheRetrievesMultipleKeysTest.php +``` + +### Other native `putMany()` tests + +Extend the existing store-specific tests where the store owns a native `putMany()` implementation: + +- `tests/Cache/CacheDatabaseStoreTest.php` + - empty `putMany([])` returns `true` and does not call `upsert()`; + - non-empty `putMany()` returns `true` when `upsert()` returns `0`, proving successful no-op upserts are not reported as failures. + - `put()` returns `true` when its delegated `upsert()` returns `0`, proving identical single-key writes are not reported as failures. +- `tests/Cache/CacheStackStoreTest.php` + - empty `putMany([])` returns `true`; + - a failed key returns `false` but later keys are still attempted. +- `MemoizedStore` + - extend `tests/Cache/CacheMemoizedStoreTest.php` with an empty-input assertion that proves it returns the delegated repository result and keeps non-empty invalidation behavior intact. +- `FailoverStore` + - extend `tests/Integration/Cache/FailoverStoreTest.php` with an empty-input assertion that proves it returns the selected store's result. + +Focused commands depend on placement: + +```sh +./vendor/bin/phpunit --no-progress tests/Cache/CacheDatabaseStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheStackStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheMemoizedStoreTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Cache/FailoverStoreTest.php +``` + +### Static analysis and full verification + +After focused tests pass: + +```sh +composer fix +``` + +This runs CS Fixer, PHPStan, and `composer test:parallel`. + +## Self-Review Checklist For Implementation + +- Confirm `CreateSwooleTimers` cannot register timers from manager or task workers. +- Confirm worker `0` registers exactly two timers per configured Swoole store and no timers for non-Swoole stores. +- Confirm the eviction timer still calls `evictRecords()` directly. +- Confirm `evictRecords()` still flushes stale records before checking memory pressure and policy. +- Confirm `LimitedMaxHeap` never grows beyond its limit. +- Confirm `LimitedMaxHeap` rejects invalid limits. +- Confirm single-pass eviction tests call `removeRecordsByEvictionPolicy()` through a probe, not `evictRecords()`. +- Confirm eviction candidates no longer store full serialized values in the heap. +- Confirm candidate fingerprints include `value_hash`; tests must fail if the value hash is removed. +- Confirm `evictionFingerprint()` is placed near the eviction helpers. +- Confirm no `withRowLock()` callback calls a public method that can acquire another row lock unless the row order is explicitly safe. +- Confirm interval resolver execution and public value serialization remain outside metadata row locks. +- Confirm `prepareIntervalRefreshCommit()` is placed near the interval refresh helpers. +- Confirm successful interval refreshes use completion-time cadence after restamp. +- Confirm stale interval commit loss returns without clearing another worker's claim. +- Confirm failed public interval writes clear only the restamped claim owned by the failing refresher. +- Confirm lock acquire has no extra work after a successful first CAS. +- Confirm no owner-token or stale-owner stealing logic is added. +- Confirm `recordHit()` still does not acquire row locks. +- Confirm the `recordHit()` comment explains the Swoole phantom-row trade-off without overstating risk. +- Confirm `putMany()` methods attempt every key and return `false` if any write fails. +- Confirm empty `putMany([])` returns `true` for SwooleStore and the shared fallback. +- Confirm `DatabaseStore`, `StackStore`, `MemoizedStore`, and `FailoverStore` have correct empty-batch semantics after the audit. +- Confirm `isUserKey()` is gone and no references remain. +- Confirm interval tests no longer use manager-process naming for worker-refresh behavior. +- Confirm `src/boost/docs/cache.md` no longer says interval refreshes run in the manager process. +- Confirm new comments explain WHY rather than narrating obvious code. + +## Documentation / PR Notes + +The PR description should be updated after implementation: + +- Mention that timers now run from the elected worker, not the manager process. +- Mention that policy eviction now honors `eviction_proportion`. +- Mention the compact eviction fingerprint as both a correctness recheck and a memory-pressure improvement. +- Mention the interval stale-overwriter fix. +- Mention that row-lock contention now backs off after failed spin bursts without changing the uncontended path. +- Mention `putMany()` result semantics if it is relevant to the summary or changelog. +- Mention the timer process wording change in `src/boost/docs/cache.md` if the PR body has a docs section. +- Re-run SwooleStore microbenchmarks after implementation and update the PR table if the measured numbers change. + +## Final Verification + +Before asking for code review after implementation: + +1. Run all focused tests listed above. +2. Run `composer fix`. +3. Re-read `SwooleStore`, `SwooleTableState`, `LimitedMaxHeap`, `CreateSwooleTimers`, `CacheServiceProvider`, and `RetrievesMultipleKeys` in full. +4. Trace the interval refresh path through claim, resolver, commit prep, public write, completion, and catch cleanup. +5. Trace eviction through scan, heap retention, fingerprint, lock recheck, and delete. +6. Trace timer registration from service provider boot to `AfterWorkerStart`. +7. Trace every edited `putMany()` path and confirm empty batches are successful no-ops. +8. Confirm there is no dead helper, stale comment, or documentation that describes the old manager-timer behavior. diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md index f2079fe29..1e91dbc83 100644 --- a/src/boost/docs/cache.md +++ b/src/boost/docs/cache.md @@ -130,11 +130,14 @@ use Hypervel\Cache\SwooleStore; 'eviction_policy' => SwooleStore::EVICTION_POLICY_LRU, 'eviction_proportion' => 0.05, 'eviction_interval' => 10000, // milliseconds + 'interval_refresh_interval' => 1000, // milliseconds ], ``` The available eviction policy constants are `EVICTION_POLICY_LRU`, `EVICTION_POLICY_LFU`, `EVICTION_POLICY_TTL`, and `EVICTION_POLICY_NOEVICTION`. +The `eviction_interval` option controls how often stale records are pruned and memory-pressure eviction runs. The `interval_refresh_interval` option controls how often registered interval caches are checked and refreshed by the elected Swoole worker. + The table itself is configured in the `swoole_tables` section of your `config/cache.php` file: ```php @@ -765,7 +768,7 @@ You may clear all atomic locks in the cache using the `flushLocks` method: Cache::flushLocks(); ``` -The `flushLocks` method is supported by the `redis`, `database`, `file`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`. +The `flushLocks` method is supported by the `redis`, `database`, `file`, `swoole`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`. > [!WARNING] > The `flushLocks` method removes every lock in the lock store, regardless of which application or process owns the lock. Use it carefully in shared environments. diff --git a/src/boost/docs/events.md b/src/boost/docs/events.md index 851df6d63..e1c90332a 100644 --- a/src/boost/docs/events.md +++ b/src/boost/docs/events.md @@ -549,7 +549,7 @@ class SendShipmentNotification implements ShouldQueue, ShouldBeEncrypted ### Unique Event Listeners > [!WARNING] -> Unique listeners require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). The `redis`, `database`, `file`, and `array` cache drivers support atomic locks. +> Unique listeners require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). The `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks. Sometimes, you may want to ensure that only one instance of a specific listener is on the queue at any point in time. You may do so by implementing the `ShouldBeUnique` interface on your listener class: diff --git a/src/boost/docs/octane.md b/src/boost/docs/octane.md index df675b757..816eaab85 100644 --- a/src/boost/docs/octane.md +++ b/src/boost/docs/octane.md @@ -554,7 +554,7 @@ Cache::store('octane')->put('framework', 'Laravel', 30); ### Cache Intervals -In addition to the typical methods provided by Laravel's cache system, the Octane cache driver features interval based caches. These caches are automatically refreshed at the specified interval and should be registered within the `boot` method of one of your application's service providers. For example, the following cache will be refreshed every five seconds: +In addition to the typical methods provided by Hypervel's cache system, the Octane cache driver features interval based caches. These caches are automatically refreshed at the specified interval and should be registered within the `boot` method of one of your application's service providers. For example, the following cache will be refreshed every five seconds: ```php use Hypervel\Support\Str; @@ -564,6 +564,8 @@ Cache::store('octane')->interval('random', function () { }, seconds: 5); ``` +Interval cache metadata is shared through Swoole tables and refreshed by the Swoole manager process. The first manager refresh tick seeds the shared cache value, so workers that did not register the interval locally may briefly read `null` until that first tick runs. The registering worker can resolve the interval immediately through its local fallback. + ## Tables diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index bbca45714..697d74f4e 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -321,7 +321,7 @@ If a job receives a collection or array of Eloquent models instead of a single m ### Unique Jobs > [!WARNING] -> Unique jobs require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, and `array` cache drivers support atomic locks. +> Unique jobs require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks. > [!WARNING] > Unique job constraints do not apply to jobs within batches. @@ -749,7 +749,7 @@ public function middleware(): array ``` > [!WARNING] -> The `WithoutOverlapping` middleware requires a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, and `array` cache drivers support atomic locks. +> The `WithoutOverlapping` middleware requires a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks. #### Sharing Lock Keys Across Job Classes diff --git a/src/boost/docs/session.md b/src/boost/docs/session.md index dbfa709a5..09bfa46af 100644 --- a/src/boost/docs/session.md +++ b/src/boost/docs/session.md @@ -307,7 +307,7 @@ For more information on Hypervel's cache methods, consult the [cache documentati ## Session Blocking > [!WARNING] -> To utilize session blocking, your application must be using a cache driver that supports [atomic locks](/docs/{{version}}/cache#atomic-locks). Currently, those cache drivers include the `redis`, `database`, `file`, and `array` drivers. In addition, you may not use the `cookie` session driver. +> To utilize session blocking, your application must be using a cache driver that supports [atomic locks](/docs/{{version}}/cache#atomic-locks). Currently, those cache drivers include the `redis`, `database`, `file`, `swoole`, and `array` drivers. In addition, you may not use the `cookie` session driver. By default, Hypervel allows requests using the same session to execute concurrently. So, for example, if you use a JavaScript HTTP library to make two HTTP requests to your application, they will both execute at the same time. For many applications, this is not a problem; however, session data loss can occur in a small subset of applications that make concurrent requests to two different application endpoints which both write data to the session. diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 60d55aac9..f2934c4fb 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -291,9 +291,9 @@ protected function getSession(): Session */ protected function createSwooleDriver(array $config): Repository { - $cacheTable = $this->app->make(SwooleTableManager::class)->get($config['table']); + $tableState = $this->app->make(SwooleTableManager::class)->get($config['table']); $store = new SwooleStore( - $cacheTable, + $tableState, $config['memory_limit_buffer'] ?? 0.05, $config['eviction_policy'] ?? SwooleStore::EVICTION_POLICY_LRU, $config['eviction_proportion'] ?? 0.05 diff --git a/src/cache/src/CacheServiceProvider.php b/src/cache/src/CacheServiceProvider.php index 7cb19e226..03d4ca4b8 100644 --- a/src/cache/src/CacheServiceProvider.php +++ b/src/cache/src/CacheServiceProvider.php @@ -10,11 +10,11 @@ use Hypervel\Cache\Console\PruneDbExpiredCommand; use Hypervel\Cache\Console\PruneStaleTagsCommand; use Hypervel\Cache\Listeners\CreateSwooleTable; -use Hypervel\Cache\Listeners\CreateTimer; +use Hypervel\Cache\Listeners\CreateSwooleTimers; use Hypervel\Cache\Redis\Console\BenchmarkCommand; use Hypervel\Cache\Redis\Console\DoctorCommand; +use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerStart; -use Hypervel\Core\Events\OnManagerStart; use Hypervel\Support\ServiceProvider; class CacheServiceProvider extends ServiceProvider @@ -56,8 +56,8 @@ public function boot(): void $this->app->make(CreateSwooleTable::class)->handle($event); }); - $events->listen(OnManagerStart::class, function (OnManagerStart $event) { - $this->app->make(CreateTimer::class)->handle($event); + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event) { + $this->app->make(CreateSwooleTimers::class)->handle($event); }); } } diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index adfa0a050..c84030289 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -163,6 +163,10 @@ public function put(string $key, mixed $value, int $seconds): bool */ public function putMany(array $values, int $seconds): bool { + if (empty($values)) { + return true; + } + $serializedValues = []; $expiration = $this->getTime() + $seconds; @@ -175,7 +179,10 @@ public function putMany(array $values, int $seconds): bool ]; } - return $this->table()->upsert($serializedValues, 'key') > 0; + // Identical-value upserts can report zero affected rows; failures surface as database exceptions. + $this->table()->upsert($serializedValues, 'key'); + + return true; } /** diff --git a/src/cache/src/LimitedMaxHeap.php b/src/cache/src/LimitedMaxHeap.php index 493c3faff..a48197a1b 100644 --- a/src/cache/src/LimitedMaxHeap.php +++ b/src/cache/src/LimitedMaxHeap.php @@ -4,27 +4,31 @@ namespace Hypervel\Cache; +use InvalidArgumentException; use SplMaxHeap; class LimitedMaxHeap extends SplMaxHeap { public function __construct(protected int $limit) { + if ($limit < 1) { + throw new InvalidArgumentException('Heap limit must be at least 1.'); + } } public function insert(mixed $value): true { if ($this->count() < $this->limit) { parent::insert($value); + return true; } if ($this->compare($value, $this->top()) < 0) { $this->extract(); + parent::insert($value); } - parent::insert($value); - return true; } } diff --git a/src/cache/src/Listeners/CreateSwooleTimers.php b/src/cache/src/Listeners/CreateSwooleTimers.php new file mode 100644 index 000000000..d21004105 --- /dev/null +++ b/src/cache/src/Listeners/CreateSwooleTimers.php @@ -0,0 +1,57 @@ +shouldRegisterTimers($event)) { + return; + } + + $this->swooleStores()->each(function (array $config, string $name) { + $this->timer->tick( + $config['eviction_interval'] ?? 10000, + fn () => $this->store($name)->evictRecords(), + ); + + $this->timer->tick( + $config['interval_refresh_interval'] ?? 1000, + fn () => $this->store($name)->refreshIntervalCaches(), + ); + }); + } + + /** + * Determine if this worker should own Swoole cache timers. + */ + protected function shouldRegisterTimers(AfterWorkerStart $event): bool + { + return $event->workerId === 0 && ! $event->server->taskworker; + } + + /** + * Get a Swoole cache store. + */ + protected function store(string $name): SwooleStore + { + /** @var SwooleStore */ + return $this->container->make('cache')->store($name)->getStore(); + } +} diff --git a/src/cache/src/Listeners/CreateTimer.php b/src/cache/src/Listeners/CreateTimer.php deleted file mode 100644 index 7b76b921c..000000000 --- a/src/cache/src/Listeners/CreateTimer.php +++ /dev/null @@ -1,27 +0,0 @@ -swooleStores()->each(function (array $config, string $name) { - Timer::tick($config['eviction_interval'] ?? 10000, function () use ($name) { - /** @var \Hypervel\Cache\SwooleStore */ - $store = Cache::store($name)->getStore(); - - $store->evictRecords(); - }); - }); - } -} diff --git a/src/cache/src/RetrievesMultipleKeys.php b/src/cache/src/RetrievesMultipleKeys.php index 518e9df36..a3af8221b 100644 --- a/src/cache/src/RetrievesMultipleKeys.php +++ b/src/cache/src/RetrievesMultipleKeys.php @@ -39,14 +39,13 @@ public function many(array $keys): array */ public function putMany(array $values, int $seconds): bool { - $manyResult = null; + $result = true; foreach ($values as $key => $value) { - $result = $this->put((string) $key, $value, $seconds); - - $manyResult = is_null($manyResult) ? $result : $result && $manyResult; + // Call put() first so every key is attempted even after an earlier write fails. + $result = $this->put((string) $key, $value, $seconds) && $result; } - return $manyResult ?: false; + return $result; } } diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index 150e833b4..fedb1fe72 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -41,13 +41,13 @@ public function put(string $key, mixed $value, int $seconds): bool public function putMany(array $values, int $seconds): bool { + $result = true; + foreach ($values as $key => $value) { - if (! $this->put($key, $value, $seconds)) { - return false; - } + $result = $this->put((string) $key, $value, $seconds) && $result; } - return true; + return $result; } public function increment(string $key, int $value = 1): bool|int diff --git a/src/cache/src/SwooleLock.php b/src/cache/src/SwooleLock.php new file mode 100644 index 000000000..d8c4146bb --- /dev/null +++ b/src/cache/src/SwooleLock.php @@ -0,0 +1,85 @@ +store->acquireLock($this->name, $this->owner, $this->seconds); + } + + /** + * Release the lock. + */ + public function release(): bool + { + return $this->store->releaseLock($this->name, $this->owner); + } + + /** + * Release this lock in disregard of ownership. + */ + public function forceRelease(): void + { + $this->store->forceReleaseLock($this->name); + } + + /** + * Return the owner value written into the driver for this lock. + */ + protected function getCurrentOwner(): ?string + { + return $this->store->getLockOwner($this->name); + } + + /** + * Refresh the lock's TTL if still owned by this process. + * + * @throws InvalidArgumentException If an explicit non-positive TTL is provided + */ + public function refresh(?int $seconds = null): bool + { + if ($seconds === null && $this->seconds <= 0) { + return true; + } + + $seconds ??= $this->seconds; + + if ($seconds <= 0) { + throw new InvalidArgumentException( + 'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.' + ); + } + + return $this->store->refreshLock($this->name, $this->owner, $seconds); + } + + /** + * Get the number of seconds until the lock expires. + */ + public function getRemainingLifetime(): ?float + { + return $this->store->getLockRemainingLifetime($this->name); + } +} diff --git a/src/cache/src/SwooleStore.php b/src/cache/src/SwooleStore.php index 9d4abf6ea..ad2a283a0 100644 --- a/src/cache/src/SwooleStore.php +++ b/src/cache/src/SwooleStore.php @@ -5,13 +5,18 @@ namespace Hypervel\Cache; use Closure; +use Hypervel\Container\Container; +use Hypervel\Contracts\Cache\CanFlushLocks; +use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Store; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Support\Carbon; use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; -use Swoole\Table; +use RuntimeException; +use Throwable; -class SwooleStore implements Store +class SwooleStore implements CanFlushLocks, LockProvider, Store { public const EVICTION_POLICY_LRU = 'lru'; @@ -23,8 +28,29 @@ class SwooleStore implements Store protected const ONE_YEAR = 31536000; + protected const USER_PREFIX = 'u:'; + + protected const INTERVAL_PREFIX = 'i:'; + + protected const INTERVAL_INDEX_PREFIX = 'x:'; + + protected const INTERVAL_INDEX_SHARDS = 64; + + /* + * This timeout must stay comfortably above normal resolver runtimes. If a + * worker crashes after claiming an interval, another process can reclaim it + * after this window instead of freezing refreshes until restart. + */ + protected const INTERVAL_REFRESH_CLAIM_TIMEOUT = 300.0; + + protected const LOCK_PREFIX = 'l:'; + + protected SwooleTable $table; + /** - * All of the registered interval caches. + * Locally registered interval cache keys. + * + * @var array */ protected array $intervals = []; @@ -32,11 +58,12 @@ class SwooleStore implements Store * Create a new Swoole store. */ public function __construct( - protected Table $table, + protected SwooleTableState $state, protected float $memoryLimitBuffer, protected string $evictionPolicy, protected float $evictionProportion ) { + $this->table = $this->state->table(); } /** @@ -44,30 +71,38 @@ public function __construct( */ public function get(string $key): mixed { - $record = $this->getRecord($key); + $tableKey = $this->userKey($key); + $record = $this->rawGet($tableKey); if (! $this->recordIsFalseOrExpired($record)) { + $this->recordHit($tableKey); + return unserialize($record['value']); } - if (in_array($key, $this->intervals) - && ! is_null($interval = $this->getInterval($key))) { - return $interval['resolver'](); + if ($this->hasLocalInterval($key)) { + $value = $this->refreshIntervalCache($this->intervalKey($key), force: true, rethrow: true); + + if ($value !== null) { + return $value; + } + + // A resolver may have stored a live null value; the locked stale-row recheck below decides whether to delete. } - $this->forget($key); + if ($record !== false) { + $this->forgetExpiredRecord($tableKey); + } return null; } /** - * Retrieve an interval item from the cache. + * Determine if the key is a local interval. */ - protected function getInterval(string $key): ?array + protected function hasLocalInterval(string $key): bool { - $interval = $this->get('interval-' . $key); - - return $interval ? unserialize($interval) : null; + return isset($this->intervals[$key]); } /** @@ -84,14 +119,16 @@ public function many(array $keys): array */ public function put(string $key, mixed $value, int $seconds): bool { - $now = $this->getCurrentTimestamp(); + $tableKey = $this->userKey($key); + $serialized = serialize($value); + $expiration = $this->expiration($seconds); - $result = $this->table->set($key, [ - 'value' => serialize($value), - 'expiration' => $now + $seconds, - ]); + $result = $this->state->withRowLock( + $tableKey, + fn (): bool => $this->rawPutSerialized($tableKey, $serialized, $expiration), + ); - $this->evictRecords(); + $this->evictRecordsIfNeeded(); return $result; } @@ -101,11 +138,13 @@ public function put(string $key, mixed $value, int $seconds): bool */ public function putMany(array $values, int $seconds): bool { + $result = true; + foreach ($values as $key => $value) { - $this->put($key, $value, $seconds); + $result = $this->put((string) $key, $value, $seconds) && $result; } - return true; + return $result; } /** @@ -113,11 +152,25 @@ public function putMany(array $values, int $seconds): bool */ public function add(string $key, mixed $value, int $seconds): bool { - if ($this->table->exists($key)) { - return false; + $tableKey = $this->userKey($key); + $serialized = serialize($value); + $expiration = $this->expiration($seconds); + + $result = $this->state->withRowLock($tableKey, function () use ($tableKey, $serialized, $expiration): bool { + $record = $this->rawGet($tableKey); + + if (! $this->recordIsFalseOrExpired($record)) { + return false; + } + + return $this->rawPutSerialized($tableKey, $serialized, $expiration); + }); + + if ($result) { + $this->evictRecordsIfNeeded(); } - return $this->put($key, $value, $seconds); + return $result; } /** @@ -125,18 +178,31 @@ public function add(string $key, mixed $value, int $seconds): bool */ public function increment(string $key, int $value = 1): int { - $record = $this->getRecord($key); + $tableKey = $this->userKey($key); + $wroteNewRecord = false; - if ($this->recordIsFalseOrExpired($record)) { - return tap($value, fn ($value) => $this->forever($key, $value)); - } + $result = $this->state->withRowLock($tableKey, function () use ($tableKey, $value, &$wroteNewRecord): int { + $record = $this->rawGet($tableKey); - return tap((int) (unserialize($record['value']) + $value), function ($value) use ($key, $record) { - $this->table->set($key, [ - 'value' => serialize($value), - 'expiration' => $record['expiration'], - ]); + if ($this->recordIsFalseOrExpired($record)) { + $wroteNewRecord = true; + $this->rawPutSerialized($tableKey, serialize($value), $this->expiration(static::ONE_YEAR)); + + return $value; + } + + $incremented = (int) (unserialize($record['value'], ['allowed_classes' => false]) + $value); + + $this->rawPutSerialized($tableKey, serialize($incremented), $record['expiration']); + + return $incremented; }); + + if ($wroteNewRecord) { + $this->evictRecordsIfNeeded(); + } + + return $result; } /** @@ -160,35 +226,67 @@ public function forever(string $key, mixed $value): bool */ public function touch(string $key, int $seconds): bool { - $record = $this->getRecord($key); + $tableKey = $this->userKey($key); - if ($this->recordIsFalseOrExpired($record)) { - return false; - } + return $this->state->withRowLock($tableKey, function () use ($tableKey, $seconds): bool { + $record = $this->rawGet($tableKey); + + if ($this->recordIsFalseOrExpired($record)) { + if ($record !== false) { + $this->rawForget($tableKey); + } - $record['expiration'] = $this->getCurrentTimestamp() + $seconds; + return false; + } - return $this->table->set($key, $record); + return $this->table->set($tableKey, [ + 'expiration' => $this->expiration($seconds), + ]); + }); } /** - * Register a cache key that should be refreshed at a given interval (in minutes). + * Register a cache key that should be refreshed at a given interval in seconds. */ public function interval(string $key, Closure $resolver, int $seconds): void { - if (! is_null($this->getInterval($key))) { - $this->intervals[] = $key; + $metadataKey = $this->intervalKey($key); + $metadata = [ + 'key' => $key, + 'metadataKey' => $metadataKey, + 'resolver' => serialize(new SerializableClosure($resolver)), + 'lastRefreshedAt' => null, + 'refreshingAt' => null, + 'refreshInterval' => $seconds, + ]; - return; + $metadataWritten = $this->state->withRowLock( + $metadataKey, + function () use ($metadataKey, $metadata): bool { + $existing = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($existing !== null) { + $metadata['lastRefreshedAt'] = $existing['lastRefreshedAt']; + $metadata['refreshingAt'] = $existing['refreshingAt']; + } + + return $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + }, + ); + + if (! $metadataWritten) { + throw new RuntimeException("Unable to register Swoole interval cache [{$key}]."); } - $this->forever('interval-' . $key, serialize([ - 'resolver' => new SerializableClosure($resolver), - 'lastRefreshedAt' => null, - 'refreshInterval' => $seconds, - ])); + try { + $this->registerIntervalIndex($metadataKey); + } catch (Throwable $e) { + $this->state->withRowLock($metadataKey, fn (): bool => $this->rawForget($metadataKey)); - $this->intervals[] = $key; + throw $e; + } + + $this->registerLocalInterval($key); } /** @@ -196,27 +294,18 @@ public function interval(string $key, Closure $resolver, int $seconds): void */ public function refreshIntervalCaches(): void { - foreach ($this->intervals as $key) { - if (! $this->intervalShouldBeRefreshed($interval = $this->getInterval($key))) { - continue; - } - - $this->forever('interval-' . $key, serialize(array_merge( - $interval, - ['lastRefreshedAt' => Carbon::now()->getTimestamp()], - ))); - - $this->forever($key, $interval['resolver']()); + foreach ($this->registeredIntervalMetadataKeys() as $metadataKey) { + $this->refreshIntervalCache($metadataKey); } } /** * Determine if the given interval record should be refreshed. */ - protected function intervalShouldBeRefreshed(array $interval): bool + protected function intervalShouldBeRefreshed(array $metadata, float $now): bool { - return is_null($interval['lastRefreshedAt']) - || (Carbon::now()->getTimestamp() - $interval['lastRefreshedAt']) >= $interval['refreshInterval']; + return is_null($metadata['lastRefreshedAt']) + || ($now - $metadata['lastRefreshedAt']) >= $metadata['refreshInterval']; } /** @@ -224,7 +313,12 @@ protected function intervalShouldBeRefreshed(array $interval): bool */ public function forget(string $key): bool { - return $this->table->del($key); + $tableKey = $this->userKey($key); + + return $this->state->withRowLock( + $tableKey, + fn (): bool => $this->rawForget($tableKey), + ); } /** @@ -232,17 +326,169 @@ public function forget(string $key): bool */ public function flush(): bool { - foreach ($this->table as $key => $record) { - if (str_starts_with($key, 'interval-')) { - continue; + return $this->state->withAllRowLocks(function (): bool { + foreach ($this->table as $tableKey => $record) { + if ($this->isControlKey($tableKey)) { + continue; + } + + $this->rawForget($tableKey); } - $this->forget($key); + return true; + }); + } + + /** + * Remove all locks from the store. + * + * @throws RuntimeException + */ + public function flushLocks(): bool + { + if (! $this->hasSeparateLockStore()) { + throw new RuntimeException('Flushing locks is only supported when the lock store is separate from the cache store.'); } + return $this->state->withAllRowLocks(function (): bool { + foreach ($this->table as $key => $record) { + if ($this->isLockKey($key)) { + $this->rawForget($key); + } + } + + return true; + }); + } + + /** + * Get a lock instance. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): SwooleLock + { + return new SwooleLock($this, $name, $seconds, $owner); + } + + /** + * Restore a lock instance using the owner identifier. + */ + public function restoreLock(string $name, string $owner): SwooleLock + { + return $this->lock($name, 0, $owner); + } + + /** + * Determine if the lock store is separate from the cache store. + */ + public function hasSeparateLockStore(): bool + { return true; } + /** + * Attempt to acquire a lock. + */ + public function acquireLock(string $name, string $owner, int $seconds): bool + { + $key = $this->lockKey($name); + $expiresAt = $seconds > 0 ? $this->expiration($seconds) : null; + + return $this->state->withRowLock($key, function () use ($key, $owner, $expiresAt): bool { + $lock = $this->rawLockRecord($key); + + if ($lock !== null && ! $this->lockIsExpired($lock)) { + return false; + } + + return $this->rawPutSerialized($key, serialize([ + 'owner' => $owner, + 'expiresAt' => $expiresAt, + ]), $this->expiration(static::ONE_YEAR)); + }); + } + + /** + * Release a lock if it is owned by the given owner. + */ + public function releaseLock(string $name, string $owner): bool + { + $key = $this->lockKey($name); + + return $this->state->withRowLock($key, function () use ($key, $owner): bool { + $lock = $this->rawLockRecord($key); + + if ($lock === null || $this->lockIsExpired($lock)) { + if ($lock !== null) { + $this->rawForget($key); + } + + return false; + } + + if ($lock['owner'] !== $owner) { + return false; + } + + return $this->rawForget($key); + }); + } + + /** + * Get the current lock owner. + */ + public function getLockOwner(string $name): ?string + { + $lock = $this->rawLockRecord($this->lockKey($name)); + + return $lock !== null && ! $this->lockIsExpired($lock) + ? $lock['owner'] + : null; + } + + /** + * Refresh a lock's TTL. + */ + public function refreshLock(string $name, string $owner, int $seconds): bool + { + $key = $this->lockKey($name); + + return $this->state->withRowLock($key, function () use ($key, $owner, $seconds): bool { + $lock = $this->rawLockRecord($key); + + if ($lock === null || $this->lockIsExpired($lock) || $lock['owner'] !== $owner) { + return false; + } + + $lock['expiresAt'] = $this->expiration($seconds); + + return $this->rawPutSerialized($key, serialize($lock), $this->expiration(static::ONE_YEAR)); + }); + } + + /** + * Get the remaining lock lifetime in seconds. + */ + public function getLockRemainingLifetime(string $name): ?float + { + $lock = $this->rawLockRecord($this->lockKey($name)); + + if ($lock === null || $lock['expiresAt'] === null || $this->lockIsExpired($lock)) { + return null; + } + + return max(0.0, $lock['expiresAt'] - $this->getCurrentTimestamp()); + } + + /** + * Force a lock to release. + */ + public function forceReleaseLock(string $name): void + { + $key = $this->lockKey($name); + + $this->state->withRowLock($key, fn () => $this->rawForget($key)); + } + /** * Determine if the record is missing or expired. */ @@ -266,28 +512,27 @@ public function evictRecords(): void { $this->flushStaleRecords(); + if ($this->evictionPolicy === static::EVICTION_POLICY_NOEVICTION) { + return; + } + while ($this->memoryLimitIsReached()) { - $this->removeRecordsByEvictionPolicy(); + if ($this->removeRecordsByEvictionPolicy() === 0) { + return; + } } } /** - * Retrieve an record from the table and write used info by key. + * Evict records if the table is near its memory limit. */ - protected function getRecord(string $key): array|false + protected function evictRecordsIfNeeded(): void { - $record = $this->table->get($key); - - if (! $record) { - return false; + if (! $this->memoryLimitIsReached()) { + return; } - $record['last_used_at'] = $this->getCurrentTimestamp(); - $record['used_count'] = ($record['used_count'] ?? 0) + 1; - - $this->table->set($key, $record); - - return $record; + $this->evictRecords(); } /** @@ -295,7 +540,9 @@ protected function getRecord(string $key): array|false */ protected function getCurrentTimestamp(): float { - return Carbon::now()->getPreciseTimestamp(6) / 1000000; + return Carbon::hasTestNow() + ? Carbon::now()->getPreciseTimestamp(6) / 1000000 + : microtime(true); } /** @@ -311,46 +558,65 @@ protected function memoryLimitIsReached(): bool return $conflictRate > $allowedMemoryUsage || $memoryUsage > $allowedMemoryUsage; } - protected function removeRecordsByEvictionPolicy() + /** + * Remove records by the configured eviction policy. + */ + protected function removeRecordsByEvictionPolicy(): int { if ($this->evictionPolicy === static::EVICTION_POLICY_NOEVICTION) { - return; + return 0; } if ($this->evictionPolicy === static::EVICTION_POLICY_LRU) { - return $this->removeRecordsByLRU(); // @phpstan-ignore method.void + return $this->removeRecordsByLRU(); } if ($this->evictionPolicy === static::EVICTION_POLICY_LFU) { - return $this->removeRecordsByLFU(); // @phpstan-ignore method.void + return $this->removeRecordsByLFU(); } if ($this->evictionPolicy === static::EVICTION_POLICY_TTL) { - return $this->removeRecordsByTTL(); // @phpstan-ignore method.void + return $this->removeRecordsByTTL(); } throw new InvalidArgumentException("Eviction policy [{$this->evictionPolicy}] is not supported."); } - protected function removeRecordsByLRU(): void + /** + * Remove records by least recently used. + */ + protected function removeRecordsByLRU(): int { - $this->handleRecordsEviction('last_used_at'); + return $this->handleRecordsEviction('last_used_at'); } - protected function removeRecordsByLFU(): void + /** + * Remove records by least frequently used. + */ + protected function removeRecordsByLFU(): int { - $this->handleRecordsEviction('used_count'); + return $this->handleRecordsEviction('used_count'); } - protected function removeRecordsByTTL(): void + /** + * Remove records by TTL. + */ + protected function removeRecordsByTTL(): int { - $this->handleRecordsEviction('expiration'); + return $this->handleRecordsEviction('expiration'); } - protected function handleRecordsEviction(string $column): void + /** + * Handle records eviction. + */ + protected function handleRecordsEviction(string $column): int { $quantity = (int) round($this->table->getSize() * $this->evictionProportion); + if ($quantity <= 0) { + return 0; + } + $heap = new class($quantity) extends LimitedMaxHeap { protected function compare($left, $right): int { @@ -359,30 +625,522 @@ protected function compare($left, $right): int }; foreach ($this->table as $key => $record) { - $value = $record[$column]; + if ($this->isControlKey($key)) { + continue; + } - $heap->insert(compact('key', 'value')); + $heap->insert([ + 'key' => $key, + 'value' => $record[$column], + 'fingerprint' => $this->evictionFingerprint($record), + ]); } + $deleted = 0; + while (! $heap->isEmpty()) { - $this->forget($heap->extract()['key']); + $candidate = $heap->extract(); + + if ($this->forgetEvictionCandidate($candidate['key'], $candidate['fingerprint'])) { + ++$deleted; + } } + + return $deleted; + } + + /** + * Get the compact eviction fingerprint for a raw table record. + * + * @return array{value_hash: string, expiration: float, last_used_at: float, used_count: int} + */ + protected function evictionFingerprint(array $record): array + { + return [ + 'value_hash' => hash('xxh128', $record['value']), + 'expiration' => $record['expiration'], + 'last_used_at' => $record['last_used_at'], + 'used_count' => $record['used_count'], + ]; } + /** + * Flush stale records. + */ protected function flushStaleRecords(): void { $now = $this->getCurrentTimestamp(); - - $keys = []; + $tableKeys = []; + $lockKeys = []; foreach ($this->table as $key => $row) { - if ($row['expiration'] < $now) { - $keys[] = $key; + if ($this->isLockKey($key)) { + if ($this->rawLockPayloadIsExpired($row)) { + $lockKeys[] = $key; + } + + continue; + } + + if ($this->isControlKey($key)) { + continue; + } + + if ($row['expiration'] <= $now) { + $tableKeys[] = $key; } } - foreach ($keys as $key) { - $this->forget($key); + foreach ($tableKeys as $key) { + $this->forgetExpiredRecord($key); } + + foreach ($lockKeys as $key) { + $this->forgetExpiredLockRecord($key); + } + } + + /** + * Record a cache hit. + */ + protected function recordHit(string $key): void + { + // Hit metadata stays lock-free for the read hot path. If a concurrent delete wins, + // Swoole can create an expired shell row that stale cleanup later prunes. + if ($this->evictionPolicy === static::EVICTION_POLICY_LRU) { + $this->table->set($key, ['last_used_at' => $this->getCurrentTimestamp()]); + + return; + } + + if ($this->evictionPolicy === static::EVICTION_POLICY_LFU) { + $this->table->incr($key, 'used_count', 1); + } + } + + /** + * Forget an expired record by table key. + */ + protected function forgetExpiredRecord(string $key): void + { + $this->state->withRowLock($key, function () use ($key): void { + $record = $this->rawGet($key); + + if ($this->recordIsFalseOrExpired($record)) { + $this->rawForget($key); + } + }); + } + + /** + * Forget an eviction candidate by table key. + */ + protected function forgetEvictionCandidate(string $key, array $fingerprint): bool + { + return $this->state->withRowLock($key, function () use ($key, $fingerprint): bool { + $record = $this->rawGet($key); + + if ($record === false || $this->evictionFingerprint($record) !== $fingerprint) { + return false; + } + + return $this->rawForget($key); + }); + } + + /** + * Forget an expired lock record. + */ + protected function forgetExpiredLockRecord(string $key): void + { + $this->state->withRowLock($key, function () use ($key): void { + $record = $this->rawGet($key); + + if ($record !== false && $this->rawLockPayloadIsExpired($record)) { + $this->rawForget($key); + } + }); + } + + /** + * Get a raw table record. + */ + protected function rawGet(string $key): array|false + { + return $this->table->get($key); + } + + /** + * Store a serialized raw table record. + */ + protected function rawPutSerialized(string $key, string $serialized, float $expiration): bool + { + return $this->table->set($key, [ + 'value' => $serialized, + 'expiration' => $expiration, + ]); + } + + /** + * Forget a raw table record. + */ + protected function rawForget(string $key): bool + { + return $this->table->del($key); + } + + /** + * Register a local interval key. + */ + protected function registerLocalInterval(string $key): void + { + $this->intervals[$key] = true; + } + + /** + * Register an interval metadata key in the shared index. + */ + protected function registerIntervalIndex(string $metadataKey): void + { + $indexKey = $this->intervalIndexKey($metadataKey); + + $result = $this->state->withRowLock($indexKey, function () use ($indexKey, $metadataKey): bool { + $record = $this->rawGet($indexKey); + $index = $this->recordIsFalseOrExpired($record) ? [] : unserialize($record['value']); + + $index[$metadataKey] = true; + + return $this->rawPutSerialized($indexKey, serialize($index), $this->expiration(static::ONE_YEAR)); + }); + + if (! $result) { + throw new RuntimeException("Unable to register Swoole interval index row [{$indexKey}]."); + } + } + + /** + * Get registered interval metadata keys. + */ + protected function registeredIntervalMetadataKeys(): array + { + $metadataKeys = []; + + for ($i = 0; $i < static::INTERVAL_INDEX_SHARDS; ++$i) { + $indexKey = static::INTERVAL_INDEX_PREFIX . $i; + $record = $this->rawGet($indexKey); + + if ($this->recordIsFalseOrExpired($record)) { + continue; + } + + foreach (array_keys(unserialize($record['value'])) as $metadataKey) { + $metadataKeys[$metadataKey] = true; + } + + $this->touchInternalRow($indexKey); + } + + return array_keys($metadataKeys); + } + + /** + * Refresh a single interval cache. + */ + protected function refreshIntervalCache(string $metadataKey, bool $force = false, bool $rethrow = false): mixed + { + $claimedAt = null; + + try { + $now = $this->getCurrentTimestamp(); + + $claim = $this->state->withRowLock($metadataKey, function () use ($metadataKey, $now, $force): ?array { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($metadata === null) { + return null; + } + + if (! $force && ! $this->intervalShouldBeRefreshed($metadata, $now)) { + return null; + } + + if ($metadata['refreshingAt'] !== null + && ! $this->intervalClaimIsStale($metadata['refreshingAt'], $now, $metadata['refreshInterval'])) { + return null; + } + + $metadata['refreshingAt'] = $now; + + if (! $this->putIntervalMetadataByInternalKey($metadataKey, $metadata)) { + throw new RuntimeException("Unable to claim Swoole interval cache [{$metadata['key']}]."); + } + + return [$metadata, $now]; + }); + + if ($claim === null) { + return null; + } + + [$metadata, $claimedAt] = $claim; + + /** @var SerializableClosure $resolver */ + $resolver = unserialize($metadata['resolver']); + $value = $resolver(); + + $commit = $this->prepareIntervalRefreshCommit($metadataKey, $claimedAt); + + if ($commit === null) { + // Another refresher owns this interval now; do not write or clear its claim. + return null; + } + + [$metadata, $claimedAt] = $commit; + + // Keep the public write outside the metadata lock: it can serialize user values, + // lock the user row, and trigger eviction, while the claim timeout floor remains much larger. + if (! $this->forever($metadata['key'], $value)) { + throw new RuntimeException("Unable to refresh Swoole interval cache [{$metadata['key']}]."); + } + + $this->completeIntervalRefresh($metadataKey, $claimedAt); + + return $value; + } catch (Throwable $e) { + if ($claimedAt !== null) { + $this->clearIntervalClaim($metadataKey, $claimedAt); + } + + if ($rethrow) { + throw $e; + } + + $this->reportIntervalException($e); + + return null; + } + } + + /** + * Prepare a claimed interval refresh for public value commit. + * + * @return null|array{0: array, 1: float} + */ + protected function prepareIntervalRefreshCommit(string $metadataKey, float $claimedAt): ?array + { + return $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt): ?array { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($metadata === null || $metadata['refreshingAt'] !== $claimedAt) { + return null; + } + + $commitClaimedAt = $this->getCurrentTimestamp(); + // Restamp ownership so failed public writes clear only this refresher's current claim. + $metadata['refreshingAt'] = $commitClaimedAt; + + if (! $this->putIntervalMetadataByInternalKey($metadataKey, $metadata)) { + throw new RuntimeException("Unable to prepare Swoole interval cache refresh [{$metadata['key']}]."); + } + + return [$metadata, $commitClaimedAt]; + }); + } + + /** + * Complete an interval refresh. + */ + protected function completeIntervalRefresh(string $metadataKey, float $claimedAt): void + { + $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt): void { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($metadata === null || $metadata['refreshingAt'] !== $claimedAt) { + return; + } + + $metadata['lastRefreshedAt'] = $claimedAt; + $metadata['refreshingAt'] = null; + + if (! $this->putIntervalMetadataByInternalKey($metadataKey, $metadata)) { + throw new RuntimeException("Unable to complete Swoole interval cache refresh [{$metadata['key']}]."); + } + }); + } + + /** + * Clear an interval refresh claim. + */ + protected function clearIntervalClaim(string $metadataKey, float $claimedAt): void + { + $this->state->withRowLock($metadataKey, function () use ($metadataKey, $claimedAt): void { + $metadata = $this->getIntervalMetadataByInternalKey($metadataKey); + + if ($metadata === null || $metadata['refreshingAt'] !== $claimedAt) { + return; + } + + $metadata['refreshingAt'] = null; + + $this->putIntervalMetadataByInternalKey($metadataKey, $metadata); + }); + } + + /** + * Determine if an interval refresh claim is stale. + */ + protected function intervalClaimIsStale(float $refreshingAt, float $now, int $refreshInterval): bool + { + $timeout = max(static::INTERVAL_REFRESH_CLAIM_TIMEOUT, $refreshInterval * 2); + + return ($now - $refreshingAt) >= $timeout; + } + + /** + * Get interval metadata by internal key. + */ + protected function getIntervalMetadataByInternalKey(string $metadataKey): ?array + { + $record = $this->rawGet($metadataKey); + + return $this->recordIsFalseOrExpired($record) + ? null + : unserialize($record['value']); + } + + /** + * Store interval metadata by internal key. + */ + protected function putIntervalMetadataByInternalKey(string $metadataKey, array $metadata): bool + { + return $this->rawPutSerialized($metadataKey, serialize($metadata), $this->expiration(static::ONE_YEAR)); + } + + /** + * Touch an internal row. + */ + protected function touchInternalRow(string $key): void + { + $this->state->withRowLock($key, function () use ($key): void { + if ($this->rawGet($key) !== false) { + $this->table->set($key, ['expiration' => $this->expiration(static::ONE_YEAR)]); + } + }); + } + + /** + * Report an interval refresh exception. + */ + protected function reportIntervalException(Throwable $e): void + { + $container = Container::getInstance(); + + if ($container->bound(ExceptionHandler::class)) { + $container->make(ExceptionHandler::class)->report($e); + + return; + } + + file_put_contents('php://stderr', (string) $e . PHP_EOL); + } + + /** + * Get the expiration timestamp for a TTL. + */ + protected function expiration(int $seconds): float + { + return $this->getCurrentTimestamp() + $seconds; + } + + /** + * Get a raw lock record. + * + * @return null|array{owner: string, expiresAt: ?float} + */ + protected function rawLockRecord(string $key): ?array + { + $record = $this->rawGet($key); + + return $record === false ? null : unserialize($record['value']); + } + + /** + * Determine if a lock is expired. + * + * @param array{expiresAt: ?float} $lock + */ + protected function lockIsExpired(array $lock): bool + { + return $lock['expiresAt'] !== null && $lock['expiresAt'] <= $this->getCurrentTimestamp(); + } + + /** + * Determine if a raw lock payload is expired. + */ + protected function rawLockPayloadIsExpired(array $row): bool + { + $lock = unserialize($row['value']); + + return $this->lockIsExpired($lock); + } + + /** + * Get the table key for a user cache key. + */ + protected function userKey(string $key): string + { + return $this->hashedTableKey(static::USER_PREFIX, $key); + } + + /** + * Get the table key for an interval cache key. + */ + protected function intervalKey(string $key): string + { + return $this->hashedTableKey(static::INTERVAL_PREFIX, $key); + } + + /** + * Get the interval index shard key. + */ + protected function intervalIndexKey(string $metadataKey): string + { + return static::INTERVAL_INDEX_PREFIX . (crc32($metadataKey) % static::INTERVAL_INDEX_SHARDS); + } + + /** + * Get the table key for a lock name. + */ + protected function lockKey(string $name): string + { + return $this->hashedTableKey(static::LOCK_PREFIX, $name); + } + + /** + * Get a hashed table key. + */ + protected function hashedTableKey(string $prefix, string $key): string + { + return $prefix . hash('xxh128', $key, false, [ + 'seed' => $this->state->hashSeed(), + ]); + } + + /** + * Determine if the table key is a control key. + */ + protected function isControlKey(string $key): bool + { + return str_starts_with($key, static::INTERVAL_PREFIX) + || str_starts_with($key, static::INTERVAL_INDEX_PREFIX) + || $this->isLockKey($key); + } + + /** + * Determine if the table key is a lock key. + */ + protected function isLockKey(string $key): bool + { + return str_starts_with($key, static::LOCK_PREFIX); } } diff --git a/src/cache/src/SwooleTable.php b/src/cache/src/SwooleTable.php index 45d9048e5..f88fd2c56 100644 --- a/src/cache/src/SwooleTable.php +++ b/src/cache/src/SwooleTable.php @@ -5,7 +5,6 @@ namespace Hypervel\Cache; use Hypervel\Cache\Exceptions\ValueTooLargeForColumnException; -use Hypervel\Support\Arr; use Swoole\Table; class SwooleTable extends Table @@ -30,33 +29,30 @@ public function column(string $name, int $type, int $size = 0): bool */ public function set(string $key, array $values): bool { - collect($values) - ->each($this->ensureColumnsSize()); + foreach ($values as $column => $value) { + if (! isset($this->columns[$column])) { + continue; + } - return parent::set($key, $values); - } + [$type, $size] = $this->columns[$column]; - /** - * Ensures the given column value is within the given size. - */ - protected function ensureColumnsSize() - { - return function ($value, $column) { - if (! Arr::has($this->columns, $column)) { - return; + if ($type !== Table::TYPE_STRING) { + continue; } - [$type, $size] = $this->columns[$column]; + $length = strlen($value); - if ($type == Table::TYPE_STRING && strlen($value) > $size) { + if ($length > $size) { throw new ValueTooLargeForColumnException(sprintf( 'Value [%s...] is too large for [%s] column. Should be less than %d characters but got %d characters.', substr($value, 0, 20), $column, $size, - strlen($value) + $length )); } - }; + } + + return parent::set($key, $values); } } diff --git a/src/cache/src/SwooleTableManager.php b/src/cache/src/SwooleTableManager.php index 571645494..662ea1f2b 100644 --- a/src/cache/src/SwooleTableManager.php +++ b/src/cache/src/SwooleTableManager.php @@ -10,14 +10,33 @@ class SwooleTableManager { - protected array $tables = []; + /** + * The resolved Swoole table states. + * + * @var array + */ + protected array $states = []; public function __construct( protected Container $app ) { } - public function createTable(int $rows, int $bytes, float $conflictProportion): Table + /** + * Create a Swoole table state. + */ + public function createState(int $rows, int $bytes, float $conflictProportion, int $hashSeed = 0): SwooleTableState + { + return new SwooleTableState( + $this->createTable($rows, $bytes, $conflictProportion), + $hashSeed + ); + } + + /** + * Create a Swoole table. + */ + public function createTable(int $rows, int $bytes, float $conflictProportion): SwooleTable { $table = new SwooleTable($rows, $conflictProportion); @@ -31,12 +50,18 @@ public function createTable(int $rows, int $bytes, float $conflictProportion): T return $table; } - public function get(string $name): Table + /** + * Get a Swoole table state by name. + */ + public function get(string $name): SwooleTableState { - return $this->tables[$name] ??= $this->resolve($name); + return $this->states[$name] ??= $this->resolve($name); } - protected function resolve(string $name): Table + /** + * Resolve a Swoole table state by name. + */ + protected function resolve(string $name): SwooleTableState { $config = $this->getConfig($name); @@ -44,13 +69,16 @@ protected function resolve(string $name): Table throw new InvalidArgumentException("Swoole table [{$name}] is not defined."); } - return $this->createTable( + return $this->createState( $config['rows'] ?? 1024, $config['bytes'] ?? 10240, $config['conflict_proportion'] ?? 0.2 ); } + /** + * Get the Swoole table configuration. + */ protected function getConfig(string $name): ?array { if ($name !== 'null') { diff --git a/src/cache/src/SwooleTableState.php b/src/cache/src/SwooleTableState.php new file mode 100644 index 000000000..334b135e6 --- /dev/null +++ b/src/cache/src/SwooleTableState.php @@ -0,0 +1,142 @@ + + */ + protected array $rowLocks; + + /** + * Create a new Swoole table state instance. + */ + public function __construct( + protected SwooleTable $table, + protected int $hashSeed = 0, + ) { + $this->hashSeed = $hashSeed ?: random_int(1, PHP_INT_MAX); + + $this->rowLocks = array_map( + fn () => new Atomic(0), + range(0, self::STRIPE_COUNT - 1), + ); + } + + /** + * Get the Swoole table. + */ + public function table(): SwooleTable + { + return $this->table; + } + + /** + * Get the hash seed. + */ + public function hashSeed(): int + { + return $this->hashSeed; + } + + /** + * Run the callback while holding the row lock for the given table key. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withRowLock(string $key, callable $callback): mixed + { + $lock = $this->lockFor($key); + $this->acquire($lock); + + try { + return $callback(); + } finally { + $this->release($lock); + } + } + + /** + * Run the callback while holding every row-lock stripe. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withAllRowLocks(callable $callback): mixed + { + $acquired = []; + + foreach ($this->rowLocks as $lock) { + $this->acquire($lock); + $acquired[] = $lock; + } + + try { + return $callback(); + } finally { + while ($lock = array_pop($acquired)) { + $this->release($lock); + } + } + } + + /** + * Get the striped lock for a table key. + */ + protected function lockFor(string $key): Atomic + { + return $this->rowLocks[crc32($key) % self::STRIPE_COUNT]; + } + + /** + * Acquire a striped lock. + */ + protected function acquire(Atomic $lock): void + { + $spins = 0; + + while (! $lock->cmpset(0, 1)) { + // Critical sections must stay short, non-yielding, and fatal-free so finally can release the stripe. + // A hard process death while holding a stripe leaves it locked until the Swoole table state is recreated. + if (++$spins >= self::SPINS_BEFORE_BACKOFF) { + // Stay hot for short waits, then yield with raw usleep; lock internals must not use fakeable sleep. + $spins = 0; + usleep(1); + } + } + } + + /** + * Release a striped lock. + */ + protected function release(Atomic $lock): void + { + $lock->cmpset(1, 0); + } +} diff --git a/src/cache/src/SwooleTimer.php b/src/cache/src/SwooleTimer.php new file mode 100644 index 000000000..a6789cc2c --- /dev/null +++ b/src/cache/src/SwooleTimer.php @@ -0,0 +1,19 @@ +putManyForever($values); } - $manyResult = null; + $result = true; foreach ($values as $key => $value) { - $result = $this->put((string) $key, $value, $ttl); - - $manyResult = is_null($manyResult) ? $result : $result && $manyResult; + $result = $this->put((string) $key, $value, $ttl) && $result; } - return $manyResult ?: false; + return $result; } /** diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php index 5cf13fe02..d48258fcb 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -79,6 +79,7 @@ 'eviction_policy' => SwooleStore::EVICTION_POLICY_LRU, 'eviction_proportion' => 0.05, 'eviction_interval' => 10000, // milliseconds + 'interval_refresh_interval' => 1000, // milliseconds ], 'stack' => [ diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index feea41668..b44ac1444 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -201,6 +201,37 @@ public function testManyItemsCanBeStoredAtOnce() $this->assertTrue($result); } + public function testPutManyReturnsTrueForEmptyInputWithoutUpserting(): void + { + [$store, $table] = $this->getStore(); + + $table->shouldNotReceive('upsert'); + + $this->assertTrue($store->putMany([], 10)); + } + + public function testPutManyReturnsTrueWhenUpsertAffectsNoRows(): void + { + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); + [$table] = $this->mockTable($store); + + $store->expects($this->once())->method('getTime')->willReturn(1); + $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturn(0); + + $this->assertTrue($store->putMany(['foo' => 'bar'], 60)); + } + + public function testPutReturnsTrueWhenDelegatedUpsertAffectsNoRows(): void + { + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); + [$table] = $this->mockTable($store); + + $store->expects($this->once())->method('getTime')->willReturn(1); + $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturn(0); + + $this->assertTrue($store->put('foo', 'bar', 60)); + } + public function testAddOnlyAddsIfKeyDoesntExist() { [$store, $table] = $this->getStore(); diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php index 15acfce37..0167cd593 100644 --- a/tests/Cache/CacheMemoizedStoreTest.php +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -88,6 +88,28 @@ public function testPlainFlexibleTreatsCachedSentinelAsHitThroughMemoizedStore() $this->assertFalse($invoked); } + public function testPutManyWithEmptyInputReturnsDelegatedRepositoryResult(): void + { + $repository = m::mock(Repository::class); + $repository->shouldReceive('putMany')->once()->with([], 60)->andReturn(false); + + $store = new MemoizedStore('memoized', $repository); + + $this->assertFalse($store->putMany([], 60)); + } + + public function testPutManyInvalidatesMemoizedValues(): void + { + $repository = new Repository(new ArrayStore); + $store = new MemoizedStore('memoized', $repository); + + $store->put('foo', 'old', 60); + + $this->assertSame('old', $store->get('foo')); + $this->assertTrue($store->putMany(['foo' => 'new'], 60)); + $this->assertSame('new', $store->get('foo')); + } + public function testMemoizedStoreCanWrapStackStore(): void { $stackRepo = $this->createStackRepository(); diff --git a/tests/Cache/CacheRetrievesMultipleKeysTest.php b/tests/Cache/CacheRetrievesMultipleKeysTest.php new file mode 100644 index 000000000..d417f0e51 --- /dev/null +++ b/tests/Cache/CacheRetrievesMultipleKeysTest.php @@ -0,0 +1,63 @@ +assertTrue($store->putMany([], 60)); + $this->assertSame([], $store->calls); + } + + public function testPutManyReturnsFalseWhenAnyWriteFailsAndAttemptsEveryValue(): void + { + $store = new RetrievesMultipleKeysPutManyProbe(['fail']); + + $this->assertFalse($store->putMany([ + 'first' => 'one', + 'fail' => 'two', + 'after' => 'three', + ], 60)); + $this->assertSame(['first', 'fail', 'after'], $store->calls); + } + + public function testPutManyReturnsTrueWhenEveryWriteSucceeds(): void + { + $store = new RetrievesMultipleKeysPutManyProbe; + + $this->assertTrue($store->putMany([ + 'first' => 'one', + 'second' => 'two', + ], 60)); + $this->assertSame(['first', 'second'], $store->calls); + } +} + +class RetrievesMultipleKeysPutManyProbe +{ + use RetrievesMultipleKeys; + + /** + * @var list + */ + public array $calls = []; + + public function __construct(private array $failures = []) + { + } + + public function put(string $key, mixed $value, int $seconds): bool + { + $this->calls[] = $key; + + return ! in_array($key, $this->failures, true); + } +} diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php index 8424b0684..3e13324f1 100644 --- a/tests/Cache/CacheStackStoreTest.php +++ b/tests/Cache/CacheStackStoreTest.php @@ -191,7 +191,35 @@ public function testPutMany() $this->swoole->shouldReceive('put')->once()->with('bar', ['value' => 'baz', 'expiration' => $expiration], $ttl)->andReturn(true); $this->redis->shouldReceive('put')->once()->with('bar', ['value' => 'baz', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->store->putMany(['foo' => 'bar', 'bar' => 'baz'], $ttl); + $this->assertTrue($this->store->putMany(['foo' => 'bar', 'bar' => 'baz'], $ttl)); + } + + public function testPutManyReturnsTrueForEmptyInput() + { + $this->createStores(); + + $this->assertTrue($this->store->putMany([], 100)); + } + + public function testPutManyReturnsFalseForFailedKeyAndAttemptsLaterKeys() + { + $this->createStores(); + + $ttl = 100; + $expiration = Carbon::now()->getTimestamp() + $ttl; + + $this->swoole->shouldReceive('put')->once()->with('first', ['value' => 'one', 'expiration' => $expiration], $ttl)->andReturn(true); + $this->redis->shouldReceive('put')->once()->with('first', ['value' => 'one', 'expiration' => $expiration], $ttl)->andReturn(true); + $this->swoole->shouldReceive('put')->once()->with('fail', ['value' => 'two', 'expiration' => $expiration], $ttl)->andReturn(false); + $this->redis->shouldNotReceive('put')->with('fail', m::any(), m::any()); + $this->swoole->shouldReceive('put')->once()->with('after', ['value' => 'three', 'expiration' => $expiration], $ttl)->andReturn(true); + $this->redis->shouldReceive('put')->once()->with('after', ['value' => 'three', 'expiration' => $expiration], $ttl)->andReturn(true); + + $this->assertFalse($this->store->putMany([ + 'first' => 'one', + 'fail' => 'two', + 'after' => 'three', + ], $ttl)); } public function testIncrement() diff --git a/tests/Cache/CacheSwooleStoreConcurrencyTest.php b/tests/Cache/CacheSwooleStoreConcurrencyTest.php new file mode 100644 index 000000000..876ded211 --- /dev/null +++ b/tests/Cache/CacheSwooleStoreConcurrencyTest.php @@ -0,0 +1,198 @@ +createState(); + + $results = $this->runConcurrentProcesses($state, 16, function (int $id, SwooleStore $store): array { + return [ + 'id' => $id, + 'won' => $store->add('key', $id, 60), + ]; + }); + + $winners = array_values(array_filter($results, fn (array $result): bool => $result['won'])); + + $this->assertCount(1, $winners); + $this->assertSame($winners[0]['id'], $this->createStore($state)->get('key')); + } + + public function testConcurrentAddForExpiredPhysicalRowHasExactlyOneWinner(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $state->table()->set($this->tableKey($store, 'userKey', 'key'), [ + 'value' => serialize('expired'), + 'expiration' => time() - 100, + ]); + + $results = $this->runConcurrentProcesses($state, 16, function (int $id, SwooleStore $store): array { + return [ + 'id' => $id, + 'won' => $store->add('key', $id, 60), + ]; + }); + + $winners = array_values(array_filter($results, fn (array $result): bool => $result['won'])); + + $this->assertCount(1, $winners); + $this->assertSame($winners[0]['id'], $store->get('key')); + } + + public function testConcurrentIncrementDoesNotLoseUpdates(): void + { + $state = $this->createState(); + $processes = 8; + $incrementsPerProcess = 100; + + $this->runConcurrentProcesses( + $state, + $processes, + function (int $id, SwooleStore $store) use ($incrementsPerProcess): bool { + for ($i = 0; $i < $incrementsPerProcess; ++$i) { + $store->increment('counter'); + } + + return true; + } + ); + + $this->assertSame($processes * $incrementsPerProcess, $this->createStore($state)->get('counter')); + } + + public function testConcurrentLockAcquireHasExactlyOneWinner(): void + { + $state = $this->createState(); + + $results = $this->runConcurrentProcesses($state, 16, function (int $id, SwooleStore $store): array { + return [ + 'id' => $id, + 'won' => $store->lock('lock', 60, "owner-{$id}")->acquire(), + ]; + }); + + $this->assertCount(1, array_filter($results, fn (array $result): bool => $result['won'])); + } + + /** + * Run callbacks in forked processes sharing one pre-created Swoole table state. + */ + private function runConcurrentProcesses(SwooleTableState $state, int $count, callable $callback): array + { + $ready = new Atomic(0); + $start = new Atomic(0); + $processes = []; + + for ($i = 0; $i < $count; ++$i) { + $processes[] = new Process(function (Process $process) use ($state, $ready, $start, $callback, $i): void { + $store = $this->createStore($state); + + $ready->add(1); + + while ($start->get() === 0) { + usleep(100); + } + + try { + $process->write(serialize([ + 'ok' => true, + 'result' => $callback($i, $store, $state), + ])); + } catch (Throwable $exception) { + $process->write(serialize([ + 'ok' => false, + 'error' => $exception::class . ': ' . $exception->getMessage(), + ])); + } + + // The fork inherits PHPUnit/Testbench shutdown handlers from the parent process. + // Exiting normally would let a child delete the parent's disposable runtime app. + posix_kill(getmypid(), SIGKILL); + }, false, SOCK_STREAM); + } + + foreach ($processes as $process) { + $process->start(); + } + + $this->waitForReadyProcesses($ready, $count); + + $start->set(1); + + $results = []; + + foreach ($processes as $process) { + $message = $process->read(); + + $this->assertIsString($message); + + $payload = unserialize($message); + + $this->assertIsArray($payload); + $this->assertTrue($payload['ok'], $payload['error'] ?? 'Child process failed.'); + + $results[] = $payload['result']; + } + + for ($i = 0; $i < $count; ++$i) { + Process::wait(); + } + + return $results; + } + + /** + * Wait until every child process reaches the start barrier. + */ + private function waitForReadyProcesses(Atomic $ready, int $count): void + { + $deadline = microtime(true) + 5; + + while ($ready->get() < $count) { + if (microtime(true) > $deadline) { + $this->fail("Only {$ready->get()} of {$count} child processes reached the start barrier."); + } + + usleep(100); + } + } + + private function createState(): SwooleTableState + { + return (new SwooleTableManager(m::mock(Container::class))) + ->createState(128, 10240, 0.2, 12345); + } + + private function createStore(SwooleTableState $state): SwooleStore + { + return new SwooleStore($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + } + + private function tableKey(SwooleStore $store, string $method, string $key): string + { + $reflection = new ReflectionMethod($store, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($store, $key); + } +} diff --git a/tests/Cache/CacheSwooleStoreIntervalTest.php b/tests/Cache/CacheSwooleStoreIntervalTest.php new file mode 100644 index 000000000..e22f26029 --- /dev/null +++ b/tests/Cache/CacheSwooleStoreIntervalTest.php @@ -0,0 +1,955 @@ +tempDir = ParallelTesting::tempDir('CacheSwooleStoreIntervalTest'); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + IntervalLostClaimProbe::reset(); + IntervalReentryProbe::reset(); + + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + public function testIntervalRegistersMetadataAndSharedIndex(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $store->interval('foo', fn () => 'bar', 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + $indexKey = $this->indexKey($store, $metadataKey); + $metadata = $this->metadata($state, $metadataKey); + $index = $this->index($state, $indexKey); + + $this->assertFalse($state->table()->get('interval-foo')); + $this->assertSame('foo', $metadata['key']); + $this->assertSame($metadataKey, $metadata['metadataKey']); + $this->assertIsString($metadata['resolver']); + $this->assertInstanceOf(SerializableClosure::class, unserialize($metadata['resolver'])); + $this->assertNull($metadata['lastRefreshedAt']); + $this->assertNull($metadata['refreshingAt']); + $this->assertSame(5, $metadata['refreshInterval']); + $this->assertSame([$metadataKey => true], $index); + } + + public function testIntervalMetadataSerializationDoesNotRunCapturedObjectMagicInsideRowLock(): void + { + $state = $this->createState(); + $store = new InstrumentedIntervalSwooleStore($state); + IntervalMetadataSerializationProbe::reset(); + $probe = new IntervalMetadataSerializationProbe; + + $store->interval('foo', function () use ($probe) { + return $probe->value(); + }, 5); + + $metadata = $this->metadata($state, $this->metadataKey($store, 'foo')); + + $this->assertIsString($metadata['resolver']); + $this->assertSame(0, IntervalMetadataSerializationProbe::$insideSleeps); + $this->assertSame(0, IntervalMetadataSerializationProbe::$insideWakeups); + $this->assertSame(1, IntervalMetadataSerializationProbe::$outsideSleeps); + $this->assertSame(0, IntervalMetadataSerializationProbe::$outsideWakeups); + + $store->refreshIntervalCaches(); + + $this->assertSame('bar', $store->get('foo')); + $this->assertSame(0, IntervalMetadataSerializationProbe::$insideSleeps); + $this->assertSame(0, IntervalMetadataSerializationProbe::$insideWakeups); + $this->assertSame(1, IntervalMetadataSerializationProbe::$outsideWakeups); + } + + public function testIntervalRegistrationIsIdempotentForLocalAndSharedIndexes(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $store->interval('foo', fn () => 'first', 5); + $store->interval('foo', fn () => 'second', 10); + + $metadataKey = $this->metadataKey($store, 'foo'); + $index = $this->index($state, $this->indexKey($store, $metadataKey)); + + $this->assertSame([$metadataKey => true], $index); + $this->assertSame(['foo' => true], $this->localIntervals($store)); + $this->assertSame(10, $this->metadata($state, $metadataKey)['refreshInterval']); + } + + public function testIntervalReregistrationPreservesLastRefreshTimestamp(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + IntervalResolverState::$attempts = 0; + + $store->interval('foo', function () { + return ++IntervalResolverState::$attempts; + }, 5); + $store->refreshIntervalCaches(); + $this->assertSame(1, $store->get('foo')); + + $metadataKey = $this->metadataKey($store, 'foo'); + $lastRefreshedAt = $this->metadata($state, $metadataKey)['lastRefreshedAt']; + + Carbon::setTestNow('2000-01-01 00:00:01'); + + $store->interval('foo', fn () => 999, 5); + + $this->assertSame($lastRefreshedAt, $this->metadata($state, $metadataKey)['lastRefreshedAt']); + + $store->refreshIntervalCaches(); + + $this->assertSame(1, $store->get('foo')); + $this->assertSame(1, IntervalResolverState::$attempts); + } + + public function testIntervalReregistrationPreservesFreshRefreshClaim(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + $count = 0; + + $store->interval('foo', function () use (&$count) { + return ++$count; + }, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + $metadata = $this->metadata($state, $metadataKey); + $metadata['refreshingAt'] = $this->currentTimestamp(); + $this->putMetadata($state, $metadataKey, $metadata); + + $store->interval('foo', function () use (&$count) { + return ++$count; + }, 1); + + $this->assertSame($this->currentTimestamp(), $this->metadata($state, $metadataKey)['refreshingAt']); + + $store->refreshIntervalCaches(); + + $this->assertSame(0, $count); + } + + public function testIntervalReregistrationUpdatesResolverAndRefreshIntervalForFutureRefreshes(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + + $store->interval('foo', fn () => 'first', 10); + $store->refreshIntervalCaches(); + $this->assertSame('first', $store->get('foo')); + + Carbon::setTestNow('2000-01-01 00:00:01'); + + $store->interval('foo', fn () => 'second', 2); + $store->refreshIntervalCaches(); + $this->assertSame('first', $store->get('foo')); + + Carbon::setTestNow('2000-01-01 00:00:02'); + + $store->refreshIntervalCaches(); + + $metadata = $this->metadata($state, $this->metadataKey($store, 'foo')); + + $this->assertSame('second', $store->get('foo')); + $this->assertSame(2, $metadata['refreshInterval']); + } + + public function testSameInstanceFallbackResolvesBeforeFirstTimerTick(): void + { + $store = $this->createStore(); + + $store->interval('foo', fn () => 'bar', 5); + + $this->assertSame('bar', $store->get('foo')); + $this->assertSame('bar', $store->get('foo')); + } + + public function testDifferentStoreReturnsNullBeforeFirstTimerTick(): void + { + $state = $this->createState(); + $workerStore = $this->createStore($state); + $readerStore = $this->createStore($state); + + $workerStore->interval('foo', fn () => 'bar', 5); + + $this->assertNull($readerStore->get('foo')); + } + + public function testRefresherStoreRefreshesIntervalsFromSharedIndex(): void + { + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + + $workerStore->interval('foo', fn () => 'bar', 5); + + $refresherStore->refreshIntervalCaches(); + + $this->assertSame('bar', $workerStore->get('foo')); + $this->assertSame('bar', $refresherStore->get('foo')); + } + + public function testRefresherStoreRefreshesMultipleIntervalsFromSharedIndex(): void + { + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + $keys = $this->keysWithSharedIndexShard($workerStore); + + foreach ($keys as $key) { + $workerStore->interval($key, fn () => "value-{$key}", 5); + } + + $metadataKeys = array_map(fn (string $key): string => $this->metadataKey($workerStore, $key), $keys); + $sharedShardKey = $this->indexKey($workerStore, $metadataKeys[0]); + $sharedShard = $this->index($state, $sharedShardKey); + + $this->assertSame($sharedShardKey, $this->indexKey($workerStore, $metadataKeys[1])); + $this->assertArrayHasKey($metadataKeys[0], $sharedShard); + $this->assertArrayHasKey($metadataKeys[1], $sharedShard); + + $refresherStore->refreshIntervalCaches(); + + foreach ($keys as $key) { + $this->assertSame("value-{$key}", $workerStore->get($key)); + $this->assertSame("value-{$key}", $refresherStore->get($key)); + } + } + + public function testNullReturningIntervalResolverStoresLivePublicRow(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + IntervalResolverState::$attempts = 0; + + $store->interval('foo', function () { + ++IntervalResolverState::$attempts; + + return null; + }, 5); + + $this->assertNull($store->get('foo')); + $this->assertNotFalse($this->userRow($state, $store, 'foo')); + + $this->assertNull($store->get('foo')); + $this->assertSame(1, IntervalResolverState::$attempts); + } + + public function testRefreshOnlyRunsWhenIntervalIsDue(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + + $workerStore->interval('foo', fn () => Carbon::now()->getTimestamp(), 5); + + $refresherStore->refreshIntervalCaches(); + $this->assertSame(Carbon::now()->getTimestamp(), $refresherStore->get('foo')); + + Carbon::setTestNow('2000-01-01 00:00:04'); + $refresherStore->refreshIntervalCaches(); + $this->assertSame(Carbon::parse('2000-01-01 00:00:00')->getTimestamp(), $refresherStore->get('foo')); + + Carbon::setTestNow('2000-01-01 00:00:06'); + $refresherStore->refreshIntervalCaches(); + $this->assertSame(Carbon::now()->getTimestamp(), $refresherStore->get('foo')); + } + + public function testSuccessfulRefreshUpdatesMetadata(): void + { + Carbon::setTestNow('2000-01-01 00:00:00.123456'); + + $state = $this->createState(); + $store = $this->createStore($state); + $store->interval('foo', fn () => 'bar', 5); + $metadataKey = $this->metadataKey($store, 'foo'); + + $store->refreshIntervalCaches(); + + $metadata = $this->metadata($state, $metadataKey); + + $this->assertSame($this->currentTimestamp(), $metadata['lastRefreshedAt']); + $this->assertNull($metadata['refreshingAt']); + } + + public function testSlowSuccessfulRefreshUsesCommitTimestampForCadence(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + $store->interval('foo', function () { + Carbon::setTestNow('2000-01-01 00:00:03.123456'); + + return 'bar'; + }, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + + $store->refreshIntervalCaches(); + + $this->assertSame( + Carbon::parse('2000-01-01 00:00:03.123456')->getPreciseTimestamp(6) / 1000000, + $this->metadata($state, $metadataKey)['lastRefreshedAt'] + ); + $this->assertSame('bar', $store->get('foo')); + } + + public function testFreshRefreshClaimPreventsOverlappingRefresh(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + $count = 0; + + $store->interval('foo', function () use (&$count) { + return ++$count; + }, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + $metadata = $this->metadata($state, $metadataKey); + $metadata['refreshingAt'] = $this->currentTimestamp(); + $this->putMetadata($state, $metadataKey, $metadata); + + $store->refreshIntervalCaches(); + + $this->assertSame(0, $count); + $this->assertNull($store->get('foo')); + } + + public function testStaleRefreshClaimCanBeReclaimed(): void + { + Carbon::setTestNow('2000-01-01 00:05:01'); + + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + + $workerStore->interval('foo', fn () => 'bar', 5); + + $metadataKey = $this->metadataKey($workerStore, 'foo'); + $metadata = $this->metadata($state, $metadataKey); + $metadata['refreshingAt'] = $this->currentTimestamp() - 301.0; + $this->putMetadata($state, $metadataKey, $metadata); + + $refresherStore->refreshIntervalCaches(); + + $this->assertSame('bar', $refresherStore->get('foo')); + $this->assertNull($this->metadata($state, $metadataKey)['refreshingAt']); + } + + public function testRefreshIntervalUsesDoubledIntervalWhenItExceedsClaimTimeout(): void + { + $store = $this->createStore(); + + $this->assertFalse($this->invoke($store, 'intervalClaimIsStale', 0.0, 399.999999, 200)); + $this->assertTrue($this->invoke($store, 'intervalClaimIsStale', 0.0, 400.0, 200)); + } + + public function testStaleRefresherCannotOverwriteNewerCommittedValue(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + IntervalReentryProbe::$refresherStore = $refresherStore; + + $workerStore->interval('foo', function () { + ++IntervalReentryProbe::$attempts; + + if (IntervalReentryProbe::$attempts === 1) { + Carbon::setTestNow('2000-01-01 00:05:01'); + IntervalReentryProbe::$refresherStore->refreshIntervalCaches(); + + return 'A'; + } + + return 'B'; + }, 5); + + $metadataKey = $this->metadataKey($workerStore, 'foo'); + + $workerStore->refreshIntervalCaches(); + + $metadata = $this->metadata($state, $metadataKey); + + $this->assertSame(2, IntervalReentryProbe::$attempts); + $this->assertSame('B', $workerStore->get('foo')); + $this->assertNull($metadata['refreshingAt']); + $this->assertSame( + Carbon::parse('2000-01-01 00:05:01')->getPreciseTimestamp(6) / 1000000, + $metadata['lastRefreshedAt'] + ); + } + + public function testLostClaimBeforeCommitDoesNotWriteResolverResult(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + IntervalLostClaimProbe::$state = $state; + $metadataKey = $this->metadataKey($store, 'foo'); + + $store->interval('foo', fn () => IntervalLostClaimProbe::loseClaim($metadataKey), 5); + + $store->refreshIntervalCaches(); + + $metadata = $this->metadata($state, $metadataKey); + + $this->assertFalse($this->userRow($state, $store, 'foo')); + $this->assertNull($metadata['lastRefreshedAt']); + $this->assertSame(123.456789, $metadata['refreshingAt']); + } + + public function testCompletionAndClaimClearingDoNotOverwriteNewerClaim(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $store->interval('foo', fn () => 'bar', 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + $metadata = $this->metadata($state, $metadataKey); + $metadata['refreshingAt'] = 2.123456; + $this->putMetadata($state, $metadataKey, $metadata); + + $this->invoke($store, 'completeIntervalRefresh', $metadataKey, 1.123456); + $this->assertSame(2.123456, $this->metadata($state, $metadataKey)['refreshingAt']); + $this->assertNull($this->metadata($state, $metadataKey)['lastRefreshedAt']); + + $this->invoke($store, 'clearIntervalClaim', $metadataKey, 1.123456); + $this->assertSame(2.123456, $this->metadata($state, $metadataKey)['refreshingAt']); + } + + public function testSameInstanceFallbackDuringFreshClaimReturnsNullWithoutRunningResolver(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + $count = 0; + + $store->interval('foo', function () use (&$count) { + return ++$count; + }, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + $metadata = $this->metadata($state, $metadataKey); + $metadata['refreshingAt'] = $this->currentTimestamp(); + $this->putMetadata($state, $metadataKey, $metadata); + + $this->assertNull($store->get('foo')); + $this->assertSame(0, $count); + } + + public function testFlushPreservesMetadataAndIndexRowsForRefresh(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = $this->createStore($state); + + $workerStore->interval('foo', fn () => 'bar', 5); + $refresherStore->refreshIntervalCaches(); + $this->assertSame('bar', $refresherStore->get('foo')); + + $this->assertTrue($refresherStore->flush()); + $this->assertNull($refresherStore->get('foo')); + $this->assertNotFalse($state->table()->get($this->metadataKey($workerStore, 'foo'))); + $this->assertNotFalse($state->table()->get($this->indexKey($workerStore, $this->metadataKey($workerStore, 'foo')))); + + Carbon::setTestNow('2000-01-01 00:00:05'); + + $refresherStore->refreshIntervalCaches(); + + $this->assertSame('bar', $refresherStore->get('foo')); + } + + public function testGenericMissDoesNotConsultSharedIntervalIndex(): void + { + $state = $this->createState(); + $workerStore = $this->createStore($state); + $otherStore = $this->createStore($state); + $count = 0; + + $workerStore->interval('foo', function () use (&$count) { + return ++$count; + }, 5); + + $this->assertNull($otherStore->get('foo')); + $this->assertSame(0, $count); + } + + public function testIndexRowsAreTouchedDuringRefreshDiscovery(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + + $store->interval('foo', fn () => 'bar', 5); + $indexKey = $this->indexKey($store, $this->metadataKey($store, 'foo')); + $before = $state->table()->get($indexKey)['expiration']; + + Carbon::setTestNow('2000-01-01 00:00:10'); + + $store->refreshIntervalCaches(); + + $this->assertGreaterThan($before, $state->table()->get($indexKey)['expiration']); + } + + public function testStaleCleanupAndEvictionSkipIntervalControlRows(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(rows: 8); + $store = $this->createStore( + state: $state, + memoryLimitBuffer: 1.0, + evictionProportion: 1.0 + ); + + $store->interval('foo', fn () => 'bar', 5); + $metadataKey = $this->metadataKey($store, 'foo'); + $indexKey = $this->indexKey($store, $metadataKey); + + $this->rewriteRowExpiration($state, $metadataKey, $this->currentTimestamp() - 1); + $this->rewriteRowExpiration($state, $indexKey, $this->currentTimestamp() - 1); + + $store->put('user', 'value', 60); + $store->evictRecords(); + + $this->assertNotFalse($state->table()->get($metadataKey)); + $this->assertNotFalse($state->table()->get($indexKey)); + } + + public function testThrowingTimerResolverIsReportedAndCanRetry(): void + { + $container = new Container; + $handler = m::spy(ExceptionHandler::class); + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + + $state = $this->createState(); + $store = $this->createStore($state); + $readerStore = $this->createStore($state); + $exception = new RuntimeException('refresh failed'); + IntervalResolverState::$attempts = 0; + + $store->interval('foo', function () use ($exception) { + ++IntervalResolverState::$attempts; + + if (IntervalResolverState::$attempts === 1) { + throw $exception; + } + + return 'bar'; + }, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + + $store->refreshIntervalCaches(); + + $handler->shouldHaveReceived('report')->with(m::on( + fn (Throwable $e): bool => $e::class === $exception::class + && $e->getMessage() === $exception->getMessage() + ))->once(); + $this->assertFalse($this->userRow($state, $readerStore, 'foo')); + $this->assertNull($this->metadata($state, $metadataKey)['lastRefreshedAt']); + $this->assertNull($this->metadata($state, $metadataKey)['refreshingAt']); + + $store->refreshIntervalCaches(); + + $this->assertSame('bar', $readerStore->get('foo')); + } + + public function testThrowingSameInstanceFallbackRethrowsAndClearsClaim(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + $exception = new RuntimeException('fallback failed'); + + $store->interval('foo', fn () => throw $exception, 5); + + $metadataKey = $this->metadataKey($store, 'foo'); + + try { + $store->get('foo'); + $this->fail('Expected interval fallback exception was not thrown.'); + } catch (Throwable $e) { + $this->assertSame($exception::class, $e::class); + $this->assertSame($exception->getMessage(), $e->getMessage()); + } + + $this->assertNull($this->metadata($state, $metadataKey)['refreshingAt']); + $this->assertNull($this->metadata($state, $metadataKey)['lastRefreshedAt']); + } + + public function testIntervalExceptionFallsBackToStderrWhenNoExceptionHandlerIsBound(): void + { + $scriptPath = $this->tempDir . '/interval-stderr.php'; + $autoloadPath = dirname(__DIR__, 2) . '/vendor/autoload.php'; + + file_put_contents($scriptPath, <<<'PHP' +reportIntervalException($e); + } +} + +Container::setInstance(new Container); + +$state = (new SwooleTableManager(new Container))->createState(8, 1024, 0.2, 12345); +$store = new ReportIntervalExceptionProbeStore($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); +$store->report(new RuntimeException('refresh failed')); +PHP); + + $process = new Process([PHP_BINARY, $scriptPath, $autoloadPath]); + $process->mustRun(); + + $this->assertStringContainsString('refresh failed', $process->getErrorOutput()); + } + + public function testFailedPublicValueWriteClearsClaimAndReportsFailure(): void + { + $container = new Container; + $handler = m::spy(ExceptionHandler::class); + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + + $state = $this->createState(); + $workerStore = $this->createStore($state); + $refresherStore = new FailingIntervalValueSwooleStore($state); + + $workerStore->interval('foo', fn () => 'bar', 5); + + $refresherStore->refreshIntervalCaches(); + + $handler->shouldHaveReceived('report')->with(m::on( + fn (RuntimeException $e): bool => $e->getMessage() === 'Unable to refresh Swoole interval cache [foo].' + ))->once(); + + $metadata = $this->metadata($state, $this->metadataKey($workerStore, 'foo')); + + $this->assertNull($metadata['refreshingAt']); + $this->assertNull($metadata['lastRefreshedAt']); + $this->assertFalse($this->userRow($state, $workerStore, 'foo')); + } + + private function createState( + int $rows = 128, + int $bytes = 65536, + float $conflictProportion = 0.2, + int $hashSeed = 12345 + ): SwooleTableState { + return (new SwooleTableManager(new Container)) + ->createState($rows, $bytes, $conflictProportion, $hashSeed); + } + + private function createStore( + ?SwooleTableState $state = null, + string $policy = SwooleStore::EVICTION_POLICY_TTL, + float $memoryLimitBuffer = 0.05, + float $evictionProportion = 0.05 + ): SwooleStore { + return new SwooleStore( + $state ?? $this->createState(), + $memoryLimitBuffer, + $policy, + $evictionProportion + ); + } + + private function invoke(SwooleStore $store, string $method, mixed ...$arguments): mixed + { + $reflection = new ReflectionMethod($store, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($store, ...$arguments); + } + + private function metadataKey(SwooleStore $store, string $key): string + { + return $this->invoke($store, 'intervalKey', $key); + } + + private function indexKey(SwooleStore $store, string $metadataKey): string + { + return $this->invoke($store, 'intervalIndexKey', $metadataKey); + } + + private function userKey(SwooleStore $store, string $key): string + { + return $this->invoke($store, 'userKey', $key); + } + + /** + * Get three interval keys, including two that share an interval index shard. + * + * @return list + */ + private function keysWithSharedIndexShard(SwooleStore $store): array + { + $keysByShard = []; + + for ($i = 0; $i < 256; ++$i) { + $key = "interval-key-{$i}"; + $metadataKey = $this->metadataKey($store, $key); + $indexKey = $this->indexKey($store, $metadataKey); + $keysByShard[$indexKey][] = $key; + + if (count($keysByShard[$indexKey]) === 2) { + return [$keysByShard[$indexKey][0], $keysByShard[$indexKey][1], "interval-key-extra-{$i}"]; + } + } + + $this->fail('Unable to find two interval keys that share an index shard.'); + } + + private function metadata(SwooleTableState $state, string $metadataKey): array + { + return unserialize($state->table()->get($metadataKey)['value']); + } + + private function putMetadata(SwooleTableState $state, string $metadataKey, array $metadata): void + { + $row = $state->table()->get($metadataKey); + + $state->table()->set($metadataKey, [ + 'value' => serialize($metadata), + 'expiration' => $row['expiration'], + ]); + } + + private function index(SwooleTableState $state, string $indexKey): array + { + return unserialize($state->table()->get($indexKey)['value']); + } + + private function localIntervals(SwooleStore $store): array + { + $property = new ReflectionProperty($store, 'intervals'); + $property->setAccessible(true); + + return $property->getValue($store); + } + + private function userRow(SwooleTableState $state, SwooleStore $store, string $key): array|false + { + return $state->table()->get($this->userKey($store, $key)); + } + + private function rewriteRowExpiration(SwooleTableState $state, string $key, float $expiration): void + { + $row = $state->table()->get($key); + $row['expiration'] = $expiration; + + $state->table()->set($key, $row); + } + + private function currentTimestamp(): float + { + return Carbon::now()->getPreciseTimestamp(6) / 1000000; + } +} + +class FailingIntervalValueSwooleStore extends SwooleStore +{ + public function __construct(SwooleTableState $state) + { + parent::__construct($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + } + + public function forever(string $key, mixed $value): bool + { + return false; + } +} + +class InstrumentedIntervalSwooleStore extends SwooleStore +{ + public function __construct(SwooleTableState $state) + { + parent::__construct($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + } + + protected function getIntervalMetadataByInternalKey(string $metadataKey): ?array + { + IntervalMetadataSerializationProbe::$insideMetadataSerialization = true; + + try { + return parent::getIntervalMetadataByInternalKey($metadataKey); + } finally { + IntervalMetadataSerializationProbe::$insideMetadataSerialization = false; + } + } + + protected function putIntervalMetadataByInternalKey(string $metadataKey, array $metadata): bool + { + IntervalMetadataSerializationProbe::$insideMetadataSerialization = true; + + try { + return parent::putIntervalMetadataByInternalKey($metadataKey, $metadata); + } finally { + IntervalMetadataSerializationProbe::$insideMetadataSerialization = false; + } + } +} + +class IntervalMetadataSerializationProbe +{ + public static bool $insideMetadataSerialization = false; + + public static int $insideSleeps = 0; + + public static int $insideWakeups = 0; + + public static int $outsideSleeps = 0; + + public static int $outsideWakeups = 0; + + public static function reset(): void + { + self::$insideMetadataSerialization = false; + self::$insideSleeps = 0; + self::$insideWakeups = 0; + self::$outsideSleeps = 0; + self::$outsideWakeups = 0; + } + + public function value(): string + { + return 'bar'; + } + + /** + * @return list + */ + public function __sleep(): array + { + if (self::$insideMetadataSerialization) { + ++self::$insideSleeps; + } else { + ++self::$outsideSleeps; + } + + return []; + } + + public function __wakeup(): void + { + if (self::$insideMetadataSerialization) { + ++self::$insideWakeups; + } else { + ++self::$outsideWakeups; + } + } +} + +class IntervalResolverState +{ + public static int $attempts = 0; +} + +class IntervalLostClaimProbe +{ + public static ?SwooleTableState $state = null; + + public static function reset(): void + { + self::$state = null; + } + + public static function loseClaim(string $metadataKey): string + { + if (self::$state === null) { + throw new RuntimeException('Interval lost-claim probe state was not configured.'); + } + + $row = self::$state->table()->get($metadataKey); + $metadata = unserialize($row['value']); + $metadata['refreshingAt'] = 123.456789; + + self::$state->table()->set($metadataKey, [ + 'value' => serialize($metadata), + 'expiration' => $row['expiration'], + ]); + + return 'stale'; + } +} + +class IntervalReentryProbe +{ + public static int $attempts = 0; + + public static ?SwooleStore $refresherStore = null; + + public static function reset(): void + { + self::$attempts = 0; + self::$refresherStore = null; + } +} diff --git a/tests/Cache/CacheSwooleStoreTest.php b/tests/Cache/CacheSwooleStoreTest.php index 36d4b388c..623ae05f1 100644 --- a/tests/Cache/CacheSwooleStoreTest.php +++ b/tests/Cache/CacheSwooleStoreTest.php @@ -5,46 +5,38 @@ namespace Hypervel\Tests\Cache; use Carbon\Carbon; +use Hypervel\Cache\Exceptions\ValueTooLargeForColumnException; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\SwooleTableManager; +use Hypervel\Cache\SwooleTableState; +use Hypervel\Contracts\Cache\CanFlushLocks; +use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; -use Swoole\Table; +use ReflectionMethod; +use TypeError; class CacheSwooleStoreTest extends TestCase { - public function testCanRetrieveItemsFromStore() + public function testCanRetrieveItemsFromStore(): void { - Carbon::setTestNow(now()); + $state = $this->createState(); + $store = $this->createStore($state); - $table = $this->createSwooleTable(); - - $table->set('foo', ['value' => serialize('bar'), 'expiration' => time() + 100]); - - $store = $this->createStore($table); + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100); $this->assertEquals('bar', $store->get('foo')); - $this->assertEquals($this->getCurrentTimestamp(), $table->get('foo')['last_used_at']); - $this->assertEquals(1, $table->get('foo')['used_count']); - - Carbon::setTestNow(now()->addMinutes(1)); - - $store->get('foo'); - $this->assertEquals($this->getCurrentTimestamp(), $table->get('foo')['last_used_at']); - $this->assertEquals(2, $table->get('foo')['used_count']); } - public function testMissingItemsReturnNull() + public function testMissingItemsReturnNull(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); $this->assertNull($store->get('foo')); } @@ -66,65 +58,69 @@ public function testMissingSwooleTableConfigThrowsTableNotDefinedException(): vo (new SwooleTableManager($container))->get('missing'); } - public function testExpiredItemsReturnNull() + public function testSwooleTableRejectsStringValuesLargerThanColumnSize(): void { - $table = $this->createSwooleTable(); + $table = $this->createState(bytes: 8)->table(); + + $this->expectException(ValueTooLargeForColumnException::class); + $this->expectExceptionMessage('Should be less than 8 characters but got 9 characters.'); - $store = $this->createStore($table); + $table->set('foo', ['value' => '123456789']); + } + + public function testExpiredItemsReturnNull(): void + { + $state = $this->createState(); + $store = $this->createStore($state); - $table->set('foo', ['value' => serialize('bar'), 'expiration' => time() - 100]); + $this->setLogicalRow($state, $store, 'foo', 'bar', time() - 100); $this->assertNull($store->get('foo')); } - public function testGetMethodCanResolvePendingInterval() + public function testGetMethodCanResolvePendingInterval(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); $store->interval('foo', fn () => 'bar', 1); $this->assertEquals('bar', $store->get('foo')); } - public function testManyMethodCanReturnManyValues() + public function testManyMethodCanReturnManyValues(): void { - $table = $this->createSwooleTable(); - - $table->set('foo', ['value' => serialize('bar'), 'expiration' => time() + 100]); - $table->set('bar', ['value' => serialize('baz'), 'expiration' => time() + 100]); + $state = $this->createState(); + $store = $this->createStore($state); - $store = $this->createStore($table); + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100); + $this->setLogicalRow($state, $store, 'bar', 'baz', time() + 100); $this->assertEquals(['foo' => 'bar', 'bar' => 'baz'], $store->many(['foo', 'bar'])); } - public function testPutStoresValueInTable() + public function testPutStoresValueInTable(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); $store->put('foo', 'bar', 5); $this->assertEquals('bar', $store->get('foo')); } - public function testNullSentinelRoundTripsThroughSwooleStore() + public function testNullSentinelRoundTripsThroughSwooleStore(): void { - $store = $this->createStore($this->createSwooleTable()); + $store = $this->createStore(); $repo = new Repository($store); $repo->rememberNullable('k', 60, fn () => null); - // Raw store-level access: the sentinel survives Swoole Table serialize/unserialize. $this->assertSame(NullSentinel::VALUE, $store->get('k')); $this->assertNull($repo->get('k')); $invoked = false; $result = $repo->rememberNullable('k', 60, function () use (&$invoked) { $invoked = true; + return 'should-not-run'; }); @@ -132,61 +128,267 @@ public function testNullSentinelRoundTripsThroughSwooleStore() $this->assertFalse($invoked); } - public function testPutManyStoresValueInTable() + public function testPutManyStoresValueInTable(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); - $store->putMany(['foo' => 'bar', 'bar' => 'baz'], 5); + $this->assertTrue($store->putMany(['foo' => 'bar', 'bar' => 'baz'], 5)); $this->assertEquals('bar', $store->get('foo')); $this->assertEquals('baz', $store->get('bar')); } - public function testAdd() + public function testPutManyReturnsTrueForEmptyInput(): void { - $table = $this->createSwooleTable(); + $store = $this->createStore(); - $store = $this->createStore($table); + $this->assertTrue($store->putMany([], 5)); + } + + public function testPutManyReturnsFalseForPartialFailureAndAttemptsEveryValue(): void + { + $store = new SwooleStorePutManyProbe($this->createState(), ['fail']); + + $this->assertFalse($store->putMany([ + 1 => 'one', + 'fail' => 'two', + 'after' => 'three', + ], 5)); + $this->assertSame(['1', 'fail', 'after'], $store->attempts); + } + + public function testAddOverwritesExpiredPhysicalRow(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $this->setLogicalRow($state, $store, 'foo', 'old', time() - 100); + + $this->assertTrue($store->add('foo', 'new', 5)); + $this->assertSame('new', $store->get('foo')); + } + + public function testAddPreservesLiveRow(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100); - $this->assertTrue($store->add('foo', 'bar', 5)); - $this->assertEquals('bar', $store->get('foo')); $this->assertFalse($store->add('foo', 'baz', 5)); + $this->assertSame('bar', $store->get('foo')); + } + + public function testAddDoesNotUpdateHitMetadata(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state, SwooleStore::EVICTION_POLICY_LRU); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + + Carbon::setTestNow('2000-01-01 00:01:00'); + + $this->assertFalse($store->add('foo', 'baz', 5)); + + $row = $this->getLogicalRow($state, $store, 'foo'); + + $this->assertSame(123.0, $row['last_used_at']); + $this->assertSame(7, $row['used_count']); + } + + public function testGetUnderTtlPolicyDoesNotUpdateHitMetadata(): void + { + $state = $this->createState(); + $store = $this->createStore($state, SwooleStore::EVICTION_POLICY_TTL); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + + $this->assertSame('bar', $store->get('foo')); + + $row = $this->getLogicalRow($state, $store, 'foo'); + + $this->assertSame(123.0, $row['last_used_at']); + $this->assertSame(7, $row['used_count']); + } + + public function testGetUnderNoEvictionPolicyDoesNotUpdateHitMetadata(): void + { + $state = $this->createState(); + $store = $this->createStore($state, SwooleStore::EVICTION_POLICY_NOEVICTION); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + + $this->assertSame('bar', $store->get('foo')); + + $row = $this->getLogicalRow($state, $store, 'foo'); + + $this->assertSame(123.0, $row['last_used_at']); + $this->assertSame(7, $row['used_count']); + } + + public function testGetUnderLruPolicyUpdatesOnlyLastUsedAt(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state, SwooleStore::EVICTION_POLICY_LRU); + $expiration = time() + 100; + + $this->setLogicalRow($state, $store, 'foo', 'bar', $expiration, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + + Carbon::setTestNow('2000-01-01 00:01:00.123456'); + + $this->assertSame('bar', $store->get('foo')); + + $row = $this->getLogicalRow($state, $store, 'foo'); + + $this->assertSame(serialize('bar'), $row['value']); + $this->assertSame((float) $expiration, $row['expiration']); + $this->assertSame(7, $row['used_count']); + $this->assertSame($this->getCurrentTimestamp(), $row['last_used_at']); } - public function testIncrementAndDecrementOperations() + public function testGetUnderLfuPolicyUpdatesOnlyUsedCount(): void { - $table = $this->createSwooleTable(); + Carbon::setTestNow('2000-01-01 00:00:00'); - $store = $this->createStore($table); + $state = $this->createState(); + $store = $this->createStore($state, SwooleStore::EVICTION_POLICY_LFU); + $expiration = time() + 100; - $store->increment('counter'); - $this->assertEquals(1, $store->get('counter')); + $this->setLogicalRow($state, $store, 'foo', 'bar', $expiration, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + + $this->assertSame('bar', $store->get('foo')); - $store->increment('counter', 2); - $this->assertEquals(3, $store->get('counter')); + $row = $this->getLogicalRow($state, $store, 'foo'); - $store->decrement('counter', 2); - $this->assertEquals(1, $store->get('counter')); + $this->assertSame(serialize('bar'), $row['value']); + $this->assertSame((float) $expiration, $row['expiration']); + $this->assertSame(123.0, $row['last_used_at']); + $this->assertSame(8, $row['used_count']); } - public function testForeverStoresValueInTable() + public function testLruHitMetadataCanCreateExpiredShellRowAfterConcurrentDelete(): void { - $table = $this->createSwooleTable(); + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_LRU, 0.05); + $tableKey = $store->userTableKey('foo'); + + $this->assertFalse($state->table()->get($tableKey)); + + $store->recordHitForTableKey($tableKey); + + $row = $state->table()->get($tableKey); + + $this->assertNotFalse($row); + $this->assertSame('', $row['value']); + $this->assertSame(0.0, $row['expiration']); + $this->assertNull($store->get('foo')); + $this->assertFalse($state->table()->get($tableKey)); + } + + public function testLfuHitMetadataCanCreateExpiredShellRowAfterConcurrentDelete(): void + { + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_LFU, 0.05); + $tableKey = $store->userTableKey('foo'); + + $this->assertFalse($state->table()->get($tableKey)); - $store = $this->createStore($table); + $store->recordHitForTableKey($tableKey); + + $row = $state->table()->get($tableKey); + + $this->assertNotFalse($row); + $this->assertSame('', $row['value']); + $this->assertSame(0.0, $row['expiration']); + $this->assertSame(1, $row['used_count']); + $this->assertNull($store->get('foo')); + $this->assertFalse($state->table()->get($tableKey)); + } + + public function testExpiredGetDeletesPhysicalRowAfterLockedRecheck(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() - 100); + + $this->assertNull($store->get('foo')); + $this->assertFalse($this->getLogicalRow($state, $store, 'foo')); + } + + public function testIncrementAndDecrementOperationsPreserveExpiration(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + $expiration = time() + 100; + + $this->setLogicalRow($state, $store, 'counter', 1, $expiration); + + $this->assertSame(3, $store->increment('counter', 2)); + $this->assertSame(1, $store->decrement('counter', 2)); + + $row = $this->getLogicalRow($state, $store, 'counter'); + + $this->assertSame(serialize(1), $row['value']); + $this->assertSame((float) $expiration, $row['expiration']); + } + + public function testIncrementCreatesMissingCounter(): void + { + $store = $this->createStore(); + + $this->assertSame(1, $store->increment('counter')); + $this->assertSame(1, $store->get('counter')); + } + + public function testIncrementDoesNotWakeObjectPayloadUnderRowLock(): void + { + $state = $this->createState(); + $store = $this->createStore($state); + SwooleStoreWakeupProbe::$wakeups = 0; + + $this->setLogicalRow($state, $store, 'counter', new SwooleStoreWakeupProbe, time() + 100); + + $this->expectException(TypeError::class); + + try { + $store->increment('counter'); + } finally { + $this->assertSame(0, SwooleStoreWakeupProbe::$wakeups); + } + } + + public function testForeverStoresValueInTable(): void + { + $store = $this->createStore(); $store->forever('foo', 'bar'); $this->assertEquals('bar', $store->get('foo')); } - public function testIntervalsCanBeRefreshed() + public function testIntervalsCanBeRefreshed(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); $store->interval('foo', fn () => Str::random(10), 1); @@ -198,44 +400,59 @@ public function testIntervalsCanBeRefreshed() $this->assertTrue(is_string($second = $store->get('foo'))); $this->assertNotEquals($first, $second); - - Carbon::setTestNow(); } - public function testCanForgetCacheItems() + public function testCanForgetCacheItems(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); $store->put('foo', 'bar', 5); $this->assertTrue($store->forget('foo')); $this->assertNull($store->get('foo')); + } + + public function testFlushDeletesUserRowsAndPreservesControlRows(): void + { + $state = $this->createState(); + $store = $this->createStore($state); $store->put('foo', 'bar', 5); + $store->interval('interval', fn () => 'value', 1); + $this->assertTrue($store->lock('lock', 60)->acquire()); + $state->table()->set('legacy-row', [ + 'value' => serialize('legacy'), + 'expiration' => time() + 100, + ]); + $this->assertTrue($store->flush()); $this->assertNull($store->get('foo')); + $this->assertFalse($state->table()->get('legacy-row')); + $this->assertNotFalse($state->table()->get($this->tableKey($store, 'intervalKey', 'interval'))); + $this->assertNotFalse($state->table()->get($this->tableKey($store, 'lockKey', 'lock'))); + $this->assertSame('value', $store->get('interval')); } - public function testIntervalsAreNotFlushed() + public function testFlushLocksDeletesLockRowsAndPreservesCacheAndIntervalRows(): void { - $table = $this->createSwooleTable(); + $state = $this->createState(); + $store = $this->createStore($state); - $store = $this->createStore($table); + $store->put('foo', 'bar', 5); + $store->interval('interval', fn () => 'value', 1); + $this->assertTrue($store->lock('lock', 60, 'owner-1')->acquire()); - $store->interval('foo', fn () => 'bar', 1); - $this->assertTrue($store->flush()); + $this->assertTrue($store->flushLocks()); - $this->assertEquals('bar', $store->get('foo')); + $this->assertSame('bar', $store->get('foo')); + $this->assertSame('value', $store->get('interval')); + $this->assertTrue($store->lock('lock', 60, 'owner-2')->acquire()); } - public function testExpiredAtWithMicrosecond() + public function testExpiredAtWithMicrosecond(): void { - $table = $this->createSwooleTable(); - - $store = $this->createStore($table); + $store = $this->createStore(); Carbon::setTestNow('2000-01-01 00:00:00.500000'); $store->put('foo', 'bar', 1); @@ -247,55 +464,246 @@ public function testExpiredAtWithMicrosecond() $this->assertNull($store->get('foo')); } - public function testCanRemoveExpiredRecordFromTable() + public function testPutDoesNotFlushStaleRecordsWhenMemoryLimitIsNotReached(): void { - $table = $this->createSwooleTable(); + $state = $this->createState(); + $store = $this->createStore($state); - $table->set('foo', ['value' => serialize('bar'), 'expiration' => time() - 100]); + $this->setLogicalRow($state, $store, 'expired', 'value', time() - 100); - $store = $this->createStore($table); + $store->put('fresh', 'value', 100); - $this->assertNull($store->get('foo')); - $this->assertFalse($table->get('foo')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'expired')); } - public function testEvictRecordsWhenMemoryLimitIsReached() + public function testEvictRecordsFlushesStaleRecordsWhenCalledDirectly(): void { - $table = $this->createSwooleTable(); + $state = $this->createState(); + $store = $this->createStore($state); - $store = $this->createStore($table); + $this->setLogicalRow($state, $store, 'expired', 'value', time() - 100); - for ($i = 0; $i < 256; ++$i) { - $store->put(hash('xxh128', "key:{$i}"), $i, 100); - } + $store->evictRecords(); + + $this->assertFalse($this->getLogicalRow($state, $store, 'expired')); + } + + public function testEvictRecordsFlushesRecordsExpiringAtCurrentTimestamp(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + + $this->setLogicalRow($state, $store, 'expired', 'value', $this->getCurrentTimestamp()); + + $store->evictRecords(); + + $this->assertFalse($this->getLogicalRow($state, $store, 'expired')); + } + + public function testPutChecksEvictionOnlyWhenMemoryLimitIsReached(): void + { + $state = $this->createState(); + + $store = new SwooleStoreEvictionSpy($state, false); + $store->put('foo', 'bar', 60); + $this->assertSame(0, $store->evictRecordsCalls); + + $store = new SwooleStoreEvictionSpy($state, true); + $store->put('bar', 'baz', 60); + $this->assertSame(1, $store->evictRecordsCalls); + } + + public function testEvictRecordsNeverDeletesControlRows(): void + { + $state = $this->createState(rows: 8); + $store = $this->createStore( + state: $state, + policy: SwooleStore::EVICTION_POLICY_TTL, + memoryLimitBuffer: 1.0, + evictionProportion: 1.0 + ); + + $store->interval('interval', fn () => 'value', 1); + $this->assertTrue($store->lock('lock', 60)->acquire()); + $store->put('foo', 'bar', 60); + $state->table()->set('legacy-row', [ + 'value' => serialize('legacy'), + 'expiration' => time() + 100, + ]); + + $store->evictRecords(); + + $this->assertFalse($state->table()->get('legacy-row')); + $this->assertNotFalse($state->table()->get($this->tableKey($store, 'intervalKey', 'interval'))); + $this->assertNotFalse($state->table()->get($this->tableKey($store, 'lockKey', 'lock'))); + } + + public function testSingleLruEvictionPassHonorsEvictionProportion(): void + { + $state = $this->createState(rows: 8); + $store = new SwooleStoreEvictionProbe( + $state, + 0.05, + SwooleStore::EVICTION_POLICY_LRU, + 2 / $state->table()->getSize() + ); + + $this->setLogicalRow($state, $store, 'oldest', 'value', time() + 100, ['last_used_at' => 10.0]); + $this->setLogicalRow($state, $store, 'older', 'value', time() + 100, ['last_used_at' => 20.0]); + $this->setLogicalRow($state, $store, 'newer', 'value', time() + 100, ['last_used_at' => 30.0]); + $this->setLogicalRow($state, $store, 'newest', 'value', time() + 100, ['last_used_at' => 40.0]); + + $this->assertSame(2, $store->removeOnePolicyBatch()); + $this->assertFalse($this->getLogicalRow($state, $store, 'oldest')); + $this->assertFalse($this->getLogicalRow($state, $store, 'older')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'newer')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'newest')); + } + + public function testSingleLfuEvictionPassHonorsEvictionProportion(): void + { + $state = $this->createState(rows: 8); + $store = new SwooleStoreEvictionProbe( + $state, + 0.05, + SwooleStore::EVICTION_POLICY_LFU, + 2 / $state->table()->getSize() + ); + + $this->setLogicalRow($state, $store, 'least', 'value', time() + 100, ['used_count' => 1]); + $this->setLogicalRow($state, $store, 'less', 'value', time() + 100, ['used_count' => 2]); + $this->setLogicalRow($state, $store, 'more', 'value', time() + 100, ['used_count' => 3]); + $this->setLogicalRow($state, $store, 'most', 'value', time() + 100, ['used_count' => 4]); + + $this->assertSame(2, $store->removeOnePolicyBatch()); + $this->assertFalse($this->getLogicalRow($state, $store, 'least')); + $this->assertFalse($this->getLogicalRow($state, $store, 'less')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'more')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'most')); + } + + public function testSingleTtlEvictionPassHonorsEvictionProportion(): void + { + $state = $this->createState(rows: 8); + $store = new SwooleStoreEvictionProbe( + $state, + 0.05, + SwooleStore::EVICTION_POLICY_TTL, + 2 / $state->table()->getSize() + ); + $now = time(); + + $this->setLogicalRow($state, $store, 'soonest', 'value', $now + 10); + $this->setLogicalRow($state, $store, 'sooner', 'value', $now + 20); + $this->setLogicalRow($state, $store, 'later', 'value', $now + 30); + $this->setLogicalRow($state, $store, 'latest', 'value', $now + 40); + + $this->assertSame(2, $store->removeOnePolicyBatch()); + $this->assertFalse($this->getLogicalRow($state, $store, 'soonest')); + $this->assertFalse($this->getLogicalRow($state, $store, 'sooner')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'later')); + $this->assertNotFalse($this->getLogicalRow($state, $store, 'latest')); + } + + public function testEvictionCandidateDoesNotDeleteRowMutatedByPut(): void + { + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + $tableKey = $store->userTableKey('foo'); + + $this->setLogicalRow($state, $store, 'foo', 'old', time() + 100); + $fingerprint = $store->fingerprintFor($state->table()->get($tableKey)); + + $this->assertTrue($store->put('foo', 'new', 60)); - $this->assertNull($store->get(hash('xxh128', 'key:0'))); - $this->assertSame(255, $store->get(hash('xxh128', 'key:255'))); - $this->assertLessThanOrEqual(128, $table->count()); + $this->assertFalse($store->forgetCandidate($tableKey, $fingerprint)); + $this->assertSame('new', $store->get('foo')); } - public function testFlushStaleRecords() + public function testEvictionCandidateDoesNotDeleteRowMutatedByIncrement(): void { - Carbon::setTestNow(now()); + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + $tableKey = $store->userTableKey('counter'); - $table = $this->createSwooleTable(); + $this->setLogicalRow($state, $store, 'counter', 1, time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + $fingerprint = $store->fingerprintFor($state->table()->get($tableKey)); - $table->set('foo', ['value' => serialize('foo'), 'expiration' => time() - 100]); + $this->assertSame(2, $store->increment('counter')); - $store = $this->createStore($table); + $this->assertFalse($store->forgetCandidate($tableKey, $fingerprint)); + $this->assertSame(2, $store->get('counter')); + } + + public function testEvictionCandidateDoesNotDeleteRowMutatedByLruHit(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_LRU, 0.05); + $tableKey = $store->userTableKey('foo'); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + $fingerprint = $store->fingerprintFor($state->table()->get($tableKey)); + + Carbon::setTestNow('2000-01-01 00:01:00'); + + $this->assertSame('bar', $store->get('foo')); + + $this->assertFalse($store->forgetCandidate($tableKey, $fingerprint)); + $this->assertSame('bar', $store->get('foo')); + } + + public function testEvictionCandidateDoesNotDeleteRowMutatedByLfuHit(): void + { + $state = $this->createState(); + $store = new SwooleStoreEvictionProbe($state, 0.05, SwooleStore::EVICTION_POLICY_LFU, 0.05); + $tableKey = $store->userTableKey('foo'); + + $this->setLogicalRow($state, $store, 'foo', 'bar', time() + 100, [ + 'last_used_at' => 123.0, + 'used_count' => 7, + ]); + $fingerprint = $store->fingerprintFor($state->table()->get($tableKey)); + + $this->assertSame('bar', $store->get('foo')); + + $this->assertFalse($store->forgetCandidate($tableKey, $fingerprint)); + $this->assertSame('bar', $store->get('foo')); + } - $store->put('bar', 'bar', 100); + public function testEvictRecordsPrunesExpiredLockRows(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $state = $this->createState(); + $store = $this->createStore($state); + + $this->assertTrue($store->lock('expired', 1)->acquire()); + + Carbon::setTestNow('2000-01-01 00:00:02'); + + $store->evictRecords(); - $this->assertFalse($table->get('foo')); + $this->assertFalse($state->table()->get($this->tableKey($store, 'lockKey', 'expired'))); } - public function testTouchUpdatesTtlOnExistingItem() + public function testTouchPreservesValueAndChangesExpiration(): void { Carbon::setTestNow($now = Carbon::now()); - $store = $this->createStore($this->createSwooleTable()); - $store->put('foo', 'bar', 30); + $state = $this->createState(); + $store = $this->createStore($state); + $store->put('foo', 'bar', 30); $store->touch('foo', 60); Carbon::setTestNow($now->addSeconds(45)); @@ -303,37 +711,325 @@ public function testTouchUpdatesTtlOnExistingItem() $this->assertSame('bar', $store->get('foo')); } - public function testTouchReturnsFalseForNonExistentItem() + public function testTouchReturnsFalseForNonExistentItem(): void { - $store = $this->createStore($this->createSwooleTable()); + $store = $this->createStore(); $this->assertFalse($store->touch('nonexistent', 60)); } - public function testTouchReturnsFalseForExpiredItem() + public function testTouchDeletesExpiredPhysicalRowAndReturnsFalse(): void { - Carbon::setTestNow($now = Carbon::now()); + $state = $this->createState(); + $store = $this->createStore($state); - $store = $this->createStore($this->createSwooleTable()); - $store->put('foo', 'bar', 10); - - Carbon::setTestNow($now->addSeconds(15)); + $this->setLogicalRow($state, $store, 'foo', 'bar', time() - 100); $this->assertFalse($store->touch('foo', 60)); + $this->assertFalse($this->getLogicalRow($state, $store, 'foo')); + } + + public function testDistinctLongLogicalKeysDoNotCollide(): void + { + $store = $this->createStore(); + $first = str_repeat('a', 63) . 'x'; + $second = str_repeat('a', 63) . 'y'; + + $store->put($first, 'first', 60); + $store->put($second, 'second', 60); + + $this->assertSame('first', $store->get($first)); + $this->assertSame('second', $store->get($second)); + } + + public function testInternalTableKeysStayUnderSwooleKeyLimit(): void + { + $store = $this->createStore(); + + $this->assertLessThan(63, strlen($this->tableKey($store, 'userKey', str_repeat('u', 256)))); + $this->assertLessThan(63, strlen($this->tableKey($store, 'intervalKey', str_repeat('i', 256)))); + $this->assertLessThan(63, strlen($this->tableKey($store, 'lockKey', str_repeat('l', 256)))); + } + + public function testSeededHashesDifferByStateAndMatchWithinState(): void + { + $firstState = $this->createState(hashSeed: 1); + $secondState = $this->createState(hashSeed: 2); + + $firstStore = $this->createStore($firstState); + $sameStateStore = $this->createStore($firstState); + $secondStore = $this->createStore($secondState); + + $this->assertSame( + $this->tableKey($firstStore, 'userKey', 'foo'), + $this->tableKey($sameStateStore, 'userKey', 'foo') + ); + $this->assertNotSame( + $this->tableKey($firstStore, 'userKey', 'foo'), + $this->tableKey($secondStore, 'userKey', 'foo') + ); + } + + public function testSwooleStoreImplementsLockContracts(): void + { + $this->assertTrue(is_subclass_of(SwooleStore::class, LockProvider::class)); + $this->assertTrue(is_subclass_of(SwooleStore::class, CanFlushLocks::class)); + } + + public function testSwooleStoreSupportsFunnels(): void + { + $repository = new Repository($this->createStore()); + $handled = false; + + $result = $repository->funnel('test-funnel') + ->limit(1) + ->releaseAfter(5) + ->block(1) + ->then(function () use (&$handled) { + $handled = true; + + return 'ok'; + }); + + $this->assertTrue($handled); + $this->assertSame('ok', $result); + } + + public function testLockAcquireSucceedsOnceWhileLive(): void + { + $store = $this->createStore(); + + $this->assertTrue($store->lock('foo', 60, 'owner-1')->acquire()); + $this->assertFalse($store->lock('foo', 60, 'owner-2')->acquire()); + } + + public function testExpiredLocksCanBeAcquiredByNewOwner(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $store = $this->createStore(); + + $this->assertTrue($store->lock('foo', 1, 'owner-1')->acquire()); + + Carbon::setTestNow('2000-01-01 00:00:02'); + + $this->assertTrue($store->lock('foo', 60, 'owner-2')->acquire()); + } + + public function testReleaseOnlyReleasesForOwner(): void + { + $store = $this->createStore(); + + $this->assertTrue($store->lock('foo', 60, 'owner-1')->acquire()); + $this->assertFalse($store->lock('foo', 60, 'owner-2')->release()); + $this->assertFalse($store->lock('foo', 60, 'owner-2')->acquire()); + $this->assertTrue($store->lock('foo', 60, 'owner-1')->release()); + $this->assertTrue($store->lock('foo', 60, 'owner-2')->acquire()); } - private function createStore(Table $table) + public function testForceReleaseReleasesRegardlessOfOwner(): void { - return new SwooleStore($table, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + $store = $this->createStore(); + + $this->assertTrue($store->lock('foo', 60, 'owner-1')->acquire()); + $store->lock('foo', 60, 'owner-2')->forceRelease(); + + $this->assertTrue($store->lock('foo', 60, 'owner-2')->acquire()); } - private function createSwooleTable() + public function testRestoreLockUsesSuppliedOwner(): void { - return (new SwooleTableManager(m::mock(Container::class)))->createTable(128, 10240, 0.2); + $store = $this->createStore(); + + $this->assertTrue($store->lock('foo', 60, 'owner-1')->acquire()); + $this->assertTrue($store->restoreLock('foo', 'owner-1')->release()); + $this->assertTrue($store->lock('foo', 60, 'owner-2')->acquire()); } - private function getCurrentTimestamp() + public function testRefreshExtendsLiveOwnedLock(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $store = $this->createStore(); + $lock = $store->lock('foo', 10, 'owner-1'); + + $this->assertTrue($lock->acquire()); + + Carbon::setTestNow('2000-01-01 00:00:05'); + + $this->assertTrue($lock->refresh(20)); + $this->assertSame(20.0, $lock->getRemainingLifetime()); + } + + public function testRefreshPermanentLockWithoutExplicitTtlIsNoOp(): void + { + $lock = $this->createStore()->lock('foo', 0); + + $this->assertTrue($lock->acquire()); + $this->assertTrue($lock->refresh()); + } + + public function testRefreshRejectsExplicitNonPositiveTtl(): void + { + $lock = $this->createStore()->lock('foo', 0); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'); + + $lock->refresh(0); + } + + public function testGetRemainingLifetimeReturnsNullForMissingExpiredAndPermanentLocks(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $store = $this->createStore(); + + $this->assertNull($store->lock('missing', 60)->getRemainingLifetime()); + + $permanent = $store->lock('permanent', 0); + $this->assertTrue($permanent->acquire()); + $this->assertNull($permanent->getRemainingLifetime()); + + $expiring = $store->lock('expiring', 1); + $this->assertTrue($expiring->acquire()); + + Carbon::setTestNow('2000-01-01 00:00:02'); + + $this->assertNull($expiring->getRemainingLifetime()); + } + + private function createState( + int $rows = 128, + int $bytes = 10240, + float $conflictProportion = 0.2, + int $hashSeed = 12345 + ): SwooleTableState { + return (new SwooleTableManager(m::mock(Container::class))) + ->createState($rows, $bytes, $conflictProportion, $hashSeed); + } + + private function createStore( + ?SwooleTableState $state = null, + string $policy = SwooleStore::EVICTION_POLICY_TTL, + float $memoryLimitBuffer = 0.05, + float $evictionProportion = 0.05 + ): SwooleStore { + return new SwooleStore( + $state ?? $this->createState(), + $memoryLimitBuffer, + $policy, + $evictionProportion + ); + } + + private function tableKey(SwooleStore $store, string $method, string $key): string + { + $reflection = new ReflectionMethod($store, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($store, $key); + } + + private function setLogicalRow( + SwooleTableState $state, + SwooleStore $store, + string $key, + mixed $value, + float $expiration, + array $metadata = [] + ): void { + $state->table()->set($this->tableKey($store, 'userKey', $key), array_merge([ + 'value' => serialize($value), + 'expiration' => $expiration, + ], $metadata)); + } + + private function getLogicalRow(SwooleTableState $state, SwooleStore $store, string $key): array|false + { + return $state->table()->get($this->tableKey($store, 'userKey', $key)); + } + + private function getCurrentTimestamp(): float { return Carbon::now()->getPreciseTimestamp(6) / 1000000; } } + +class SwooleStoreEvictionProbe extends SwooleStore +{ + public function removeOnePolicyBatch(): int + { + return $this->removeRecordsByEvictionPolicy(); + } + + public function userTableKey(string $key): string + { + return $this->userKey($key); + } + + public function fingerprintFor(array $record): array + { + return $this->evictionFingerprint($record); + } + + public function forgetCandidate(string $tableKey, array $fingerprint): bool + { + return $this->forgetEvictionCandidate($tableKey, $fingerprint); + } + + public function recordHitForTableKey(string $tableKey): void + { + $this->recordHit($tableKey); + } +} + +class SwooleStorePutManyProbe extends SwooleStore +{ + /** + * @var list + */ + public array $attempts = []; + + public function __construct(SwooleTableState $state, protected array $failures) + { + parent::__construct($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + } + + public function put(string $key, mixed $value, int $seconds): bool + { + $this->attempts[] = $key; + + return ! in_array($key, $this->failures, true); + } +} + +class SwooleStoreEvictionSpy extends SwooleStore +{ + public int $evictRecordsCalls = 0; + + public function __construct(SwooleTableState $state, protected bool $memoryLimitReached) + { + parent::__construct($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); + } + + public function evictRecords(): void + { + ++$this->evictRecordsCalls; + } + + protected function memoryLimitIsReached(): bool + { + return $this->memoryLimitReached; + } +} + +class SwooleStoreWakeupProbe +{ + public static int $wakeups = 0; + + public function __wakeup(): void + { + ++self::$wakeups; + } +} diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 5547452fb..34fada945 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -76,6 +76,13 @@ public function testPutManyAcceptsDateTimeInterfaceTtl(): void $this->assertSame('qux', $store->tags(['bop', 'zap'])->get('baz')); } + public function testPutManyReturnsTrueForEmptyInput(): void + { + $store = new ArrayStore; + + $this->assertTrue($store->tags(['bop', 'zap'])->putMany([], 60)); + } + public function testCacheSavedWithMultipleTagsCanBeFlushed() { $store = new ArrayStore; diff --git a/tests/Cache/CreateSwooleTimersTest.php b/tests/Cache/CreateSwooleTimersTest.php new file mode 100644 index 000000000..2c6377171 --- /dev/null +++ b/tests/Cache/CreateSwooleTimersTest.php @@ -0,0 +1,120 @@ +shouldReceive('array')->once()->with('cache.stores', [])->andReturn([ + 'fast' => [ + 'driver' => 'swoole', + 'eviction_interval' => 25000, + 'interval_refresh_interval' => 3000, + ], + 'defaulted' => [ + 'driver' => 'swoole', + ], + 'redis' => [ + 'driver' => 'redis', + ], + ]); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->once()->with('config')->andReturn($config); + + $timer = new FakeSwooleTimer; + + (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); + + $this->assertSame([25000, 3000, 10000, 1000], array_column($timer->ticks, 'milliseconds')); + } + + public function testDoesNotRegisterTimersOnOtherWorkers(): void + { + $container = m::mock(Container::class); + $timer = new FakeSwooleTimer; + + (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 1)); + + $this->assertSame([], $timer->ticks); + } + + public function testDoesNotRegisterTimersOnTaskWorkers(): void + { + $container = m::mock(Container::class); + $timer = new FakeSwooleTimer; + + (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0, taskworker: true)); + + $this->assertSame([], $timer->ticks); + } + + public function testTimerCallbacksCallTheConfiguredStore(): void + { + $config = m::mock(ConfigRepository::class); + $config->shouldReceive('array')->once()->with('cache.stores', [])->andReturn([ + 'fast' => [ + 'driver' => 'swoole', + ], + ]); + + $store = m::mock(SwooleStore::class); + $store->shouldReceive('evictRecords')->once(); + $store->shouldReceive('refreshIntervalCaches')->once(); + + $repository = m::mock(); + $repository->shouldReceive('getStore')->twice()->andReturn($store); + + $cache = m::mock(); + $cache->shouldReceive('store')->twice()->with('fast')->andReturn($repository); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->once()->with('config')->andReturn($config); + $container->shouldReceive('make')->twice()->with('cache')->andReturn($cache); + + $timer = new FakeSwooleTimer; + + (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); + + $timer->ticks[0]['callback'](); + $timer->ticks[1]['callback'](); + } + + private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart + { + $server = m::mock(SwooleServer::class); + $server->taskworker = $taskworker; + + return new AfterWorkerStart($server, $workerId); + } +} + +class FakeSwooleTimer extends SwooleTimer +{ + /** + * @var list + */ + public array $ticks = []; + + public function tick(int $milliseconds, Closure $callback): int|false + { + $this->ticks[] = compact('milliseconds', 'callback'); + + return array_key_last($this->ticks); + } +} diff --git a/tests/Cache/FunnelUnsupportedStoresTest.php b/tests/Cache/FunnelUnsupportedStoresTest.php index dfcd2772b..0156c5ad7 100644 --- a/tests/Cache/FunnelUnsupportedStoresTest.php +++ b/tests/Cache/FunnelUnsupportedStoresTest.php @@ -6,23 +6,17 @@ use Hypervel\Cache\SessionStore; use Hypervel\Cache\StackStore; -use Hypervel\Cache\SwooleStore; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Tests\TestCase; class FunnelUnsupportedStoresTest extends TestCase { - public function testSwooleStoreDoesNotImplementLockProvider() - { - $this->assertFalse(is_subclass_of(SwooleStore::class, LockProvider::class)); - } - - public function testStackStoreDoesNotImplementLockProvider() + public function testStackStoreDoesNotImplementLockProvider(): void { $this->assertFalse(is_subclass_of(StackStore::class, LockProvider::class)); } - public function testSessionStoreDoesNotImplementLockProvider() + public function testSessionStoreDoesNotImplementLockProvider(): void { $this->assertFalse(is_subclass_of(SessionStore::class, LockProvider::class)); } diff --git a/tests/Cache/LimitedMaxHeapTest.php b/tests/Cache/LimitedMaxHeapTest.php new file mode 100644 index 000000000..026507b16 --- /dev/null +++ b/tests/Cache/LimitedMaxHeapTest.php @@ -0,0 +1,93 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Heap limit must be at least 1.'); + + new LimitedMaxHeap($limit); + } + + public static function invalidLimits(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + ]; + } + + public function testSmallerValueReplacesCurrentMaximumWhenHeapIsFull(): void + { + $heap = new LimitedMaxHeap(3); + + foreach ([10, 20, 30, 5] as $value) { + $heap->insert($value); + } + + $this->assertRetainedValues([5, 10, 20], $heap); + } + + public function testLargerValueIsDiscardedWhenHeapIsFull(): void + { + $heap = new LimitedMaxHeap(3); + + foreach ([10, 20, 30, 40] as $value) { + $heap->insert($value); + } + + $this->assertRetainedValues([10, 20, 30], $heap); + } + + public function testAscendingInputRetainsSmallestValues(): void + { + $heap = new LimitedMaxHeap(4); + + foreach ([1, 2, 3, 4, 5, 6, 7, 8] as $value) { + $heap->insert($value); + } + + $this->assertRetainedValues([1, 2, 3, 4], $heap); + } + + public function testDescendingInputRetainsSmallestValues(): void + { + $heap = new LimitedMaxHeap(4); + + foreach ([8, 7, 6, 5, 4, 3, 2, 1] as $value) { + $heap->insert($value); + } + + $this->assertRetainedValues([1, 2, 3, 4], $heap); + } + + public function testShuffledInputRetainsSmallestValues(): void + { + $heap = new LimitedMaxHeap(4); + + foreach ([7, 2, 8, 1, 5, 3, 6, 4] as $value) { + $heap->insert($value); + } + + $this->assertRetainedValues([1, 2, 3, 4], $heap); + } + + protected function assertRetainedValues(array $expected, LimitedMaxHeap $heap): void + { + $actual = iterator_to_array($heap); + sort($actual); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Integration/Cache/FailoverStoreTest.php b/tests/Integration/Cache/FailoverStoreTest.php index b9fc781d0..f32a4ab9e 100644 --- a/tests/Integration/Cache/FailoverStoreTest.php +++ b/tests/Integration/Cache/FailoverStoreTest.php @@ -140,6 +140,24 @@ public function testFailoverCacheSkipsFailedOverEventWhenThereAreNoListeners(): $this->assertSame('fallback', $store->get('test')); } + public function testPutManyWithEmptyInputReturnsSelectedStoreResult(): void + { + $events = m::mock(Dispatcher::class); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('putMany')->once()->with([], 60)->andReturn(false); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('primary') + ->once() + ->andReturn($repository); + + $store = new FailoverStore($cacheManager, $events, ['primary']); + + $this->assertFalse($store->putMany([], 60)); + } + public function testNullSentinelRoundTripsThroughFailoverStorePrimary() { $primaryRepo = new Repository(new ArrayStore(serializesValues: true));