Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[redis] Use Redis transactional scripts to prevent message loss. #770

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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion pkg/redis/LuaScripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@

class LuaScripts
{
/**
* Lua script to receive message.
*
* KEYS[1] - The queue we are reading message
* KEYS[2] - The reserved queue we are moving message to
* ARGV[1] - Now timestamp
* ARGV[2] - Redelivery at timestamp
*
* @return string
*/
public static function receiveMessage(): string
{
return <<<'LUA'
local message = redis.call('RPOP', KEYS[1])

if (not message) then
return nil
end

local jsonSuccess, json = pcall(cjson.decode, message);

if (not jsonSuccess) then
return nil
end

if (nil == json['headers']['attempts']) then
json['headers']['attempts'] = 0
end

if (0 == json['headers']['attempts'] and nil ~= json['headers']['expires_at']) then
if (tonumber(ARGV[1]) > json['headers']['expires_at']) then
return nil
end
end

json['headers']['attempts'] = json['headers']['attempts'] + 1

message = cjson.encode(json)

redis.call('ZADD', KEYS[2], tonumber(ARGV[2]), message)

return message
LUA;
}

/**
* Get the Lua script to migrate expired messages back onto the queue.
*
Expand All @@ -15,7 +60,7 @@ class LuaScripts
*
* @return string
*/
public static function migrateExpired()
public static function migrateExpired(): string
{
return <<<'LUA'
-- Get all of the messages with an expired "score"...
Expand Down
104 changes: 104 additions & 0 deletions pkg/redis/RedisBlockingConsumeStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace Enqueue\Redis;

class RedisBlockingConsumeStrategy implements RedisConsumeStrategy
{
use RedisConsumerHelperTrait;

/**
* @var string[]
*/
private $queueNames;

/**
* @var RedisContext
*/
private $context;

public function __construct(RedisContext $context)
{
$this->context = $context;
}

/**
* @param RedisDestination[] $queues
* @param int $timeout
* @param int $redeliveryDelay
*
* @return RedisMessage|null
*/
public function receiveMessage(array $queues, int $timeout, int $redeliveryDelay): ?RedisMessage
{
$startAt = time();
$thisTimeout = (int) ceil($timeout / 1000);

if (null === $this->queueNames) {
$this->queueNames = [];
foreach ($queues as $queue) {
$this->queueNames[] = $queue->getName();
}
}

while ($thisTimeout > 0) {
$this->migrateExpiredMessages($this->context->getRedis(), $this->queueNames);

if (false == $result = $this->context->getRedis()->brpop($this->queueNames, $thisTimeout)) {
return null;
}

$this->pushQueueNameBack($this->queueNames, $result->getKey());

if ($message = $this->processResult($result, $redeliveryDelay)) {
return $message;
}

$thisTimeout -= time() - $startAt;
}

return null;
}

public function receiveMessageNoWait(RedisDestination $queue, int $redeliveryDelay): ?RedisMessage
{
$this->migrateExpiredMessages($this->context->getRedis(), [$queue->getName()]);

if ($result = $this->context->getRedis()->rpop($queue->getName())) {
return $this->processResult($result, $redeliveryDelay);
}

return null;
}

public function resetState()
{
$this->queueNames = null;
}

protected function processResult(RedisResult $result, int $redeliveryDelay): ?RedisMessage
{
$message = $this->context->getSerializer()->toMessage($result->getMessage());

$now = time();

if (0 === $message->getAttempts() && $expiresAt = $message->getHeader('expires_at')) {
if ($now > $expiresAt) {
return null;
}
}

$message->setHeader('attempts', $message->getAttempts() + 1);
$message->setRedelivered($message->getAttempts() > 1);
$message->setKey($result->getKey());
$message->setReservedKey($this->context->getSerializer()->toString($message));

$reservedQueue = $result->getKey().':reserved';
$redeliveryAt = $now + $redeliveryDelay;

$this->context->getRedis()->zadd($reservedQueue, $message->getReservedKey(), $redeliveryAt);

return $message;
}
}
9 changes: 7 additions & 2 deletions pkg/redis/RedisConnectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

class RedisConnectionFactory implements ConnectionFactory
{
const CONSUME_STRATEGY_BLOCKING = 'blocking';
const CONSUME_STRATEGY_NON_BLOCKING = 'non_blocking';

/**
* @var array
*/
Expand Down Expand Up @@ -40,6 +43,7 @@ class RedisConnectionFactory implements ConnectionFactory
* 'ssl' => could be any of http://fi2.php.net/manual/en/context.ssl.php#refsect1-context.ssl-options
* 'redelivery_delay' => Default 300 sec. Returns back message into the queue if message was not acknowledged or rejected after this delay.
* It could happen if consumer has failed with fatal error or even if message processing is slow and takes more than this time.
* 'consume_strategy' => [blocking|non_blocking]
* ].
*
* or
Expand Down Expand Up @@ -87,10 +91,10 @@ public function createContext(): Context
if ($this->config['lazy']) {
return new RedisContext(function () {
return $this->createRedis();
}, $this->config['redelivery_delay']);
}, $this->config);
}

return new RedisContext($this->createRedis(), $this->config['redelivery_delay']);
return new RedisContext($this->createRedis(), $this->config);
}

private function createRedis(): Redis
Expand Down Expand Up @@ -161,6 +165,7 @@ private function defaultConfig(): array
'predis_options' => null,
'ssl' => null,
'redelivery_delay' => 300,
'consume_strategy' => self::CONSUME_STRATEGY_NON_BLOCKING,
];
}
}
14 changes: 14 additions & 0 deletions pkg/redis/RedisConsumeStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Enqueue\Redis;

interface RedisConsumeStrategy
{
public function receiveMessage(array $queues, int $timeout, int $redeliveryDelay): ?RedisMessage;

public function receiveMessageNoWait(RedisDestination $queue, int $redeliveryDelay): ?RedisMessage;

public function resetState();
}
21 changes: 13 additions & 8 deletions pkg/redis/RedisConsumer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@

class RedisConsumer implements Consumer
{
use RedisConsumerHelperTrait;

/**
* @var RedisDestination
*/
Expand All @@ -23,15 +21,24 @@ class RedisConsumer implements Consumer
*/
private $context;

/**
* @var RedisConsumeStrategy
*/
private $consumeStrategy;

/**
* @var int
*/
private $redeliveryDelay = 300;

public function __construct(RedisContext $context, RedisDestination $queue)
{
public function __construct(
RedisContext $context,
RedisDestination $queue,
RedisConsumeStrategy $consumeStrategy
) {
$this->context = $context;
$this->queue = $queue;
$this->consumeStrategy = $consumeStrategy;
}

/**
Expand Down Expand Up @@ -63,8 +70,6 @@ public function getQueue(): Queue
*/
public function receive(int $timeout = 0): ?Message
{
$timeout = (int) ceil($timeout / 1000);

if ($timeout <= 0) {
while (true) {
if ($message = $this->receive(5000)) {
Expand All @@ -73,15 +78,15 @@ public function receive(int $timeout = 0): ?Message
}
}

return $this->receiveMessage([$this->queue], $timeout, $this->redeliveryDelay);
return $this->consumeStrategy->receiveMessage([$this->queue], $timeout, $this->redeliveryDelay);
}

/**
* @return RedisMessage
*/
public function receiveNoWait(): ?Message
{
return $this->receiveMessageNoWait($this->queue, $this->redeliveryDelay);
return $this->consumeStrategy->receiveMessageNoWait($this->queue, $this->redeliveryDelay);
}

/**
Expand Down
Loading