host = env('RAW_REDIS_HOST', '127.0.0.1'); $this->port = (int) env('RAW_REDIS_PORT', 6379); $this->timeout = (float) env('RAW_REDIS_TIMEOUT', 2.5); $this->scheme = env('RAW_REDIS_SCHEME', 'tcp'); // tcp or tls $this->password = env('RAW_REDIS_PASSWORD'); $this->database = (int) env('RAW_REDIS_DB', 0); $this->maxRetries = (int) env('RAW_REDIS_MAX_RETRIES', 3); $this->baseBackoffMs = (int) env('RAW_REDIS_RETRY_BACKOFF_MS', 100); $this->connect(); } /** * Open the raw socket connection. */ protected function connect(): void { $prefix = $this->scheme === 'tls' ? 'tls://' : 'tcp://'; $address = $prefix . $this->host; $socket = @stream_socket_client( "{$address}:{$this->port}", $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT ); if (!$socket) { $this->socket = null; throw new Exception("Redis connection failed: {$errstr} ({$errno})"); } stream_set_timeout($socket, (int) env('REDIS_READ_TIMEOUT', 60)); $this->socket = $socket; if (!empty($this->password)) { $this->rawCommand(['AUTH', $this->password]); } if ($this->database > 0) { $this->rawCommand(['SELECT', (string) $this->database]); } } /** * Force-close and drop the current socket so the next call reconnects. */ protected function invalidateConnection(): void { if ($this->socket) { @fclose($this->socket); } $this->socket = null; } /** * Public entrypoint: send a command with automatic retry/reconnect on failure. * * @param array $args e.g. ['SET', 'foo', 'bar'] * @return mixed */ public function command(array $args) { $attempt = 0; $lastException = null; while ($attempt <= $this->maxRetries) { try { if (!$this->socket || $this->isDead($this->socket)) { $this->invalidateConnection(); $this->connect(); } return $this->rawCommand($args); } catch (Exception $e) { $lastException = $e; // Don't retry on Redis-level errors (e.g. WRONGTYPE, bad command syntax) // only retry on connection/transport failures. if (!$this->isTransportError($e)) { throw $e; } $this->invalidateConnection(); $attempt++; if ($attempt > $this->maxRetries) { break; } // exponential backoff: base * 2^(attempt-1), in microseconds $backoffMs = $this->baseBackoffMs * (2 ** ($attempt - 1)); usleep($backoffMs * 1000); } } throw new Exception( "Redis command failed after {$this->maxRetries} retries: " . $lastException->getMessage() ); } /** * Distinguish transport-level failures (worth retrying) from Redis protocol * errors like WRONGTYPE or ERR syntax (not worth retrying, will just fail again). */ protected function isTransportError(Exception $e): bool { $message = $e->getMessage(); $transportSignals = [ 'Failed to write to Redis socket', 'Failed to read from Redis socket', 'Redis connection failed', 'Failed to read bulk string', 'Connection closed', 'Broken pipe', ]; foreach ($transportSignals as $signal) { if (stripos($message, $signal) !== false) { return true; } } return false; } /** * Check whether the socket has been closed or timed out on the other end. * * @param resource $socket */ protected function isDead($socket): bool { if (!is_resource($socket)) { return true; } $meta = stream_get_meta_data($socket); return $meta['eof'] || $meta['timed_out']; } /** * Send a command using the RESP protocol and return the parsed reply. * No retry logic here — this is the raw single-attempt version, used * internally by command() and during connect() for AUTH/SELECT. * * @param array $args * @return mixed */ protected function rawCommand(array $args) { $this->write($args); return $this->read(); } /** * Encode and write a command in RESP array-of-bulk-strings format. */ protected function write(array $args): void { $command = '*' . count($args) . "\r\n"; foreach ($args as $arg) { $arg = (string) $arg; $command .= '$' . strlen($arg) . "\r\n" . $arg . "\r\n"; } $written = @fwrite($this->socket, $command); if ($written === false || $written === 0) { throw new Exception('Failed to write to Redis socket (broken pipe?)'); } } /** * Read and parse a single RESP reply (handles all 5 RESP types, recursively for arrays). * * @return mixed */ protected function read() { $line = fgets($this->socket); if ($line === false || $line === '') { throw new Exception('Failed to read from Redis socket (connection closed?)'); } $meta = stream_get_meta_data($this->socket); if ($meta['timed_out']) { throw new Exception('Failed to read from Redis socket (read timed out)'); } $type = $line[0]; $payload = rtrim(substr($line, 1), "\r\n"); switch ($type) { case '+': // Simple string return $payload; case '-': // Redis-level error (not a transport failure — do not retry) throw new Exception('Redis error: ' . $payload); case ':': // Integer return (int) $payload; case '$': // Bulk string $length = (int) $payload; if ($length === -1) { return null; // nil } $data = ''; $bytesToRead = $length + 2; // +2 for trailing \r\n while ($bytesToRead > 0) { $chunk = fread($this->socket, $bytesToRead); if ($chunk === false || $chunk === '') { throw new Exception('Failed to read bulk string from Redis socket'); } $data .= $chunk; $bytesToRead -= strlen($chunk); } return substr($data, 0, $length); case '*': // Array $count = (int) $payload; if ($count === -1) { return null; } $result = []; for ($i = 0; $i < $count; $i++) { $result[] = $this->read(); } return $result; default: throw new Exception('Unknown RESP type: ' . $type); } } /** * Convenience wrappers for common commands — all go through command() * so they inherit retry/reconnect behavior automatically. */ public function get(string $key) { return $this->command(['GET', $key]); } public function set(string $key, string $value, ?int $ttlSeconds = null) { if ($ttlSeconds !== null) { return $this->command(['SET', $key, $value, 'EX', (string) $ttlSeconds]); } return $this->command(['SET', $key, $value]); } public function del(string ...$keys) { return $this->command(array_merge(['DEL'], $keys)); } public function lpush(string $key, string ...$values) { return $this->command(array_merge(['LPUSH', $key], $values)); } public function rpop(string $key) { return $this->command(['RPOP', $key]); } public function expire(string $key, int $seconds) { return $this->command(['EXPIRE', $key, (string) $seconds]); } public function hset(string $key, string $field, string $value) { return $this->command(['HSET', $key, $field, $value]); } /** * Set a hash field only if it does not already exist. * Returns 1 if this call created the field, 0 if it was already set. * Atomic — use this to claim a one-shot flag under concurrency. */ public function hsetnx(string $key, string $field, string $value) { return $this->command(['HSETNX', $key, $field, $value]); } public function hdel(string $key, string ...$fields) { return $this->command(array_merge(['HDEL', $key], $fields)); } public function exists(string $key): bool { return (bool) $this->command(['EXISTS', $key]); } public function hmset(string $key, array $dictionary) { $args = ['HMSET', $key]; foreach ($dictionary as $field => $value) { $args[] = (string) $field; $args[] = (string) $value; } return $this->command($args); } public function hgetall(string $key) { $result = $this->command(['HGETALL', $key]); if (!is_array($result)) { return []; } $assoc = []; for ($i = 0; $i < count($result); $i += 2) { if (isset($result[$i + 1])) { $assoc[$result[$i]] = $result[$i + 1]; } } return $assoc; } public function ping() { return $this->command(['PING']); } public function close(): void { $this->invalidateConnection(); } public function __destruct() { $this->close(); } }