Skip to content

Commit 777e34a

Browse files
authored
Retry connection on socket error (#106)
* WIP retry connection on socket error currently does not handle fwrite failure, only socket read * improved implementation * cleanup * add test to verify number of retries
1 parent 8af4acd commit 777e34a

File tree

4 files changed

+156
-6
lines changed

4 files changed

+156
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Yii Framework 2 redis extension Change Log
77
- Bug #114: Fixed ActiveQuery `not between` and `not` conditions which where not working correctly (cebe, ak1987)
88
- Bug #123: Fixed ActiveQuery to work with negative limit values, which are used in ActiveDataProvider for the count query (cebe)
99
- Enh #9: Added orderBy support to redis ActiveQuery and LuaScriptBuilder (valinurovam)
10+
- Enh #91: Added option to retry connection after failing to communicate with redis server on stale socket (cebe)
11+
- Enh #106: Improved handling of connection errors and introduced `yii\redis\SocketException` for these (cebe)
1012
- Chg #127: Added PHP 7.2 compatibility (brandonkelly)
1113

1214

Connection.php

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,12 @@ class Connection extends Component
239239

240240
/**
241241
* @var string the hostname or ip address to use for connecting to the redis server. Defaults to 'localhost'.
242-
* If [[unixSocket]] is specified, hostname and port will be ignored.
242+
* If [[unixSocket]] is specified, hostname and [[port]] will be ignored.
243243
*/
244244
public $hostname = 'localhost';
245245
/**
246246
* @var integer the port to use for connecting to the redis server. Default port is 6379.
247-
* If [[unixSocket]] is specified, hostname and port will be ignored.
247+
* If [[unixSocket]] is specified, [[hostname]] and port will be ignored.
248248
*/
249249
public $port = 6379;
250250
/**
@@ -279,6 +279,13 @@ class Connection extends Component
279279
* @since 2.0.5
280280
*/
281281
public $socketClientFlags = STREAM_CLIENT_CONNECT;
282+
/**
283+
* @var integer The number of times a command execution should be retried when a connection failure occurs.
284+
* This is used in [[executeCommand()]] when a [[SocketException]] is thrown.
285+
* Defaults to 0 meaning no retries on failure.
286+
* @since 2.0.7
287+
*/
288+
public $retries = 0;
282289
/**
283290
* @var array List of available redis commands.
284291
* @see http://redis.io/commands
@@ -555,7 +562,11 @@ public function close()
555562
if ($this->_socket !== false) {
556563
$connection = ($this->unixSocket ?: $this->hostname . ':' . $this->port) . ', database=' . $this->database;
557564
\Yii::trace('Closing DB connection: ' . $connection, __METHOD__);
558-
$this->executeCommand('QUIT');
565+
try {
566+
$this->executeCommand('QUIT');
567+
} catch (SocketException $e) {
568+
// ignore errors when quitting a closed connection
569+
}
559570
stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR);
560571
$this->_socket = false;
561572
}
@@ -647,8 +658,38 @@ public function executeCommand($name, $params = [])
647658
}
648659

649660
\Yii::trace("Executing Redis Command: {$name}", __METHOD__);
650-
fwrite($this->_socket, $command);
661+
if ($this->retries > 0) {
662+
$tries = $this->retries;
663+
while ($tries-- > 0) {
664+
try {
665+
return $this->sendCommandInternal($command, $params);
666+
} catch (SocketException $e) {
667+
\Yii::error($e, __METHOD__);
668+
// backup retries, fail on commands that fail inside here
669+
$retries = $this->retries;
670+
$this->retries = 0;
671+
$this->close();
672+
$this->open();
673+
$this->retries = $retries;
674+
}
675+
}
676+
}
677+
return $this->sendCommandInternal($command, $params);
678+
}
651679

680+
/**
681+
* Sends RAW command string to the server.
682+
* @throws SocketException on connection error.
683+
*/
684+
private function sendCommandInternal($command, $params)
685+
{
686+
$written = @fwrite($this->_socket, $command);
687+
if ($written === false) {
688+
throw new SocketException("Failed to write to socket.\nRedis command was: " . $command);
689+
}
690+
if ($written !== ($len = mb_strlen($command, '8bit'))) {
691+
throw new SocketException("Failed to write to socket. $written of $len bytes written.\nRedis command was: " . $command);
692+
}
652693
return $this->parseResponse(implode(' ', $params));
653694
}
654695

@@ -660,7 +701,7 @@ public function executeCommand($name, $params = [])
660701
private function parseResponse($command)
661702
{
662703
if (($line = fgets($this->_socket)) === false) {
663-
throw new Exception("Failed to read from socket.\nRedis command was: " . $command);
704+
throw new SocketException("Failed to read from socket.\nRedis command was: " . $command);
664705
}
665706
$type = $line[0];
666707
$line = mb_substr($line, 1, -2, '8bit');
@@ -684,7 +725,7 @@ private function parseResponse($command)
684725
$data = '';
685726
while ($length > 0) {
686727
if (($block = fread($this->_socket, $length)) === false) {
687-
throw new Exception("Failed to read from socket.\nRedis command was: " . $command);
728+
throw new SocketException("Failed to read from socket.\nRedis command was: " . $command);
688729
}
689730
$data .= $block;
690731
$length -= mb_strlen($block, '8bit');

SocketException.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
/**
3+
* @link http://www.yiiframework.com/
4+
* @copyright Copyright (c) 2008 Yii Software LLC
5+
* @license http://www.yiiframework.com/license/
6+
*/
7+
8+
namespace yii\redis;
9+
10+
use yii\db\Exception;
11+
12+
/**
13+
* SocketException indicates a socket connection failure in [[Connection]].
14+
* @since 2.0.7
15+
*/
16+
class SocketException extends Exception
17+
{
18+
/**
19+
* @return string the user-friendly name of this exception
20+
*/
21+
public function getName()
22+
{
23+
return 'Redis Socket Exception';
24+
}
25+
}

tests/RedisConnectionTest.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
<?php
22

33
namespace yiiunit\extensions\redis;
4+
use Yii;
5+
use yii\helpers\ArrayHelper;
6+
use yii\log\Logger;
7+
use yii\redis\Connection;
8+
use yii\redis\SocketException;
49

510
/**
611
* @group redis
712
*/
813
class ConnectionTest extends TestCase
914
{
15+
protected function tearDown()
16+
{
17+
$this->getConnection(false)->configSet('timeout', 0);
18+
parent::tearDown();
19+
}
20+
1021
/**
1122
* test connection to redis and selection of db
1223
*/
@@ -87,6 +98,77 @@ public function testSerialize()
8798
$this->assertTrue($db2->ping());
8899
}
89100

101+
public function testConnectionTimeout()
102+
{
103+
$db = $this->getConnection(false);
104+
$db->configSet('timeout', 1);
105+
$this->assertTrue($db->ping());
106+
sleep(1);
107+
$this->assertTrue($db->ping());
108+
sleep(2);
109+
if (method_exists($this, 'setExpectedException')) {
110+
$this->setExpectedException('\yii\redis\SocketException');
111+
} else {
112+
$this->expectException('\yii\redis\SocketException');
113+
}
114+
$this->assertTrue($db->ping());
115+
}
116+
117+
public function testConnectionTimeoutRetry()
118+
{
119+
$logger = new Logger();
120+
Yii::setLogger($logger);
121+
122+
$db = $this->getConnection(false);
123+
$db->retries = 1;
124+
$db->configSet('timeout', 1);
125+
$this->assertCount(3, $logger->messages, 'log of connection and init commands.');
126+
127+
$this->assertTrue($db->ping());
128+
$this->assertCount(4, $logger->messages, 'log +1 ping command.');
129+
usleep(500000); // 500ms
130+
131+
$this->assertTrue($db->ping());
132+
$this->assertCount(5, $logger->messages, 'log +1 ping command.');
133+
sleep(2);
134+
135+
// reconnect should happen here
136+
137+
$this->assertTrue($db->ping());
138+
$this->assertCount(11, $logger->messages, 'log +1 ping command, and reconnection.'
139+
. print_r(array_map(function($s) { return (string) $s; }, ArrayHelper::getColumn($logger->messages, 0)), true));
140+
}
141+
142+
/**
143+
* Retry connecting 2 times
144+
*/
145+
public function testConnectionTimeoutRetryCount()
146+
{
147+
$logger = new Logger();
148+
Yii::setLogger($logger);
149+
150+
$db = $this->getConnection(false);
151+
$db->retries = 2;
152+
$db->configSet('timeout', 1);
153+
$db->on(Connection::EVENT_AFTER_OPEN, function() {
154+
// sleep 2 seconds after connect to make every command time out
155+
sleep(2);
156+
});
157+
$this->assertCount(3, $logger->messages, 'log of connection and init commands.');
158+
159+
$exception = false;
160+
try {
161+
// should try to reconnect 2 times, before finally failing
162+
// results in 3 times sending the PING command to redis
163+
sleep(2);
164+
$db->ping();
165+
} catch (SocketException $e) {
166+
$exception = true;
167+
}
168+
$this->assertTrue($exception, 'SocketException should have been thrown.');
169+
$this->assertCount(14, $logger->messages, 'log +1 ping command, and reconnection.'
170+
. print_r(array_map(function($s) { return (string) $s; }, ArrayHelper::getColumn($logger->messages, 0)), true));
171+
}
90172

91173
/**
92174
* https://github.com/yiisoft/yii2/issues/4745

0 commit comments

Comments
 (0)