From b2da0c345d1b96c4327630ee9755d953efa36f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 15 Feb 2025 12:02:43 +0100 Subject: [PATCH] Support cloning `RedisClient` instance --- README.md | 37 +++++++++++++++++++++++++++++ src/RedisClient.php | 49 +++++++++++++++++++++++++++++++++++++++ tests/FunctionalTest.php | 42 +++++++++++++++++++++++++++++++++ tests/RedisClientTest.php | 32 +++++++++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/README.md b/README.md index 6652011..94416ff 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ It enables you to set and query its data or use its PubSub topics to react to in * [API](#api) * [RedisClient](#redisclient) * [__construct()](#__construct) + * [__clone()](#__clone) * [__call()](#__call) * [callAsync()](#callasync) * [end()](#end) @@ -415,6 +416,42 @@ $connector = new React\Socket\Connector([ $redis = new Clue\React\Redis\RedisClient('localhost', $connector); ``` +#### __clone() + +The `__clone()` method is a magic method in PHP that is called +automatically when a `RedisClient` instance is being cloned: + +```php +$original = new Clue\React\Redis\RedisClient($uri); +$redis = clone $original; +``` + +This method ensures the cloned client is created in a "fresh" state and +any connection state is reset on the clone, matching how a new instance +would start after returning from its constructor. Accordingly, the clone +will always start in an unconnected and unclosed state, with no event +listeners attached and ready to accept commands. Invoking any of the +[commands](#commands) will establish a new connection as usual: + +```php +$redis = clone $original; +$redis->set('name', 'Alice'); +``` + +This can be especially useful if the original connection is used for a +[PubSub subscription](#pubsub) or when using blocking commands or similar +and you need a control connection that is not affected by any of this. +Both instances will not be directly affected by any operations performed, +for example you can [`close()`](#close) either instance without also +closing the other. Similarly, you can also clone a fresh instance from a +closed state or overwrite a dead connection: + +```php +$redis->close(); +$redis = clone $redis; +$redis->set('name', 'Alice'); +``` + #### __call() The `__call(string $name, list $args): PromiseInterface` method can be used to diff --git a/src/RedisClient.php b/src/RedisClient.php index 32ced43..1c1bf87 100644 --- a/src/RedisClient.php +++ b/src/RedisClient.php @@ -90,6 +90,55 @@ public function __construct(string $uri, ?ConnectorInterface $connector = null) $this->factory = new Factory($connector); } + /** + * The `__clone()` method is a magic method in PHP that is called + * automatically when a `RedisClient` instance is being cloned: + * + * ```php + * $original = new Clue\React\Redis\RedisClient($uri); + * $redis = clone $original; + * ``` + * + * This method ensures the cloned client is created in a "fresh" state and + * any connection state is reset on the clone, matching how a new instance + * would start after returning from its constructor. Accordingly, the clone + * will always start in an unconnected and unclosed state, with no event + * listeners attached and ready to accept commands. Invoking any of the + * [commands](#commands) will establish a new connection as usual: + * + * ```php + * $redis = clone $original; + * $redis->set('name', 'Alice'); + * ``` + * + * This can be especially useful if the original connection is used for a + * [PubSub subscription](#pubsub) or when using blocking commands or similar + * and you need a control connection that is not affected by any of this. + * Both instances will not be directly affected by any operations performed, + * for example you can [`close()`](#close) either instance without also + * closing the other. Similarly, you can also clone a fresh instance from a + * closed state or overwrite a dead connection: + * + * ```php + * $redis->close(); + * $redis = clone $redis; + * $redis->set('name', 'Alice'); + * ``` + * + * @return void + * @throws void + */ + public function __clone() + { + $this->closed = false; + $this->promise = null; + $this->idleTimer = null; + $this->pending = 0; + $this->subscribed = []; + $this->psubscribed = []; + $this->removeAllListeners(); + } + /** * @return PromiseInterface */ diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 25bf829..5757eac 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -176,4 +176,46 @@ public function testClose(): void $redis->get('willBeRejectedRightAway')->then(null, $this->expectCallableOnce()); } + + public function testCloneWhenOriginalIsIdleReturnsClientThatWillCloseIndependently(): void + { + $prefix = 'test:' . mt_rand() . ':'; + $original = new RedisClient($this->uri); + + $this->assertNull(await($original->callAsync('GET', $prefix . 'doesnotexist'))); + + $redis = clone $original; + + $this->assertNull(await($redis->callAsync('GET', $prefix . 'doesnotexist'))); + } + + public function testCloneWhenOriginalIsPendingReturnsClientThatWillCloseIndependently(): void + { + $prefix = 'test:' . mt_rand() . ':'; + $original = new RedisClient($this->uri); + + $this->assertNull(await($original->callAsync('GET', $prefix . 'doesnotexist'))); + $promise = $original->callAsync('GET', $prefix . 'doesnotexist'); + + $redis = clone $original; + + $this->assertNull(await($redis->callAsync('GET', $prefix . 'doesnotexist'))); + $this->assertNull(await($promise)); + } + + public function testCloneReturnsClientNotAffectedByPubSubSubscriptions(): void + { + $prefix = 'test:' . mt_rand() . ':'; + $consumer = new RedisClient($this->uri); + + $consumer->on('message', $this->expectCallableNever()); + $consumer->on('pmessage', $this->expectCallableNever()); + await($consumer->callAsync('SUBSCRIBE', $prefix . 'demo')); + await($consumer->callAsync('PSUBSCRIBE', $prefix . '*')); + + $redis = clone $consumer; + $consumer->close(); + + $this->assertNull(await($redis->callAsync('GET', $prefix . 'doesnotexist'))); + } } diff --git a/tests/RedisClientTest.php b/tests/RedisClientTest.php index 785bfbc..b17e148 100644 --- a/tests/RedisClientTest.php +++ b/tests/RedisClientTest.php @@ -836,4 +836,36 @@ public function testBlpopWillRejectWhenUnderlyingClientClosesWhileWaitingForResp $promise->then(null, $this->expectCallableOnceWith($e)); } + + public function testCloneClosedClientReturnsClientThatWillCreateNewConnectionForFirstCommand(): void + { + $this->redis->close(); + + $redis = clone $this->redis; + + $deferred = new Deferred($this->expectCallableNever()); + $this->factory->expects($this->once())->method('createClient')->willReturn($deferred->promise()); + + $promise = $redis->callAsync('PING'); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testCloneClientReturnsClientThatWillNotBeAffectedByOldClientClosing(): void + { + $this->redis->on('close', $this->expectCallableOnce()); + + $redis = clone $this->redis; + + $this->assertEquals([], $redis->listeners()); + + $deferred = new Deferred($this->expectCallableNever()); + $this->factory->expects($this->once())->method('createClient')->willReturn($deferred->promise()); + + $promise = $redis->callAsync('PING'); + + $this->redis->close(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } }