From 0109a080d7d83e22eac2807a4cce08af4f7ac354 Mon Sep 17 00:00:00 2001 From: Divarion-D Date: Sat, 11 Apr 2026 22:06:04 +0300 Subject: [PATCH] feat(tools): add Redis diagnostics tool --- tools/test_redis.php | 477 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 tools/test_redis.php diff --git a/tools/test_redis.php b/tools/test_redis.php new file mode 100644 index 0000000..463e4aa --- /dev/null +++ b/tools/test_redis.php @@ -0,0 +1,477 @@ +#!/usr/bin/env php +hasMethod('multi'); +$hasExec = $rc->hasMethod('exec'); +out('multi/exec support', ($hasMulti && $hasExec) ? 'OK' : 'FAIL'); + +// ─── Test 2: Connection ───────────────────────────────────────── + +section('2. Connection'); + +$redis = new Redis(); +try { + $t0 = microtime(true); + $connected = $redis->connect($host, $port, $connectTimeout); + $connectMs = round((microtime(true) - $t0) * 1000, 1); + out('connect()', $connected ? 'OK' : 'FAIL', "{$connectMs}ms"); +} catch (RedisException $e) { + out('connect()', 'FAIL', $e->getMessage()); + exit(1); +} + +if ($pass) { + try { + $authed = $redis->auth($pass); + out('auth()', $authed ? 'OK' : 'FAIL'); + } catch (RedisException $e) { + out('auth()', 'FAIL', $e->getMessage()); + exit(1); + } +} else { + // Проверяем нужна ли авторизация + try { + $redis->ping(); + out('auth()', 'WARN', 'пароль не задан, но ping прошёл (Redis без пароля!)'); + } catch (RedisException $e) { + if (stripos($e->getMessage(), 'NOAUTH') !== false) { + out('auth()', 'FAIL', 'Redis требует пароль! Укажите --pass=...'); + exit(1); + } + } +} + +try { + $pong = $redis->ping(); + out('ping()', ($pong === true || $pong === '+PONG') ? 'OK' : 'FAIL', var_export($pong, true)); +} catch (RedisException $e) { + out('ping()', 'FAIL', $e->getMessage()); + exit(1); +} + +// ─── Test 3: Server Info ──────────────────────────────────────── + +section('3. Redis Server'); + +try { + $info = $redis->info(); + out('redis_version', 'OK', $info['redis_version'] ?? 'unknown'); + out('uptime', 'OK', ($info['uptime_in_seconds'] ?? '?') . ' sec'); + out('connected_clients', 'OK', $info['connected_clients'] ?? '?'); + out('used_memory_human', 'OK', $info['used_memory_human'] ?? '?'); + out('maxmemory_policy', 'OK', $info['maxmemory_policy'] ?? '?'); + + $maxmem = $info['maxmemory'] ?? 0; + $usedmem = $info['used_memory'] ?? 0; + if ($maxmem > 0) { + $pct = round($usedmem / $maxmem * 100, 1); + out('memory usage', $pct > 90 ? 'WARN' : 'OK', "{$pct}% ({$info['used_memory_human']} / " . round($maxmem / 1048576) . "MB)"); + } else { + out('maxmemory', 'WARN', 'не задан (unlimited) — рискованно для production'); + } + + // Keys count + $dbsize = $redis->dbSize(); + out('total keys', 'OK', number_format($dbsize)); +} catch (RedisException $e) { + out('info()', 'FAIL', $e->getMessage()); +} + +// ─── Test 4: Read/Write ───────────────────────────────────────── + +section('4. Read/Write'); + +$testKey = '__xcvm_diag_' . getmypid(); +try { + $redis->set($testKey, 'test_value', 5); + $val = $redis->get($testKey); + out('set/get', $val === 'test_value' ? 'OK' : 'FAIL', $val === 'test_value' ? '' : "got: " . var_export($val, true)); + $redis->del($testKey); +} catch (RedisException $e) { + out('set/get', 'FAIL', $e->getMessage()); +} + +// ─── Test 5: MULTI/EXEC (Pipeline) ───────────────────────────── + +section('5. MULTI/EXEC (Pipeline) — ЭТО КЛЮЧЕВОЙ ТЕСТ'); + +// 5a: Простой pipeline +try { + $multi = $redis->multi(); + $multi->set($testKey . '_a', 'val_a'); + $multi->set($testKey . '_b', 'val_b'); + $multi->get($testKey . '_a'); + $multi->get($testKey . '_b'); + $result = $multi->exec(); + + if ($result === false) { + out('multi/exec basic', 'FAIL', 'exec() вернул false — pipeline сломан!'); + } elseif (!is_array($result)) { + out('multi/exec basic', 'FAIL', 'exec() вернул ' . gettype($result) . ': ' . var_export($result, true)); + } else { + $ok = count($result) === 4 && $result[2] === 'val_a' && $result[3] === 'val_b'; + out('multi/exec basic', $ok ? 'OK' : 'FAIL', 'results: ' . json_encode($result)); + } + $redis->del($testKey . '_a', $testKey . '_b'); +} catch (RedisException $e) { + out('multi/exec basic', 'FAIL', $e->getMessage()); +} + +// 5b: Pipeline с sorted sets (как ConnectionTracker) +try { + // Подготовка данных + $redis->zAdd($testKey . '_zset', 100, 'member_1'); + $redis->zAdd($testKey . '_zset', 200, 'member_2'); + + $multi = $redis->multi(); + $multi->zRevRangeByScore($testKey . '_zset', '+inf', '-inf'); + $multi->zRevRangeByScore($testKey . '_zset', '+inf', '-inf', ['limit' => [0, 1]]); + $multi->zCard($testKey . '_zset'); + $result = $multi->exec(); + + if ($result === false) { + out('multi/exec zset', 'FAIL', 'exec() вернул false'); + } elseif (!is_array($result)) { + out('multi/exec zset', 'FAIL', 'exec() вернул ' . gettype($result)); + } else { + $rangeOk = is_array($result[0]) && count($result[0]) === 2; + $limitOk = is_array($result[1]) && count($result[1]) === 1; + $cardOk = $result[2] === 2; + out('zRevRangeByScore', $rangeOk ? 'OK' : 'FAIL', json_encode($result[0])); + out('zRevRangeByScore+limit', $limitOk ? 'OK' : 'FAIL', json_encode($result[1])); + out('zCard', $cardOk ? 'OK' : 'FAIL', (string)$result[2]); + } + $redis->del($testKey . '_zset'); +} catch (RedisException $e) { + out('multi/exec zset', 'FAIL', $e->getMessage()); +} + +// 5c: Пустой pipeline (0 команд) +try { + $multi = $redis->multi(); + $result = $multi->exec(); + + if ($result === false) { + out('multi/exec empty', 'WARN', 'exec() на пустом pipeline вернул false'); + } else { + out('multi/exec empty', 'OK', 'результат: ' . json_encode($result)); + } +} catch (RedisException $e) { + out('multi/exec empty', 'WARN', $e->getMessage()); +} + +// 5d: Большой pipeline (100 команд — имитация таблицы с 100 юзерами) +try { + $multi = $redis->multi(); + for ($i = 0; $i < 100; $i++) { + $multi->zRevRangeByScore('LINE#999999' . $i, '+inf', '-inf', ['limit' => [0, 1]]); + } + $t0 = microtime(true); + $result = $multi->exec(); + $execMs = round((microtime(true) - $t0) * 1000, 1); + + if ($result === false) { + out('multi/exec 100cmd', 'FAIL', 'exec() вернул false'); + } elseif (!is_array($result)) { + out('multi/exec 100cmd', 'FAIL', gettype($result)); + } else { + out('multi/exec 100cmd', 'OK', count($result) . " results in {$execMs}ms"); + } +} catch (RedisException $e) { + out('multi/exec 100cmd', 'FAIL', $e->getMessage()); +} + +// ─── Test 6: Serializer ───────────────────────────────────────── + +section('6. Serializer (igbinary)'); + +$hasIgbinary = extension_loaded('igbinary'); +out('igbinary extension', $hasIgbinary ? 'OK' : 'FAIL', $hasIgbinary ? 'v' . phpversion('igbinary') : 'не загружено!'); + +if ($hasIgbinary) { + try { + $testData = ['user_id' => 42, 'stream_id' => 557, 'uuid' => 'test-uuid']; + $serialized = igbinary_serialize($testData); + $redis->set($testKey . '_igb', $serialized, 5); + $raw = $redis->get($testKey . '_igb'); + $deserialized = igbinary_unserialize($raw); + $ok = $deserialized['user_id'] === 42 && $deserialized['stream_id'] === 557; + out('igbinary round-trip', $ok ? 'OK' : 'FAIL', $ok ? '' : var_export($deserialized, true)); + $redis->del($testKey . '_igb'); + } catch (Throwable $e) { + out('igbinary round-trip', 'FAIL', $e->getMessage()); + } +} + +// ─── Test 7: Timeout Behavior ─────────────────────────────────── + +section('7. Options & Timeouts'); + +try { + $rOpt = $redis->getOption(Redis::OPT_READ_TIMEOUT); + out('OPT_READ_TIMEOUT', 'OK', "{$rOpt} sec"); + if ((float)$rOpt < 2.0) { + out('read timeout', 'WARN', "слишком малый ({$rOpt}s), pipeline может не успевать"); + } +} catch (Throwable $e) { + out('OPT_READ_TIMEOUT', 'WARN', $e->getMessage()); +} + +try { + $tcpKeepalive = $redis->getOption(Redis::OPT_TCP_KEEPALIVE); + out('OPT_TCP_KEEPALIVE', 'OK', "{$tcpKeepalive} sec"); +} catch (Throwable $e) { + out('OPT_TCP_KEEPALIVE', 'WARN', $e->getMessage()); +} + +// Проверяем timeout сервера +try { + $configTimeout = $redis->config('GET', 'timeout'); + $t = $configTimeout['timeout'] ?? '?'; + out('server timeout', ($t == 0) ? 'WARN' : 'OK', "{$t} sec" . ($t == 0 ? ' (0 = connections never timeout — OK for persistent)' : '')); +} catch (Throwable $e) { + out('server timeout', 'WARN', $e->getMessage()); +} + +// ─── Test 8: Проверка XC_VM ключей ────────────────────────────── + +section('8. XC_VM Live Data'); + +$liveKeys = ['LIVE', 'SIGNALS#*', 'SERVER#*', 'LINE#*', 'STREAM#*', 'PROXY#*', 'ENDED']; +foreach ($liveKeys as $pattern) { + try { + if (strpos($pattern, '*') !== false) { + $keys = $redis->keys($pattern); + $count = count($keys); + out($pattern, 'OK', "{$count} keys"); + } else { + $type = $redis->type($pattern); + $typeNames = [0 => 'none', 1 => 'string', 2 => 'set', 3 => 'list', 4 => 'zset', 5 => 'hash']; + $typeName = $typeNames[$type] ?? "unknown({$type})"; + if ($type === 4) { // zset + $card = $redis->zCard($pattern); + out($pattern, 'OK', "{$typeName}, {$card} members"); + } elseif ($type === 2) { // set + $card = $redis->sCard($pattern); + out($pattern, 'OK', "{$typeName}, {$card} members"); + } elseif ($type === 0) { + out($pattern, 'WARN', 'не существует'); + } else { + out($pattern, 'OK', $typeName); + } + } + } catch (Throwable $e) { + out($pattern, 'FAIL', $e->getMessage()); + } +} + +// ─── Test 9: Reconnect после close ────────────────────────────── + +section('9. Reconnect Simulation'); + +try { + $redis->close(); + out('close()', 'OK'); +} catch (Throwable $e) { + out('close()', 'WARN', $e->getMessage()); +} + +// Проверяем поведение exec() после close +// phpredis ≥6.x авто-реконнектится → exec() вернёт array (нормально). +// phpredis <6 → exec() вернёт false или выбросит исключение. +try { + $multi = @$redis->multi(); + if ($multi) { + @$multi->ping(); + $result = @$multi->exec(); + if ($result === false) { + out('exec() after close', 'OK', 'вернул false — авто-реконнект выключен'); + } elseif (is_array($result)) { + out('exec() after close', 'OK', 'вернул array — phpredis авто-реконнект (v6.x+)'); + } else { + out('exec() after close', 'WARN', 'неожиданный тип: ' . gettype($result)); + } + } else { + out('multi() after close', 'OK', 'вернул false'); + } +} catch (RedisException $e) { + out('exec() after close', 'OK', 'RedisException (ожидаемо): ' . $e->getMessage()); +} + +// Reconnect +try { + $redis = new Redis(); + $redis->connect($host, $port, $connectTimeout); + if ($pass) $redis->auth($pass); + $pong = $redis->ping(); + out('reconnect', ($pong === true || $pong === '+PONG') ? 'OK' : 'FAIL'); +} catch (Throwable $e) { + out('reconnect', 'FAIL', $e->getMessage()); +} + +// ─── Test 10: RedisManager singleton behavior ─────────────────── + +section('10. RedisManager singleton (если доступен)'); + +// Paths.php определяет CONFIG_PATH и другие константы, нужные RedisManager → ConfigReader +if ($basePath && !defined('CONFIG_PATH')) { + $pathsFile = $basePath . '/core/Config/Paths.php'; + if (file_exists($pathsFile)) { + require_once $pathsFile; + } +} + +if (class_exists('RedisManager')) { + // RedisManager зависит от ConfigReader (config.ini) и SettingsManager (БД). + // Без полного bootstrap SettingsManager пуст → connect() вернёт null. + $hasConfig = class_exists('ConfigReader', false) && !empty(ConfigReader::getAll()); + $hasSettings = class_exists('SettingsManager', false) && !empty(SettingsManager::getAll()); + + if (!$hasConfig || !$hasSettings) { + // Прокидываем данные из redis.conf/config.ini чтобы тест работал без БД + if (!$hasConfig && $host && defined('CONFIG_PATH')) { + // ConfigReader::getAll() сам загрузит config.ini при наличии CONFIG_PATH + $hasConfig = !empty(ConfigReader::getAll()); + } + if (!$hasSettings && $pass) { + SettingsManager::set(['redis_password' => $pass]); + $hasSettings = true; + } + } + + if (!$hasConfig || !$hasSettings) { + out('RedisManager', 'WARN', 'ConfigReader/SettingsManager не инициализированы (нужен полный bootstrap с БД)'); + } else { + try { + $inst = RedisManager::instance(); + out('instance()', is_object($inst) ? 'OK' : 'FAIL', is_object($inst) ? get_class($inst) : 'null!'); + + if (is_object($inst)) { + // Проверяем: instance() при живом соединении не переподключается + $inst2 = RedisManager::instance(); + out('singleton identity', ($inst === $inst2) ? 'OK' : 'WARN', ($inst === $inst2) ? 'тот же объект' : 'РАЗНЫЕ объекты!'); + + // Проверяем: после close, instance() делает reconnect? + RedisManager::closeInstance(); + $inst3 = RedisManager::instance(); + out('reconnect after close', is_object($inst3) ? 'OK' : 'FAIL', + is_object($inst3) ? 'singleton переподключился' : 'instance() вернул null после close!' + ); + } + } catch (Throwable $e) { + out('RedisManager', 'FAIL', $e->getMessage()); + } + } +} else { + out('RedisManager', 'WARN', 'класс не загружен (запустите на сервере через console или с bootstrap)'); +} + +// ─── Summary ──────────────────────────────────────────────────── + +section('Summary'); +echo " Если тесты 5 (MULTI/EXEC) прошли — причина ошибок NOT в Redis.\n"; +echo " Если тесты 5 FAIL — проблема на стороне Redis-сервера.\n"; +echo " Если тест 9 показывает exec()=false после close — ConnectionTracker\n"; +echo " получает dead connection из RedisManager::instance().\n"; +echo "\n"; + +$redis->close();