-
Notifications
You must be signed in to change notification settings - Fork 0
feat(queue): reconnect commands/publisher path on Redis outage #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| use PHPUnit\Framework\TestCase; | ||
| use Utopia\Queue\Broker\Redis as RedisBroker; | ||
| use Utopia\Queue\Connection; | ||
| use Utopia\Queue\Message; | ||
| use Utopia\Queue\Queue; | ||
|
|
||
| class RedisReconnectCallbackTest extends TestCase | ||
|
|
@@ -70,6 +71,97 @@ public function testReconnectSuccessCallbackReceivesAttemptCount(): void | |
| $this->assertSame($queue, $calls[0]['queue']); | ||
| $this->assertSame(1, $calls[0]['attempts']); | ||
| } | ||
|
|
||
| public function testEnqueueReconnectsAndSucceeds(): void | ||
| { | ||
| $queue = new Queue('publisher-reconnect'); | ||
| $connection = new FailOncePublisherConnection(); | ||
| $broker = new RedisBroker($connection, $connection); | ||
| $reconnects = []; | ||
| $successes = []; | ||
|
|
||
| $broker->setReconnectCallback(function (Queue $queue, \Throwable $error, int $attempt, int $sleepMs) use (&$reconnects): void { | ||
| $reconnects[] = ['attempt' => $attempt, 'error' => $error, 'sleepMs' => $sleepMs]; | ||
| }); | ||
| $broker->setReconnectSuccessCallback(function (Queue $queue, int $attempts) use (&$successes): void { | ||
| $successes[] = $attempts; | ||
| }); | ||
|
|
||
| $result = $broker->enqueue($queue, ['hello' => 'world']); | ||
|
|
||
| $this->assertTrue($result); | ||
| $this->assertSame(2, $connection->pushAttempts); | ||
| $this->assertSame(1, $connection->closeCalls); | ||
| $this->assertCount(1, $reconnects); | ||
| $this->assertSame(1, $reconnects[0]['attempt']); | ||
| $this->assertInstanceOf(\RedisException::class, $reconnects[0]['error']); | ||
| $this->assertGreaterThanOrEqual(0, $reconnects[0]['sleepMs']); | ||
| $this->assertLessThanOrEqual(100, $reconnects[0]['sleepMs']); | ||
| $this->assertSame([1], $successes); | ||
| } | ||
|
|
||
| public function testEnqueueThrowsAfterExhaustingReconnects(): void | ||
| { | ||
| $queue = new Queue('publisher-reconnect-exhausted'); | ||
| $connection = new AlwaysFailingPublisherConnection(); | ||
| $broker = new RedisBroker($connection, $connection); | ||
| $reconnects = 0; | ||
|
|
||
| $broker->setReconnectCallback(function () use (&$reconnects): void { | ||
| $reconnects++; | ||
| }); | ||
|
|
||
| try { | ||
| $broker->enqueue($queue, ['hello' => 'world']); | ||
| $this->fail('Expected RedisException after exhausting reconnects.'); | ||
| } catch (\RedisException $e) { | ||
| $this->assertSame('Redis is unavailable.', $e->getMessage()); | ||
| } | ||
|
|
||
| // COMMAND_MAX_ATTEMPTS (3): 3 pushes, 2 reconnects between them. | ||
| $this->assertSame(3, $connection->pushAttempts); | ||
| $this->assertSame(2, $reconnects); | ||
| } | ||
|
|
||
| public function testCommitReconnectsAndSucceeds(): void | ||
| { | ||
| $queue = new Queue('ack-reconnect'); | ||
| $connection = new FailOnceAckConnection(); | ||
| $broker = new RedisBroker($connection, $connection); | ||
| $reconnects = 0; | ||
|
|
||
| $broker->setReconnectCallback(function () use (&$reconnects): void { | ||
| $reconnects++; | ||
| }); | ||
|
|
||
| $broker->commit($queue, new Message(['pid' => 'job-1', 'queue' => 'ack-reconnect', 'timestamp' => 0, 'payload' => []])); | ||
|
|
||
| $this->assertSame(2, $connection->removeAttempts); | ||
| $this->assertSame(1, $connection->closeCalls); | ||
| $this->assertSame(1, $reconnects); | ||
|
Comment on lines
+120
to
+141
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| public function testEnqueueDoesNotRetryNonConnectionErrors(): void | ||
| { | ||
| $queue = new Queue('publisher-runtime-error'); | ||
| $connection = new RuntimeErrorPublisherConnection(); | ||
| $broker = new RedisBroker($connection, $connection); | ||
| $reconnects = 0; | ||
|
|
||
| $broker->setReconnectCallback(function () use (&$reconnects): void { | ||
| $reconnects++; | ||
| }); | ||
|
|
||
| try { | ||
| $broker->enqueue($queue, ['hello' => 'world']); | ||
| $this->fail('Expected the original RuntimeException to propagate.'); | ||
| } catch (\RuntimeException $e) { | ||
| $this->assertSame('boom', $e->getMessage()); | ||
| } | ||
|
|
||
| $this->assertSame(1, $connection->pushAttempts); | ||
| $this->assertSame(0, $reconnects); | ||
| } | ||
| } | ||
|
|
||
| class FailingRedisConnection implements Connection | ||
|
|
@@ -194,3 +286,71 @@ public function rightPopArray(string $queue, int $timeout): array|false | |
| return false; | ||
| } | ||
| } | ||
|
|
||
| class FailOncePublisherConnection extends FailingRedisConnection | ||
| { | ||
| public int $pushAttempts = 0; | ||
| public int $closeCalls = 0; | ||
|
|
||
| public function leftPushArray(string $queue, array $payload): bool | ||
| { | ||
| $this->pushAttempts++; | ||
|
|
||
| if ($this->pushAttempts === 1) { | ||
| throw new \RedisException('Redis is unavailable.'); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| public function close(): void | ||
| { | ||
| $this->closeCalls++; | ||
| } | ||
| } | ||
|
|
||
| class AlwaysFailingPublisherConnection extends FailingRedisConnection | ||
| { | ||
| public int $pushAttempts = 0; | ||
|
|
||
| public function leftPushArray(string $queue, array $payload): bool | ||
| { | ||
| $this->pushAttempts++; | ||
|
|
||
| throw new \RedisException('Redis is unavailable.'); | ||
| } | ||
| } | ||
|
|
||
| class FailOnceAckConnection extends FailingRedisConnection | ||
| { | ||
| public int $removeAttempts = 0; | ||
| public int $closeCalls = 0; | ||
|
|
||
| public function remove(string $key): bool | ||
| { | ||
| $this->removeAttempts++; | ||
|
|
||
| if ($this->removeAttempts === 1) { | ||
| throw new \RedisException('Redis is unavailable.'); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| public function close(): void | ||
| { | ||
| $this->closeCalls++; | ||
| } | ||
| } | ||
|
|
||
| class RuntimeErrorPublisherConnection extends FailingRedisConnection | ||
| { | ||
| public int $pushAttempts = 0; | ||
|
|
||
| public function leftPushArray(string $queue, array $payload): bool | ||
| { | ||
| $this->pushAttempts++; | ||
|
|
||
| throw new \RuntimeException('boom'); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the connection drops after
remove()has already been acknowledged by Redis but beforeincrement()is sent,executeCommand()retries the entire closure. On the second runremove()is a safe no-op, butincrement("{…}.success")anddecrement("{…}.processing")both fire a second time — permanently over-counting successes and under-counting processing. The same applies toreject()(increment("{…}.failed")/decrement("{…}.processing")).The root cause is that four non-idempotent Redis commands are batched into one closure that the outer retry loop treats as atomic. Wrapping each individual command in its own
executeCommand()call would avoid re-running already-successful counter mutations on reconnect. Alternatively, a Redis pipeline/MULTI-EXECtransaction would make the whole block genuinely atomic at the Redis level.