|
| 1 | +<?php namespace App\Redis; |
| 2 | +/** |
| 3 | + * Copyright 2026 OpenStack Foundation |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | + * Unless required by applicable law or agreed to in writing, software |
| 9 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 10 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 11 | + * See the License for the specific language governing permissions and |
| 12 | + * limitations under the License. |
| 13 | + **/ |
| 14 | + |
| 15 | +use Illuminate\Redis\Connections\PredisConnection; |
| 16 | +use Illuminate\Support\Facades\Log; |
| 17 | +use Predis\Connection\ConnectionException; |
| 18 | + |
| 19 | +/** |
| 20 | + * Class ResilientPredisConnection |
| 21 | + * |
| 22 | + * Extends the default PredisConnection to add automatic retry with |
| 23 | + * reconnect for idempotent Redis commands on transient connection failures. |
| 24 | + * |
| 25 | + * Non-idempotent commands (INCR, LPUSH, RPUSH, EVAL, etc.) are never |
| 26 | + * retried because the command may have already been executed on the |
| 27 | + * server before the read-side of the connection failed. |
| 28 | + */ |
| 29 | +class ResilientPredisConnection extends PredisConnection |
| 30 | +{ |
| 31 | + private int $retryLimit; |
| 32 | + |
| 33 | + private int $retryDelay; |
| 34 | + |
| 35 | + /** |
| 36 | + * Commands that are safe to retry after a connection failure. |
| 37 | + * A command is safe when executing it twice produces the same result. |
| 38 | + */ |
| 39 | + private const IDEMPOTENT_COMMANDS = [ |
| 40 | + // reads |
| 41 | + 'GET', 'MGET', 'HGET', 'HGETALL', 'HMGET', 'HEXISTS', 'HLEN', 'HKEYS', 'HVALS', |
| 42 | + 'LLEN', 'LRANGE', 'LINDEX', |
| 43 | + 'SCARD', 'SMEMBERS', 'SISMEMBER', |
| 44 | + 'ZCARD', 'ZCOUNT', 'ZRANGE', 'ZRANGEBYSCORE', 'ZREVRANGEBYSCORE', 'ZSCORE', 'ZRANK', 'ZREVRANK', |
| 45 | + 'EXISTS', 'TYPE', 'TTL', 'PTTL', 'KEYS', 'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN', |
| 46 | + 'INFO', 'PING', 'DBSIZE', 'TIME', 'STRLEN', 'GETRANGE', |
| 47 | + // idempotent writes |
| 48 | + 'SET', 'SETEX', 'PSETEX', 'MSET', 'SETNX', 'GETSET', |
| 49 | + 'HSET', 'HMSET', 'HSETNX', |
| 50 | + 'DEL', 'HDEL', 'UNLINK', |
| 51 | + 'EXPIRE', 'EXPIREAT', 'PEXPIRE', 'PEXPIREAT', 'PERSIST', |
| 52 | + 'SADD', 'SREM', |
| 53 | + 'ZADD', 'ZREM', 'ZREMRANGEBYSCORE', 'ZREMRANGEBYRANK', |
| 54 | + ]; |
| 55 | + |
| 56 | + /** |
| 57 | + * @param \Predis\Client $client |
| 58 | + * @param int $retryLimit Max number of retries (0 = no retries, behaves like stock PredisConnection) |
| 59 | + * @param int $retryDelay Base delay in milliseconds between retries (doubled each attempt) |
| 60 | + */ |
| 61 | + public function __construct($client, int $retryLimit = 2, int $retryDelay = 50) |
| 62 | + { |
| 63 | + parent::__construct($client); |
| 64 | + $this->retryLimit = $retryLimit; |
| 65 | + $this->retryDelay = $retryDelay; |
| 66 | + } |
| 67 | + |
| 68 | + /** |
| 69 | + * @inheritdoc |
| 70 | + */ |
| 71 | + public function command($method, array $parameters = []) |
| 72 | + { |
| 73 | + try { |
| 74 | + return parent::command($method, $parameters); |
| 75 | + } catch (ConnectionException $e) { |
| 76 | + if (!$this->isIdempotent($method)) { |
| 77 | + throw $e; |
| 78 | + } |
| 79 | + return $this->retryCommand($method, $parameters, $e); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + /** |
| 84 | + * Retry an idempotent command after reconnecting. |
| 85 | + */ |
| 86 | + private function retryCommand(string $method, array $parameters, ConnectionException $previous): mixed |
| 87 | + { |
| 88 | + $lastException = $previous; |
| 89 | + |
| 90 | + for ($attempt = 1; $attempt <= $this->retryLimit; $attempt++) { |
| 91 | + $delay = $this->retryDelay * (2 ** ($attempt - 1)); // exponential back-off |
| 92 | + |
| 93 | + Log::warning('ResilientPredisConnection: retrying command', [ |
| 94 | + 'command' => strtoupper($method), |
| 95 | + 'attempt' => $attempt, |
| 96 | + 'max_retries' => $this->retryLimit, |
| 97 | + 'delay_ms' => $delay, |
| 98 | + 'error' => $previous->getMessage(), |
| 99 | + ]); |
| 100 | + |
| 101 | + usleep($delay * 1000); |
| 102 | + |
| 103 | + try { |
| 104 | + $this->client->disconnect(); |
| 105 | + |
| 106 | + return parent::command($method, $parameters); |
| 107 | + } catch (ConnectionException $e) { |
| 108 | + $lastException = $e; |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + Log::error('ResilientPredisConnection: all retries exhausted', [ |
| 113 | + 'command' => strtoupper($method), |
| 114 | + 'retries' => $this->retryLimit, |
| 115 | + 'error' => $lastException->getMessage(), |
| 116 | + ]); |
| 117 | + |
| 118 | + throw $lastException; |
| 119 | + } |
| 120 | + |
| 121 | + private function isIdempotent(string $method): bool |
| 122 | + { |
| 123 | + return in_array(strtoupper($method), self::IDEMPOTENT_COMMANDS, true); |
| 124 | + } |
| 125 | +} |
0 commit comments