Reconnect
This commit is contained in:
@ -7,6 +7,8 @@ namespace Nsq;
|
|||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Nsq\Exception\ConnectionFail;
|
use Nsq\Exception\ConnectionFail;
|
||||||
use Nsq\Exception\UnexpectedResponse;
|
use Nsq\Exception\UnexpectedResponse;
|
||||||
|
use Nsq\Reconnect\ExponentialStrategy;
|
||||||
|
use Nsq\Reconnect\ReconnectStrategy;
|
||||||
use PHPinnacle\Buffer\ByteBuffer;
|
use PHPinnacle\Buffer\ByteBuffer;
|
||||||
use Psr\Log\LoggerAwareTrait;
|
use Psr\Log\LoggerAwareTrait;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@ -14,7 +16,7 @@ use Psr\Log\NullLogger;
|
|||||||
use Socket\Raw\Exception;
|
use Socket\Raw\Exception;
|
||||||
use Socket\Raw\Factory;
|
use Socket\Raw\Factory;
|
||||||
use Socket\Raw\Socket;
|
use Socket\Raw\Socket;
|
||||||
use Throwable;
|
use function addcslashes;
|
||||||
use function json_encode;
|
use function json_encode;
|
||||||
use function pack;
|
use function pack;
|
||||||
use const JSON_FORCE_OBJECT;
|
use const JSON_FORCE_OBJECT;
|
||||||
@ -32,7 +34,7 @@ abstract class Connection
|
|||||||
|
|
||||||
private ?Socket $socket = null;
|
private ?Socket $socket = null;
|
||||||
|
|
||||||
private bool $closed = false;
|
private ReconnectStrategy $reconnect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array{
|
* @var array{
|
||||||
@ -51,37 +53,44 @@ abstract class Connection
|
|||||||
string $userAgent = null,
|
string $userAgent = null,
|
||||||
int $heartbeatInterval = null,
|
int $heartbeatInterval = null,
|
||||||
int $sampleRate = 0,
|
int $sampleRate = 0,
|
||||||
|
ReconnectStrategy $reconnectStrategy = null,
|
||||||
LoggerInterface $logger = null,
|
LoggerInterface $logger = null,
|
||||||
) {
|
) {
|
||||||
$this->address = $address;
|
$this->address = $address;
|
||||||
|
|
||||||
$this->features = [
|
$this->features = [
|
||||||
'client_id' => $clientId ?? '',
|
'client_id' => $clientId ?? '',
|
||||||
'hostname' => $hostname ?? (static fn (mixed $host): string => \is_string($host) ? $host : '')(gethostname()),
|
'hostname' => $hostname ?? (static fn (mixed $h): string => \is_string($h) ? $h : '')(gethostname()),
|
||||||
'user_agent' => $userAgent ?? 'nsqphp/'.InstalledVersions::getPrettyVersion('nsq/nsq'),
|
'user_agent' => $userAgent ?? 'nsqphp/'.InstalledVersions::getPrettyVersion('nsq/nsq'),
|
||||||
'heartbeat_interval' => $heartbeatInterval,
|
'heartbeat_interval' => $heartbeatInterval,
|
||||||
'sample_rate' => $sampleRate,
|
'sample_rate' => $sampleRate,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->logger = $logger ?? new NullLogger();
|
$this->logger = $logger ?? new NullLogger();
|
||||||
|
$this->reconnect = $reconnectStrategy ?? new ExponentialStrategy(logger: $this->logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function connect(): void
|
public function connect(): void
|
||||||
{
|
{
|
||||||
|
$this->reconnect->connect(function (): void {
|
||||||
try {
|
try {
|
||||||
$this->socket = (new Factory())->createClient($this->address);
|
$this->socket = (new Factory())->createClient($this->address);
|
||||||
} catch (Exception $e) {
|
}
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
catch (Exception $e) {
|
||||||
|
$this->logger->error('Connecting to {address} failed.', ['address' => $this->address]);
|
||||||
|
|
||||||
throw ConnectionFail::fromThrowable($e);
|
throw ConnectionFail::fromThrowable($e);
|
||||||
}
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
$this->send(' V2');
|
$this->send(' V2');
|
||||||
|
|
||||||
$body = json_encode($this->features, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT);
|
$body = json_encode($this->features, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT);
|
||||||
$size = pack('N', \strlen($body));
|
$size = pack('N', \strlen($body));
|
||||||
|
|
||||||
$this->logger->info('Feature Negotiation: '.http_build_query($this->features));
|
|
||||||
|
|
||||||
$this->sendWithResponse('IDENTIFY '.PHP_EOL.$size.$body)->okOrFail();
|
$this->sendWithResponse('IDENTIFY '.PHP_EOL.$size.$body)->okOrFail();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,26 +98,26 @@ abstract class Connection
|
|||||||
*/
|
*/
|
||||||
public function disconnect(): void
|
public function disconnect(): void
|
||||||
{
|
{
|
||||||
if ($this->closed) {
|
if (null === $this->socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->send('CLS'.PHP_EOL);
|
$this->socket->write('CLS'.PHP_EOL);
|
||||||
|
|
||||||
if (null !== $this->socket) {
|
|
||||||
$this->socket->close();
|
$this->socket->close();
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
// @codeCoverageIgnoreStart
|
||||||
|
catch (Exception $e) {
|
||||||
$this->logger->debug($e->getMessage(), ['exception' => $e]);
|
$this->logger->debug($e->getMessage(), ['exception' => $e]);
|
||||||
}
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
$this->closed = true;
|
$this->socket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isClosed(): bool
|
public function isReady(): bool
|
||||||
{
|
{
|
||||||
return $this->closed;
|
return null !== $this->socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,28 +138,62 @@ abstract class Connection
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$socket->write($buffer);
|
$socket->write($buffer);
|
||||||
} catch (Exception $e) {
|
}
|
||||||
$this->closed = true;
|
// @codeCoverageIgnoreStart
|
||||||
|
catch (Exception $e) {
|
||||||
|
$this->disconnect();
|
||||||
|
|
||||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||||
|
|
||||||
throw ConnectionFail::fromThrowable($e);
|
throw ConnectionFail::fromThrowable($e);
|
||||||
}
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasMessage(float $timeout = 0): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return false !== $this->socket()->selectRead($timeout);
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
catch (Exception $e) {
|
||||||
|
$this->disconnect();
|
||||||
|
|
||||||
|
throw ConnectionFail::fromThrowable($e);
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
}
|
||||||
|
|
||||||
public function receive(float $timeout = 0): ?Response
|
public function receive(float $timeout = 0): ?Response
|
||||||
{
|
{
|
||||||
$socket = $this->socket();
|
$socket = $this->socket();
|
||||||
$deadline = microtime(true) + $timeout;
|
$deadline = microtime(true) + $timeout;
|
||||||
|
|
||||||
if (false === $socket->selectRead($timeout)) {
|
if (!$this->hasMessage($timeout)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$size = (new ByteBuffer($socket->read(Bytes::BYTES_SIZE)))->consumeUint32();
|
try {
|
||||||
$response = new Response(new ByteBuffer($socket->read($size)));
|
$size = $socket->read(Bytes::BYTES_SIZE);
|
||||||
|
|
||||||
|
if ('' === $size) {
|
||||||
|
$this->disconnect();
|
||||||
|
|
||||||
|
throw new ConnectionFail('Probably connection lost');
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer = new ByteBuffer(
|
||||||
|
$socket->read(
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
unpack('N', $size)[1]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->debug('Received buffer: '.addcslashes($buffer->bytes(), PHP_EOL));
|
||||||
|
|
||||||
|
$response = new Response($buffer);
|
||||||
|
|
||||||
if ($response->isHeartBeat()) {
|
if ($response->isHeartBeat()) {
|
||||||
$this->send('NOP'.PHP_EOL);
|
$this->send('NOP'.PHP_EOL);
|
||||||
@ -159,33 +202,31 @@ abstract class Connection
|
|||||||
($currentTime = microtime(true)) > $deadline ? 0 : $deadline - $currentTime
|
($currentTime = microtime(true)) > $deadline ? 0 : $deadline - $currentTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
catch (Exception $e) {
|
||||||
|
$this->disconnect();
|
||||||
|
|
||||||
|
throw ConnectionFail::fromThrowable($e);
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sendWithResponse(string $buffer): Response
|
protected function sendWithResponse(string $buffer): Response
|
||||||
{
|
{
|
||||||
$this->send($buffer);
|
return $this
|
||||||
|
->send($buffer)
|
||||||
$response = $this->receive(0.1);
|
->receive(1) ?? throw UnexpectedResponse::null();
|
||||||
|
|
||||||
if (null === $response) {
|
|
||||||
throw new UnexpectedResponse('Response was expected, but null received.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function socket(): Socket
|
private function socket(): Socket
|
||||||
{
|
{
|
||||||
if ($this->closed) {
|
|
||||||
throw new ConnectionFail('This connection is closed, create new one.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $this->socket) {
|
if (null === $this->socket) {
|
||||||
$this->connect();
|
$this->connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->socket;
|
return $this->socket ?? throw new ConnectionFail('This connection is closed, create new one.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,4 +8,11 @@ use RuntimeException;
|
|||||||
|
|
||||||
final class UnexpectedResponse extends RuntimeException implements NsqException
|
final class UnexpectedResponse extends RuntimeException implements NsqException
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @codeCoverageIgnore
|
||||||
|
*/
|
||||||
|
public static function null(): self
|
||||||
|
{
|
||||||
|
return new self('Response was expected, but null received.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
64
src/Reconnect/ExponentialStrategy.php
Normal file
64
src/Reconnect/ExponentialStrategy.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Nsq\Reconnect;
|
||||||
|
|
||||||
|
use Nsq\Exception\ConnectionFail;
|
||||||
|
use Psr\Log\LoggerAwareTrait;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Throwable;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ExponentialStrategy implements ReconnectStrategy
|
||||||
|
{
|
||||||
|
use LoggerAwareTrait;
|
||||||
|
|
||||||
|
private int $delay;
|
||||||
|
|
||||||
|
private int $nextTryAfter;
|
||||||
|
|
||||||
|
private int $attempt = 0;
|
||||||
|
|
||||||
|
private TimeProvider $timeProvider;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private int $minDelay = 8,
|
||||||
|
private int $maxDelay = 32,
|
||||||
|
TimeProvider $timeProvider = null,
|
||||||
|
LoggerInterface $logger = null,
|
||||||
|
) {
|
||||||
|
$this->delay = 0;
|
||||||
|
$this->timeProvider = $timeProvider ?? new RealTimeProvider();
|
||||||
|
$this->nextTryAfter = $this->timeProvider->time();
|
||||||
|
$this->logger = $logger ?? new NullLogger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function connect(callable $callable): void
|
||||||
|
{
|
||||||
|
$currentTime = $this->timeProvider->time();
|
||||||
|
|
||||||
|
if ($currentTime < $this->nextTryAfter) {
|
||||||
|
throw new ConnectionFail('Time to reconnect has not yet come');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callable();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$nextDelay = 0 === $this->delay ? $this->minDelay : $this->delay * 2;
|
||||||
|
$this->delay = $nextDelay > $this->maxDelay ? $this->maxDelay : $nextDelay;
|
||||||
|
$this->nextTryAfter = $currentTime + $this->delay;
|
||||||
|
|
||||||
|
$this->logger->info(sprintf('Reconnect #%s after %ss', ++$this->attempt, $this->delay));
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->delay = 0;
|
||||||
|
$this->attempt = 0;
|
||||||
|
}
|
||||||
|
}
|
13
src/Reconnect/RealTimeProvider.php
Normal file
13
src/Reconnect/RealTimeProvider.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Nsq\Reconnect;
|
||||||
|
|
||||||
|
final class RealTimeProvider implements TimeProvider
|
||||||
|
{
|
||||||
|
public function time(): int
|
||||||
|
{
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
}
|
15
src/Reconnect/ReconnectStrategy.php
Normal file
15
src/Reconnect/ReconnectStrategy.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Nsq\Reconnect;
|
||||||
|
|
||||||
|
use Nsq\Exception\ConnectionFail;
|
||||||
|
|
||||||
|
interface ReconnectStrategy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws ConnectionFail
|
||||||
|
*/
|
||||||
|
public function connect(callable $callable): void;
|
||||||
|
}
|
10
src/Reconnect/TimeProvider.php
Normal file
10
src/Reconnect/TimeProvider.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Nsq\Reconnect;
|
||||||
|
|
||||||
|
interface TimeProvider
|
||||||
|
{
|
||||||
|
public function time(): int;
|
||||||
|
}
|
@ -33,11 +33,15 @@ final class Response
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self::TYPE_RESPONSE !== $this->type) {
|
if (self::TYPE_RESPONSE !== $this->type) {
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
throw new UnexpectedResponse(sprintf('"%s" type expected, but "%s" received.', self::TYPE_RESPONSE, $this->type));
|
throw new UnexpectedResponse(sprintf('"%s" type expected, but "%s" received.', self::TYPE_RESPONSE, $this->type));
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::OK !== $this->buffer->bytes()) {
|
if (self::OK !== $this->buffer->bytes()) {
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
throw new UnexpectedResponse(sprintf('OK response expected, but "%s" received.', $this->buffer->bytes()));
|
throw new UnexpectedResponse(sprintf('OK response expected, but "%s" received.', $this->buffer->bytes()));
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +53,9 @@ final class Response
|
|||||||
public function toMessage(Consumer $reader): Message
|
public function toMessage(Consumer $reader): Message
|
||||||
{
|
{
|
||||||
if (self::TYPE_MESSAGE !== $this->type) {
|
if (self::TYPE_MESSAGE !== $this->type) {
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
throw new UnexpectedResponse(sprintf('Expecting "%s" type, but NSQ return: "%s"', self::TYPE_MESSAGE, $this->type));
|
throw new UnexpectedResponse(sprintf('Expecting "%s" type, but NSQ return: "%s"', self::TYPE_MESSAGE, $this->type));
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
$buffer = new ByteBuffer($this->buffer->bytes());
|
$buffer = new ByteBuffer($this->buffer->bytes());
|
||||||
|
90
tests/ExponentialStrategyTest.php
Normal file
90
tests/ExponentialStrategyTest.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Nsq\Exception\ConnectionFail;
|
||||||
|
use Nsq\Reconnect\ExponentialStrategy;
|
||||||
|
use Nsq\Reconnect\TimeProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ExponentialStrategyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTimeNotYetCome(): void
|
||||||
|
{
|
||||||
|
$timeProvider = new FakeTimeProvider();
|
||||||
|
$strategy = new ExponentialStrategy(
|
||||||
|
minDelay: 8,
|
||||||
|
maxDelay: 32,
|
||||||
|
timeProvider: $timeProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
$successConnect = static function (int $time = null) use ($strategy, $timeProvider): void {
|
||||||
|
$timeProvider($time);
|
||||||
|
|
||||||
|
$strategy->connect(static function (): void {
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$failConnect = static function (int $time = null) use ($strategy, $timeProvider): void {
|
||||||
|
$timeProvider($time);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$strategy->connect(function (): void {
|
||||||
|
throw new ConnectionFail('Time come but failed');
|
||||||
|
});
|
||||||
|
} catch (ConnectionFail $e) {
|
||||||
|
self::assertSame('Time come but failed', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('Expecting exception with message "Time come but failed"');
|
||||||
|
};
|
||||||
|
$timeNotCome = static function (int $time = null) use ($strategy, $timeProvider): void {
|
||||||
|
$timeProvider($time);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$strategy->connect(function (): void {
|
||||||
|
throw new ConnectionFail('');
|
||||||
|
});
|
||||||
|
} catch (ConnectionFail $e) {
|
||||||
|
self::assertSame('Time to reconnect has not yet come', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fail('Was expecting exception with message "Time to reconnect has not yet come"');
|
||||||
|
};
|
||||||
|
|
||||||
|
$failConnect(0);
|
||||||
|
$timeNotCome(7);
|
||||||
|
$failConnect(8);
|
||||||
|
$timeNotCome(22);
|
||||||
|
$timeNotCome(13);
|
||||||
|
$failConnect(24);
|
||||||
|
$successConnect(56);
|
||||||
|
$failConnect();
|
||||||
|
$timeNotCome();
|
||||||
|
$timeNotCome(63);
|
||||||
|
$failConnect(64);
|
||||||
|
|
||||||
|
$this->expectException(ConnectionFail::class);
|
||||||
|
$this->expectExceptionMessage('Time to reconnect has not yet come');
|
||||||
|
|
||||||
|
$successConnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTimeProvider implements TimeProvider
|
||||||
|
{
|
||||||
|
public int $time = 0;
|
||||||
|
|
||||||
|
public function time(): int
|
||||||
|
{
|
||||||
|
return $this->time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(int $time = null): void
|
||||||
|
{
|
||||||
|
$this->time = $time ?? $this->time;
|
||||||
|
}
|
||||||
|
}
|
@ -75,8 +75,8 @@ final class NsqTest extends TestCase
|
|||||||
$message->touch();
|
$message->touch();
|
||||||
$message->finish();
|
$message->finish();
|
||||||
|
|
||||||
self::assertFalse($consumer->isClosed());
|
self::assertTrue($consumer->isReady());
|
||||||
$generator->send(Subscriber::STOP);
|
$generator->send(Subscriber::STOP);
|
||||||
self::assertTrue($consumer->isClosed());
|
self::assertFalse($consumer->isReady());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user