diff --git a/composer.json b/composer.json index a8c67a6..5529226 100644 --- a/composer.json +++ b/composer.json @@ -48,8 +48,8 @@ "ext-json": "*", "psr/simple-cache": "2 - 3", "roadrunner-php/roadrunner-api-dto": "^1.0", + "spiral/goridge": "^4.2", "spiral/roadrunner": "^2023.1 || ^2024.1", - "spiral/goridge": "^4.0", "google/protobuf": "^3.7" }, "autoload": { diff --git a/src/AsyncCache.php b/src/AsyncCache.php new file mode 100644 index 0000000..b37b5b3 --- /dev/null +++ b/src/AsyncCache.php @@ -0,0 +1,146 @@ +rpc instanceof AsyncRPCInterface); + } + + /** + * Note: The current PSR-16 implementation always returns true or + * exception on error. + * + * {@inheritDoc} + * + * @throws KeyValueException + * @throws RPCException + */ + public function deleteAsync(string $key): bool + { + return $this->deleteMultipleAsync([$key]); + } + + /** + * Note: The current PSR-16 implementation always returns true or + * exception on error. + * + * {@inheritDoc} + * + * @psalm-param iterable $keys + * + * @throws KeyValueException + * @throws RPCException + */ + public function deleteMultipleAsync(iterable $keys): bool + { + assert($this->rpc instanceof AsyncRPCInterface); + + // Handle someone never calling commitAsync() + if (count($this->callsInFlight) > 1000) { + $this->commitAsync(); + } + + $this->callsInFlight[] = $this->rpc->callAsync('kv.Delete', $this->requestKeys($keys)); + + return true; + } + + /** + * {@inheritDoc} + * + * @psalm-param positive-int|\DateInterval|null $ttl + * @psalm-suppress MoreSpecificImplementedParamType + * @throws KeyValueException + * @throws RPCException + */ + public function setAsync(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + { + return $this->setMultipleAsync([$key => $value], $ttl); + } + + /** + * {@inheritDoc} + * + * @psalm-param iterable $values + * @psalm-param positive-int|\DateInterval|null $ttl + * @psalm-suppress MoreSpecificImplementedParamType + * @throws KeyValueException + * @throws RPCException + */ + public function setMultipleAsync(iterable $values, null|int|DateInterval $ttl = null): bool + { + assert($this->rpc instanceof AsyncRPCInterface); + + // Handle someone never calling commitAsync() + if (count($this->callsInFlight) > 1000) { + $this->commitAsync(); + } + + $this->callsInFlight[] = $this->rpc->callAsync( + 'kv.Set', + $this->requestValues($values, $this->ttlToRfc3339String($ttl)) + ); + + return true; + } + + /** + * @throws KeyValueException + * @throws RPCException + */ + public function commitAsync(): bool + { + assert($this->rpc instanceof AsyncRPCInterface); + + try { + $this->rpc->getResponses($this->callsInFlight, Response::class); + } catch (ServiceException $e) { + $message = str_replace(["\t", "\n"], ' ', $e->getMessage()); + + if (str_contains($message, 'no such storage')) { + throw new StorageException(sprintf(self::ERROR_INVALID_STORAGE, $this->name)); + } + + throw new KeyValueException($message, $e->getCode(), $e); + } finally { + $this->callsInFlight = []; + } + + return true; + } +} diff --git a/src/AsyncStorageInterface.php b/src/AsyncStorageInterface.php new file mode 100644 index 0000000..8c4bb4e --- /dev/null +++ b/src/AsyncStorageInterface.php @@ -0,0 +1,73 @@ + value pairs in the cache, with an optional TTL. + * + * @param iterable $values A list of key => value pairs for a multiple-set operation. + * @param null|int|\DateInterval $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * + * @return bool True on success and false on failure. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $values is neither an array nor a Traversable, + * or if any of the $values are not a legal value. + */ + public function setMultipleAsync(iterable $values, null|int|DateInterval $ttl = null): bool; + + /** + * Persists data in the cache, uniquely referenced by a key with an optional expiration TTL time. + * + * @param string $key The key of the item to store. + * @param mixed $value The value of the item to store, must be serializable. + * @param null|int|\DateInterval $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * + * @return bool True on success and false on failure. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + public function setAsync(string $key, mixed $value, null|int|DateInterval $ttl = null): bool; + + /** + * Delete an item from the cache by its unique key. + * + * @param string $key The unique cache key of the item to delete. + * + * @return bool True if the item was successfully removed. False if there was an error. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + public function deleteAsync(string $key): bool; + + /** + * Deletes multiple cache items in a single operation. + * + * @param iterable $keys A list of string-based keys to be deleted. + * + * @return bool True if the items were successfully removed. False if there was an error. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. + */ + public function deleteMultipleAsync(iterable $keys): bool; +} diff --git a/src/Cache.php b/src/Cache.php index 3c03ed9..7790fb7 100644 --- a/src/Cache.php +++ b/src/Cache.php @@ -26,7 +26,7 @@ class Cache implements StorageInterface { use SerializerAwareTrait; - private const ERROR_INVALID_STORAGE = + protected const ERROR_INVALID_STORAGE = 'Storage "%s" has not been defined. Please make sure your '. 'RoadRunner "kv" configuration contains a storage key named "%1$s"'; @@ -47,9 +47,9 @@ class Cache implements StorageInterface * @param non-empty-string $name */ public function __construct( - RPCInterface $rpc, - private readonly string $name, - SerializerInterface $serializer = new DefaultSerializer() + RPCInterface $rpc, + protected readonly string $name, + SerializerInterface $serializer = new DefaultSerializer() ) { $this->rpc = $rpc->withCodec(new ProtobufCodec()); $this->zone = new \DateTimeZone('UTC'); @@ -108,7 +108,7 @@ public function getMultipleTtl(iterable $keys = []): iterable /** * @return array */ - private function createIndex(Response $response): array + protected function createIndex(Response $response): array { $result = []; @@ -146,7 +146,7 @@ private function call(string $method, Request $request): Response * @param iterable $keys * @throws InvalidArgumentException */ - private function requestKeys(iterable $keys): Request + protected function requestKeys(iterable $keys): Request { $items = []; @@ -257,8 +257,9 @@ public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null /** * @param iterable $values * @throws SerializationException + * @throws InvalidArgumentException */ - private function requestValues(iterable $values, string $ttl): Request + protected function requestValues(iterable $values, string $ttl): Request { $items = []; $serializer = $this->getSerializer(); @@ -279,8 +280,9 @@ private function requestValues(iterable $values, string $ttl): Request /** * @throws InvalidArgumentException + * @throws \Exception */ - private function ttlToRfc3339String(null|int|\DateInterval $ttl): string + protected function ttlToRfc3339String(null|int|\DateInterval $ttl): string { if ($ttl === null) { return ''; diff --git a/src/Factory.php b/src/Factory.php index 0d6fd3a..2692d37 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -4,10 +4,11 @@ namespace Spiral\RoadRunner\KeyValue; +use Spiral\Goridge\RPC\AsyncRPCInterface; use Spiral\Goridge\RPC\RPCInterface; +use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; use Spiral\RoadRunner\KeyValue\Serializer\SerializerAwareTrait; use Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface; -use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; /** * @psalm-suppress PropertyNotSetInConstructor @@ -25,6 +26,10 @@ public function __construct( public function select(string $name): StorageInterface { - return new Cache($this->rpc, $name, $this->getSerializer()); + if ($this->rpc instanceof AsyncRPCInterface) { + return new AsyncCache($this->rpc, $name, $this->getSerializer()); + } else { + return new Cache($this->rpc, $name, $this->getSerializer()); + } } } diff --git a/tests/AsyncCacheTest.php b/tests/AsyncCacheTest.php new file mode 100644 index 0000000..266ec00 --- /dev/null +++ b/tests/AsyncCacheTest.php @@ -0,0 +1,868 @@ +name = \bin2hex(\random_bytes(32)); + parent::setUp(); + } + + public function testName(): void + { + $driver = $this->cache(); + + $this->assertSame($this->name, $driver->getName()); + } + + /** + * @param array $mapping + * @param SerializerInterface|null $serializer + */ + private function cache(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): AsyncCache + { + return new AsyncCache($this->asyncRPC($mapping), $this->name, $serializer); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testTtl(SerializerInterface $serializer): void + { + [$key, $expected] = [$this->randomString(), $this->now()]; + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $key, + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getTtl($key); + + $this->assertNotNull($actual); + $this->assertEquals($expected, $actual); + } + + private function randomString(int $len = 32): string + { + return \bin2hex(\random_bytes($len)); + } + + /** + * Returns normalized datetime without milliseconds + * + * @return \DateTimeInterface + */ + private function now(): \DateTimeInterface + { + $time = (new \DateTime())->format(\DateTimeInterface::RFC3339); + + return \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $time); + } + + /** + * @param array $items + */ + private function response(array $items = []): string + { + return (new Response(['items' => $items]))->serializeToString(); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testNoTtl(SerializerInterface $serializer): void + { + $driver = $this->cache(['kv.TTL' => $this->response()], $serializer); + + $this->assertNull($driver->getTtl('key')); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testMultipleTtl(SerializerInterface $serializer): void + { + $keys = [$this->randomString(), $this->randomString()]; + $expected = $this->now(); + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $keys[0], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + new Item([ + 'key' => $keys[1], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getMultipleTtl($keys); + + foreach ($actual as $key => $time) { + $this->assertContains($key, $keys); + $this->assertEquals($expected, $time); + } + } + + /** + * @dataProvider serializersDataProvider + */ + public function testMultipleTtlWithMissingTime(SerializerInterface $serializer): void + { + $keys = [$this->randomString(), $this->randomString(), $this->randomString(), $this->randomString()]; + $expected = $this->now(); + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $keys[0], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getMultipleTtl($keys); + + foreach ($actual as $key => $time) { + $this->assertContains($key, $keys); + + $expectedForKey = $key === $keys[0] ? $expected : null; + $this->assertEquals($expectedForKey, $time); + } + } + + /** + * @dataProvider serializersDataProvider + */ + public function testTtlWithInvalidResponseKey(SerializerInterface $serializer): void + { + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $this->randomString(), + 'value' => $serializer->serialize(null), + 'timeout' => $this->now()->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $this->assertNull($driver->getTtl('__invalid__')); + } + + /** + * @return array + */ + public static function methodsDataProvider(): array + { + return [ + 'getTtl' => [fn (AsyncCache $c) => $c->getTtl('key')], + 'getMultipleTtl' => [fn (AsyncCache $c) => $c->getMultipleTtl(['key'])], + 'get' => [fn (AsyncCache $c) => $c->get('key')], + 'set' => [fn (AsyncCache $c) => $c->set('key', 'value')], + 'setAsync' => [fn (AsyncCache $c) => $c->setAsync('key', 'value') && $c->commitAsync()], + 'getMultiple' => [fn (AsyncCache $c) => $c->getMultiple(['key'])], + 'setMultiple' => [fn (AsyncCache $c) => $c->setMultiple(['key' => 'value'])], + 'setMultipleAsync' => [fn (AsyncCache $c) => $c->setMultiple(['key' => 'value']) && $c->commitAsync()], + 'deleteMultiple' => [fn (AsyncCache $c) => $c->deleteMultiple(['key'])], + 'deleteMultipleAsync' => [fn (AsyncCache $c) => $c->deleteMultipleAsync(['key']) && $c->commitAsync()], + 'delete' => [fn (AsyncCache $c) => $c->delete('key')], + 'deleteAsync' => [fn (AsyncCache $c) => $c->delete('key') && $c->commitAsync()], + 'has' => [fn (AsyncCache $c) => $c->has('key')], + ]; + } + + /** + * @param callable $handler (AsyncCache) $handler + * @dataProvider methodsDataProvider + */ + public function testBadStorageNameOnAnyMethodExecution(callable $handler): void + { + // When RPC ServiceException like + $error = function () { + throw new ServiceException('no such storage "' . $this->name . '"'); + }; + + // Then expects message like that cache storage has not been defined + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + \sprintf( + 'Storage "%s" has not been defined. Please make sure your ' . + 'RoadRunner "kv" configuration contains a storage key named "%1$s"', + $this->name, + ), + ); + + $driver = $this->cache([ + 'kv.Has' => $error, + 'kv.Set' => $error, + 'kv.MGet' => $error, + 'kv.MExpire' => $error, + 'kv.TTL' => $error, + 'kv.Delete' => $error, + ]); + + $result = $handler($driver); + + // When the generator returns, then no error occurs + if ($result instanceof \Generator) { + \iterator_to_array($result); + } + } + + public function testTtlNotAvailable(): void + { + // When RPC ServiceException like + $error = function () { + throw new ServiceException('memcached_plugin_ttl: ttl not available'); + }; + + // Then expects message like that TTL not available + $this->expectException(NotImplementedException::class); + $this->expectExceptionMessage( + \sprintf( + 'Storage "%s" does not support kv.TTL RPC method execution. Please ' . + 'use another driver for the storage if you require this functionality', + $this->name, + ), + ); + + $driver = $this->cache(['kv.TTL' => $error]); + + $driver->getTtl('key'); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testGet(SerializerInterface $serializer): void + { + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([ + new Item(['key' => 'key', 'value' => $serializer->serialize($expected)]), + ]), + ], $serializer); + + $this->assertSame($expected, $driver->get('key')); + } + + public function testGetWhenValueNotExists(): void + { + $driver = $this->cache(['kv.MGet' => $this->response()]); + + $this->assertNull($driver->get('key')); + } + + public function testGetDefaultWhenValueNotExists(): void + { + $expected = $this->randomString(); + + $driver = $this->cache(['kv.MGet' => $this->response()]); + + $this->assertSame($expected, $driver->get('key', $expected)); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testGetMultiple(SerializerInterface $serializer): void + { + $expected = [ + 'key0' => $this->randomString(), + 'key1' => $this->randomString(), + 'key2' => null, + 'key3' => null, + ]; + + $driver = $this->cache([ + // Only 2 items of 4 should be returned + 'kv.MGet' => $this->response([ + new Item(['key' => 'key0', 'value' => $serializer->serialize($expected['key0'])]), + new Item(['key' => 'key1', 'value' => $serializer->serialize($expected['key1'])]), + ]), + ], $serializer); + + $actual = $driver->getMultiple(\array_keys($expected)); + + $this->assertSame($expected, \iterator_to_array($actual)); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testHas(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response([ + new Item(['key' => $key, 'value' => $serializer->serialize(null)]), + ]), + ], $serializer); + + $this->assertTrue($driver->has($key)); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testHasWhenNotExists(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response(), + ], $serializer); + + $this->assertFalse($driver->has($key)); + } + + /** + * @dataProvider serializersDataProvider + */ + public function testHasWithInvalidResponse(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response([ + new Item(['key' => $key, 'value' => $serializer->serialize(null)]), + ]), + ], $serializer); + + $this->assertFalse($driver->has('__invalid_key__')); + } + + public function testClear(): void + { + $driver = $this->cache(['kv.Clear' => $this->response()]); + + $result = $driver->clear(); + + $this->assertTrue($result); + } + + public function testClearError(): void + { + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage('Something went wrong'); + + $driver = $this->cache([ + 'kv.Clear' => function () { + throw new ServiceException('Something went wrong'); + }, + ]); + + $driver->clear(); + } + + public function testClearMethodNotFoundError(): void + { + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage( + 'RoadRunner does not support kv.Clear RPC method. ' . + 'Please make sure you are using RoadRunner v2.3.1 or higher.', + ); + + $driver = $this->cache(); + $driver->clear(); + } + + public static function serializersWithValuesDataProvider(): array + { + $result = []; + + foreach (self::serializersDataProvider() as $name => [$serializer]) { + foreach (self::valuesDataProvider() as $type => [$value]) { + $result['[' . $type . '] using [' . $name . ']'] = [$serializer, $value]; + } + } + + return $result; + } + + /** + * @return array + * @throws \SodiumException + */ + public static function serializersDataProvider(): array + { + $result = []; + $result['PHP Serialize'] = [new DefaultSerializer()]; + + // ext-igbinary required for this serializer + if (\extension_loaded('igbinary')) { + $result['Igbinary'] = [new IgbinarySerializer()]; + } + + // ext-sodium required for this serialize + if (\extension_loaded('sodium')) { + foreach ($result as $name => [$serializer]) { + $result['Sodium through ' . $name] = [ + new SodiumSerializer($serializer, \sodium_crypto_box_keypair()), + ]; + } + } + + return $result; + } + + /** + * @dataProvider serializersWithValuesDataProvider + */ + public function testSet(SerializerInterface $serializer, $expected): void + { + if (\is_float($expected) && \is_nan($expected)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($expected)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $driver = $this->getAssertableCacheOnSet($serializer, ['key' => $expected]); + + $driver->set('key', $expected); + } + + /** + * @dataProvider serializersWithValuesDataProvider + */ + public function testSetAsync(SerializerInterface $serializer, $expected): void + { + if (\is_float($expected) && \is_nan($expected)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($expected)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $driver = $this->getAssertableCacheOnSet($serializer, ['key' => $expected]); + + $driver->setAsync('key', $expected); + $driver->commitAsync(); + } + + /** + * @param SerializerInterface $serializer + * @param array $expected + * @return AsyncCache + */ + private function getAssertableCacheOnSet(SerializerInterface $serializer, array $expected): AsyncCache + { + return $this->cache([ + 'kv.Set' => function (Request $request) use ($serializer, $expected): string { + $items = $request->getItems(); + + $result = []; + + /** @var Item $item */ + foreach ($items as $item) { + $result[] = $item; + + $this->assertArrayHasKey($item->getKey(), $expected); + $this->assertEquals($expected[$item->getKey()], $serializer->unserialize($item->getValue())); + } + + $this->assertSame($items->count(), \count($expected)); + + return $this->response($result); + }, + ], $serializer); + } + + /** + * @dataProvider serializersWithValuesDataProvider + */ + public function testMultipleSet(SerializerInterface $serializer, $value): void + { + if (\is_float($value) && \is_nan($value)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($value)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $expected = ['key' => $value, 'key2' => $value]; + + $driver = $this->getAssertableCacheOnSet($serializer, $expected); + $driver->setMultiple($expected); + } + + /** + * @dataProvider serializersWithValuesDataProvider + */ + public function testMultipleSetAsync(SerializerInterface $serializer, $value): void + { + if (\is_float($value) && \is_nan($value)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($value)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $expected = ['key' => $value, 'key2' => $value]; + + $driver = $this->getAssertableCacheOnSet($serializer, $expected); + $driver->setMultipleAsync($expected); + $driver->commitAsync(); + } + + public function testSetWithRelativeIntTTL(): void + { + $seconds = 0xDEAD_BEEF; + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + // Relative date: [$now] + [$seconds] + $expected = $now->add(new \DateInterval("PT{$seconds}S")) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + // Send relative date in $now + $seconds + $driver->set('key', 'value', $seconds); + } + + public function testSetAsyncWithRelativeIntTTL(): void + { + $seconds = 0xDEAD_BEEF; + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + // Relative date: [$now] + [$seconds] + $expected = $now->add(new \DateInterval("PT{$seconds}S")) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + // Send relative date in $now + $seconds + $driver->setAsync('key', 'value', $seconds); + $driver->commitAsync(); + } + + /** + * @param array $mapping + * @param SerializerInterface $serializer + */ + private function frozenDateCache( + \DateTimeImmutable $date, + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer(), + ): AsyncCache { + return new AsyncFrozenDateCacheStub($date, $this->asyncRPC($mapping), $this->name, $serializer); + } + + public function testSetWithRelativeDateIntervalTTL(): void + { + $seconds = 0xDEAD_BEEF; + $interval = new \DateInterval("PT{$seconds}S"); + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + + // Add interval to frozen current time + $expected = $now->add($interval) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + $driver->set('key', 'value', $interval); + } + + public function testSetAsyncWithRelativeDateIntervalTTL(): void + { + $seconds = 0xDEAD_BEEF; + $interval = new \DateInterval("PT{$seconds}S"); + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + + // Add interval to frozen current time + $expected = $now->add($interval) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + $driver->setAsync('key', 'value', $interval); + $driver->commitAsync(); + } + + /** + * @dataProvider valuesDataProvider + */ + public function testSetWithInvalidTTL($invalidTTL): void + { + $type = \get_debug_type($invalidTTL); + + if ($invalidTTL === null || \is_int($invalidTTL) || $invalidTTL instanceof \DateTimeInterface) { + $this->markTestSkipped('Can not complete negative test for valid TTL of type ' . $type); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cache item ttl (expiration) must be of type int or \DateInterval, but ' . $type . ' passed', + ); + + $driver = $this->cache(); + + // Send relative date in $now + $seconds + $driver->set('key', 'value', $invalidTTL); + } + + /** + * @dataProvider valuesDataProvider + */ + public function testSetAsyncWithInvalidTTL($invalidTTL): void + { + $type = \get_debug_type($invalidTTL); + + if ($invalidTTL === null || \is_int($invalidTTL) || $invalidTTL instanceof \DateTimeInterface) { + $this->markTestSkipped('Can not complete negative test for valid TTL of type ' . $type); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cache item ttl (expiration) must be of type int or \DateInterval, but ' . $type . ' passed', + ); + + $driver = $this->cache(); + + // Send relative date in $now + $seconds + $driver->setAsync('key', 'value', $invalidTTL); + // Make sure not reachable + $this->assertSame(true, false); + $driver->commitAsync(); + } + + public function testDelete(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->delete('key')); + } + + public function testDeleteAsync(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->deleteAsync('key')); + $this->assertTrue($driver->commitAsync()); + } + + public function testDeleteWithError(): void + { + $this->expectException(KeyValueException::class); + + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->delete('key'); + } + + public function testDeleteAsyncWithError(): void + { + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->deleteAsync('key'); + $this->expectException(KeyValueException::class); + $driver->commitAsync(); + } + + public function testDeleteMultiple(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->deleteMultiple(['key', 'key2'])); + } + + public function testDeleteMultipleAsync(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->deleteMultipleAsync(['key', 'key2'])); + $this->assertTrue($driver->commitAsync()); + } + + public function testDeleteMultipleWithError(): void + { + $this->expectException(KeyValueException::class); + + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->deleteMultiple(['key', 'key2']); + } + + public function testDeleteMultipleAsyncWithError(): void + { + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->deleteMultipleAsync(['key', 'key2']); + $this->expectException(KeyValueException::class); + $driver->commitAsync(); + } + + public function testGetMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + foreach ($driver->getMultiple([0 => 0xDEAD_BEEF]) as $_) { + // + } + } + + public function testSetMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->setMultiple([0 => 0xDEAD_BEEF]); + } + + public function testSetAsyncMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->setMultipleAsync([0 => 0xDEAD_BEEF]); + // Make sure not reachable + $this->assertSame(true, false); + $driver->commitAsync(); + } + + public function testDeleteMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->deleteMultiple([0 => 0xDEAD_BEEF]); + } + + public function testDeleteMultipleAsyncWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->deleteMultipleAsync([0 => 0xDEAD_BEEF]); + // Make sure not reachable + $this->assertSame(true, false); + $driver->commitAsync(); + } + + public function testImmutableWhileSwitchSerialization(): void + { + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), + ], new RawSerializerStub()); + + $decorated = $driver->withSerializer(new DefaultSerializer()); + + // Behaviour MUST NOT be changed + $this->assertSame($expected, $driver->get('key')); + } + + public function testErrorOnInvalidSerialization(): void + { + $this->expectException(SerializationException::class); + + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), + ], new RawSerializerStub()); + + $actual = $driver->withSerializer(new DefaultSerializer()) + ->get('key'); + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index 9803549..efc3f1a 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -8,6 +8,7 @@ use Spiral\RoadRunner\KeyValue\FactoryInterface; use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; use Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface; +use function random_bytes; class FactoryTest extends TestCase { @@ -19,19 +20,42 @@ private function factory(array $mapping = [], SerializerInterface $serializer = return new Factory($this->rpc($mapping), $serializer); } + /** + * @param array $mapping + */ + private function asyncFactory(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): FactoryInterface + { + return new Factory($this->asyncRPC($mapping), $serializer); + } + public function testFactoryCreation(): void { $this->expectNotToPerformAssertions(); $this->factory(); } + public function testAsyncFactoryCreation(): void + { + $this->expectNotToPerformAssertions(); + $this->asyncFactory(); + } + public function testSuccessSelectOfUnknownStorage(): void { - $name = \random_bytes(32); + $name = random_bytes(32); $driver = $this->factory() - ->select($name) - ; + ->select($name); + + $this->assertSame($name, $driver->getName()); + } + + public function testSuccessSelectOfUnknownStorageWithAsync(): void + { + $name = random_bytes(32); + + $driver = $this->asyncFactory() + ->select($name); $this->assertSame($name, $driver->getName()); } diff --git a/tests/Stub/AsyncFrozenDateCacheStub.php b/tests/Stub/AsyncFrozenDateCacheStub.php new file mode 100644 index 0000000..df09768 --- /dev/null +++ b/tests/Stub/AsyncFrozenDateCacheStub.php @@ -0,0 +1,31 @@ +date = $date; + + parent::__construct($rpc, $name, $serializer); + } + + final protected function now(): \DateTimeImmutable + { + return $this->date; + } +} diff --git a/tests/Stub/AsyncRPCConnectionStub.php b/tests/Stub/AsyncRPCConnectionStub.php new file mode 100644 index 0000000..096e766 --- /dev/null +++ b/tests/Stub/AsyncRPCConnectionStub.php @@ -0,0 +1,51 @@ + + */ + private array $responses = []; + private int $seq = 0; + + public function callIgnoreResponse(string $method, mixed $payload): void + { + $this->call($method, $payload); + } + + public function callAsync(string $method, mixed $payload): int + { + $seq = $this->seq; + $this->responses[$seq] = ['needsCall' => true, 'method' => $method, 'payload' => $payload]; + $this->seq++; + return $seq; + } + + public function hasResponse(int $seq): bool + { + return isset($this->responses[$seq]); + } + + public function hasResponses(array $seqs): array + { + return array_filter($seqs, $this->hasResponse(...)); + } + + public function getResponse(int $seq, mixed $options = null): mixed + { + if (isset($this->responses[$seq]['needsCall'])) { + $this->responses[$seq] = $this->call($this->responses[$seq]['method'], $this->responses[$seq]['payload'], $options); + } + + return $this->responses[$seq]; + } + + public function getResponses(array $seqs, mixed $options = null): iterable + { + return array_map(fn($seq) => $this->getResponse($seq, $options), $seqs); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 6299ff5..f4709c2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ namespace Spiral\RoadRunner\KeyValue\Tests; use PHPUnit\Framework\TestCase as BaseTestCase; +use Spiral\RoadRunner\KeyValue\Tests\Stub\AsyncRPCConnectionStub; use Spiral\RoadRunner\KeyValue\Tests\Stub\RPCConnectionStub; abstract class TestCase extends BaseTestCase @@ -18,6 +19,15 @@ protected function rpc(array $mapping = []): RPCConnectionStub return new RPCConnectionStub($mapping); } + /** + * @param array $mapping + * @return AsyncRPCConnectionStub + */ + protected function asyncRPC(array $mapping = []): AsyncRPCConnectionStub + { + return new AsyncRPCConnectionStub($mapping); + } + public static function valuesDataProvider(): array { return [