# План исправления Redis — диффы с объяснениями **Дата:** 2026-02-28 **Аудит:** [`REDIS_AUDIT.md`](../REDIS_AUDIT.md) **Статус:** Верифицирован, готов к реализации **Верификация:** 2026-02-28 — все 7 диффов проверены против кодовой базы --- ## DIFF 1: Парсер команд — shlex вместо split (БАГ #1, КРИТИЧНЫЙ) ### Почему `command.split()` разбивает строку по ВСЕМ пробелам, игнорируя кавычки. `shlex.split()` понимает одинарные, двойные кавычки и экранирование — стандарт для shell-подобного парсинга. ``` split(): "SET key 'hello world'" → ['SET', 'key', "'hello", "world'"] ← СЛОМАНО shlex.split(): "SET key 'hello world'" → ['SET', 'key', 'hello world'] ← ПРАВИЛЬНО ``` ### Файл: `core/redis_client.py` строка 78 **ДО:** ```python def execute(self, command: str) -> str: """Parse a raw command string, execute via redis-py, return formatted.""" if not self._conn: return "[not connected]" parts = command.split() if not parts: return "" ``` **ПОСЛЕ:** ```python def execute(self, command: str) -> str: """Parse a raw command string, execute via redis-py, return formatted.""" if not self._conn: return "[not connected]" import shlex try: parts = shlex.split(command) except ValueError: parts = command.split() # fallback для незакрытых кавычек if not parts: return "" ``` ### Файл: `tools/ssh.py` строка 958 **ДО:** ```python try: parts = command.split() if not parts: print("ERROR: Empty Redis command") sys.exit(1) result = r.execute_command(*parts) ``` **ПОСЛЕ:** ```python try: import shlex try: parts = shlex.split(command) except ValueError: parts = command.split() if not parts: print("ERROR: Empty Redis command") sys.exit(1) result = r.execute_command(*parts) ``` ### Результат ```bash # Было: --redis ALIAS "SET key 'hello world'" → syntax error # Стало: --redis ALIAS "SET key 'hello world'" → True --redis ALIAS "GET key" → hello world ``` --- ## DIFF 2: GUI stats — прямые методы вместо execute (БАГ #2, СРЕДНИЙ) ### Почему `client.execute("DBSIZE")` возвращает `"(integer) 27199166"` — уже отформатированную строку. Этот текст отображается в label как есть, включая `(integer)`. `client.execute("INFO memory")` возвращает строку где `_format()` заменяет `\r\n` на `\n`, но парсер ищет `\r\n` → ничего не находит → memory всегда `"—"`. **Решение:** Использовать `client.dbsize()` (int) и `client.info("memory")` (dict) напрямую. ### Файл: `gui/tabs/redis_tab.py` метод `_refresh_stats()` строки 188-210 **ДО:** ```python def _refresh_stats(self): if not self._current_alias: return def _do(): try: client = self._get_client() db = int(self._db_var.get()) client.select_db(db) keys_count = client.execute("DBSIZE") info = client.execute("INFO memory") # Parse memory from INFO output memory = "—" if isinstance(info, str): for line in info.split("\r\n"): if line.startswith("used_memory_human:"): memory = line.split(":")[1].strip() break keys_text = str(keys_count) if keys_count is not None else "—" self.after(0, lambda: self._keys_label.configure( text=t("redis_keys") + f": {keys_text}")) self.after(0, lambda: self._memory_label.configure( text=t("redis_memory") + f": {memory}")) except Exception: pass threading.Thread(target=_do, daemon=True).start() ``` **ПОСЛЕ:** ```python def _refresh_stats(self): if not self._current_alias: return def _do(): try: client = self._get_client() db = int(self._db_var.get()) client.select_db(db) # Прямые методы возвращают int и dict, не форматированные строки keys_count = client.dbsize() info = client.info("memory") memory = info.get("used_memory_human", "—") if info else "—" keys_text = f"{keys_count:,}" if keys_count is not None else "—" self.after(0, lambda: self._keys_label.configure( text=t("redis_keys") + f": {keys_text}")) self.after(0, lambda: self._memory_label.configure( text=t("redis_memory") + f": {memory}")) except Exception: pass threading.Thread(target=_do, daemon=True).start() ``` ### Результат ``` # Было: Keys: (integer) 27199166 Memory: — # Стало: Keys: 27,199,166 Memory: 2.82G ``` --- ## DIFF 3: SSL поддержка в GUI RedisClient (БАГ #3, СРЕДНИЙ) ### Почему CLI (`tools/ssh.py`) передаёт `ssl=server.get("ssl", False)` при создании подключения. GUI (`redis_client.py`) этот параметр НЕ передаёт → невозможно подключиться к Redis с TLS. Дополнительно: `server_dialog.py` не показывает поле `ssl` для Redis в UI. ### Файл: `core/redis_client.py` конструктор и connect() **ДО:** ```python class RedisClient: def __init__(self, server: dict): self._host = server["ip"] self._port = int(server.get("port", 6379)) self._password = server.get("password") or None self._db = int(server.get("db_index", 0)) self._conn = None def connect(self) -> bool: try: r = _get_redis() self._conn = r.Redis( host=self._host, port=self._port, password=self._password, db=self._db, decode_responses=True, socket_timeout=5, socket_connect_timeout=5, ) ``` **ПОСЛЕ:** ```python class RedisClient: def __init__(self, server: dict): self._host = server["ip"] self._port = int(server.get("port", 6379)) self._password = server.get("password") or None self._db = int(server.get("db_index", 0)) self._ssl = server.get("ssl", False) or server.get("use_ssl", False) self._conn = None def connect(self) -> bool: try: r = _get_redis() self._conn = r.Redis( host=self._host, port=self._port, password=self._password, db=self._db, decode_responses=True, socket_timeout=5, socket_connect_timeout=5, ssl=self._ssl, ) ``` ### Файл: `gui/server_dialog.py` строка 22 — FIELD_MAP **ДО:** ```python "redis": ["password", "db_index"], ``` **ПОСЛЕ:** ```python "redis": ["password", "db_index", "use_ssl"], ``` ### Почему `ssl` и `use_ssl` В проекте используются оба ключа в разных местах: - `tools/ssh.py` читает `server.get("ssl", False)` - `gui/server_dialog.py` сохраняет `use_ssl` (для WinRM) Поэтому в `RedisClient` проверяем оба: `server.get("ssl", False) or server.get("use_ssl", False)`. --- ## DIFF 4: CLI error handling (БАГ #4, НИЗКИЙ) ### Почему При ошибке Redis (WRONGTYPE, NOSCRIPT, синтаксис) выводится полный Python traceback с двойным повтором. Пользователь видит нечитаемое сообщение. ### Файл: `tools/ssh.py` функция `run_redis_cmd()` строки 957-973 **ДО:** ```python try: parts = command.split() if not parts: print("ERROR: Empty Redis command") sys.exit(1) result = r.execute_command(*parts) if isinstance(result, list): for i, item in enumerate(result): print(f"{i + 1}) {item}") print(f"\n({len(result)} items)") elif isinstance(result, dict): for k, v in result.items(): print(f"{k}: {v}") elif isinstance(result, bytes): print(result.decode("utf-8", errors="replace")) else: print(result) finally: r.close() ``` **ПОСЛЕ:** ```python try: import shlex try: parts = shlex.split(command) except ValueError: parts = command.split() if not parts: print("ERROR: Empty Redis command") sys.exit(1) try: result = r.execute_command(*parts) except Exception as e: print(f"(error) {e}", file=sys.stderr) sys.exit(1) if isinstance(result, list): for i, item in enumerate(result): print(f"{i + 1}) {item}") print(f"\n({len(result)} items)") elif isinstance(result, dict): for k, v in result.items(): print(f"{k}: {v}") elif isinstance(result, bytes): print(result.decode("utf-8", errors="replace")) else: print(result) finally: r.close() ``` ### Результат ```bash # Было: ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value # Стало: (error) WRONGTYPE Operation against a key holding the wrong kind of value ``` --- ## DIFF 5: Двойной PING → одинарный (БАГ #5, НИЗКИЙ) ### Почему `connect()` делает `self._conn.ping()`. Затем `check_connection()` делает ещё один `self._conn.ping()`. Два network round-trip вместо одного при каждом status check. При 10 серверах × каждые 30 секунд = 20 лишних пингов. ### Файл: `core/status_checker.py` строки 98-110 **ДО:** ```python def _check_redis(self, server: dict) -> bool: """Check Redis via PING.""" try: from core.redis_client import RedisClient client = RedisClient(server) result = client.connect() if result: ok = client.check_connection() client.disconnect() return ok return False except Exception: return False ``` **ПОСЛЕ:** ```python def _check_redis(self, server: dict) -> bool: """Check Redis via PING.""" try: from core.redis_client import RedisClient client = RedisClient(server) result = client.connect() # connect() уже делает ping() client.disconnect() return result except Exception: return False ``` --- ## DIFF 6: CLI status — добавить try-except и close (БАГ #6, НИЗКИЙ) ### Почему Если Redis offline, `r.ping()` бросает исключение. `r.close()` не вызывается (утечка). Ошибка не перехвачена → traceback вместо `OFFLINE`. ### Файл: `tools/ssh.py` строки 698-703 **ДО:** ```python if stype == "redis": import redis as redis_lib r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379), password=server.get("password", "") or None, db=server.get("db_index", 0), socket_timeout=10, ssl=server.get("ssl", False)) r.ping() r.close() return "ONLINE" ``` **ПОСЛЕ:** ```python if stype == "redis": import redis as redis_lib r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379), password=server.get("password", "") or None, db=server.get("db_index", 0), socket_timeout=10, ssl=server.get("ssl", False)) try: r.ping() return "ONLINE" finally: r.close() ``` --- ## DIFF 7: Дедупликация подключения в CLI (БАГ #7, УЛУЧШЕНИЕ) ### Почему Три функции (`run_redis_cmd`, `redis_info`, `redis_keys`) содержат одинаковый блок из 6 строк для создания Redis-подключения. При изменении параметров (например, добавление `ssl_cert_reqs`) нужно менять в 3 местах. ### Файл: `tools/ssh.py` — добавить хелпер перед строкой 946 **ДОБАВИТЬ:** ```python def _get_redis_client(server: dict): """Create a Redis client from server config. Single source of truth.""" import redis as redis_lib return redis_lib.Redis( host=server["ip"], port=server.get("port", 6379), password=server.get("password", "") or None, db=server.get("db_index", 0), decode_responses=True, socket_timeout=10, ssl=server.get("ssl", False), ) ``` **Затем заменить во всех трёх функциях:** ```python # Было (в каждой из 3 функций): import redis as redis_lib host = server["ip"] port = server.get("port", 6379) password = server.get("password", "") or None db_index = server.get("db_index", 0) ssl_enabled = server.get("ssl", False) r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, decode_responses=True, socket_timeout=10, ssl=ssl_enabled) # Стало: r = _get_redis_client(server) ``` --- ## Порядок применения | # | Дифф | Файлы | Строк | Приоритет | |---|------|-------|-------|-----------| | 1 | Парсер shlex | `redis_client.py`, `ssh.py` | 8 | КРИТИЧНЫЙ | | 2 | GUI stats | `redis_tab.py` | 10 | СРЕДНИЙ | | 3 | SSL в GUI | `redis_client.py`, `server_dialog.py` | 4 | СРЕДНИЙ | | 4 | CLI error handling | `ssh.py` | 6 | НИЗКИЙ | | 5 | Double PING | `status_checker.py` | 2 | НИЗКИЙ | | 6 | CLI status fix | `ssh.py` | 4 | НИЗКИЙ | | 7 | Дедупликация | `ssh.py` | -12 (убрано дублей) | УЛУЧШЕНИЕ | **Всего:** ~20 строк нового кода, -12 строк дублей = +8 строк нетто. --- ## Схема: что сломано и что фиксит каждый дифф ``` Пользователь вводит: SET mykey "hello world" │ ├─ CLI (ssh.py) ├─ GUI (redis_tab.py) │ │ │ │ │ command.split() │ client.execute() │ ❌ DIFF 1 фиксит │ │ │ │ command.split() ← redis_client.py │ │ ❌ DIFF 1 фиксит │ │ │ ├─ _refresh_stats() │ │ execute("DBSIZE") → "(integer) N" │ │ ❌ DIFF 2 фиксит → dbsize() → int │ │ │ │ execute("INFO memory") → строка без \r\n │ │ ❌ DIFF 2 фиксит → info("memory") → dict │ │ ├─ SSL подключение ├─ SSL подключение │ ✅ Работает │ ❌ DIFF 3 фиксит ``` --- ## Результаты верификации (аудит плана) Каждый дифф проверен тремя независимыми аудиторами против текущей кодовой базы. ### Сводная таблица | DIFF | Точное совпадение ДО↔код | Риски | Статус | |------|--------------------------|-------|--------| | 1 — shlex | ✅ redis_client.py:78 + ssh.py:958 | Нулевой. 11 команд протестированы | ОДОБРЕН | | 2 — stats | ✅ redis_tab.py:191-208 | Нулевой. dbsize()→int, info()→dict подтверждено | ОДОБРЕН | | 3 — SSL | ✅ redis_client.py:21-43 + dialog:22 | Нулевой. use_ssl чекбокс уже существует в UI | ОДОБРЕН | | 4 — errors | ✅ ssh.py:957-975 | Нулевой. finally вызывается даже при sys.exit | ОДОБРЕН | | 5 — ping | ✅ status_checker.py:98-110 | Нулевой. connect() содержит ping() | ОДОБРЕН | | 6 — status | ✅ ssh.py:698-703 | Нулевой. try-finally корректно | ОДОБРЕН | | 7 — dedup | ✅ 3 копии по 9 строк | **НИЗКИЙ** — см. замечание ниже | ОДОБРЕН с замечанием | ### Замечания аудиторов **DIFF 1:** - shlex.split() протестирован на: PING, GET, SET, HGETALL, INFO, SCAN, SELECT, MSET, CONFIG SET, значения с кавычками, Windows-пути - Все 11 типов команд парсятся корректно - Fallback на split() при ValueError (незакрытые кавычки) — корректный - Inline import shlex соответствует паттерну проекта (lazy imports) **DIFF 2:** - `dbsize()` всегда возвращает int (0 при ошибке, никогда None) - `info("memory")` возвращает dict с ключом `"used_memory_human"` - `f"{keys_count:,}"` безопасен для 0 и любого int - Thread race condition существует но **pre-existing** — DIFF 2 не вводит новых **DIFF 3:** - `use_ssl` — согласовано с паттерном проекта (winrm, grafana, prometheus используют то же) - UI чекбокс `use_ssl` уже существует в server_dialog.py, нужно только добавить в FIELD_MAP - Dual-key fallback `server.get("ssl") or server.get("use_ssl")` покрывает CLI и GUI пути - `ssl=False` по умолчанию — не ломает существующие подключения **DIFF 5:** - `connect()` вызывает `self._conn.ping()` на строке 42 redis_client.py — подтверждено - Второй ping через `check_connection()` — гарантированно дубликат **DIFF 7 — ЗАМЕЧАНИЕ:** - Status check в ssh.py:698-703 содержит **4-й дубликат** подключения Redis - План НЕ включает его в дедупликацию - **Рекомендация:** Обновить status check тоже при реализации DIFF 7 - **Примечание:** decode_responses=True в хелпере безопасен для status check (ping() не зависит от декодирования) ### Итог верификации **7 из 7 диффов ОДОБРЕНЫ.** Ни один не сломает существующую функциональность. Единственное замечание: DIFF 7 стоит расширить на status check (4-й дубликат). --- ## Связанные документы - [`REDIS_AUDIT.md`](../REDIS_AUDIT.md) — полный отчёт аудита - [`plans/reliable-sftp-upload.md`](reliable-sftp-upload.md) — план по SFTP - [`SFTP_UPLOAD_AUDIT.md`](../SFTP_UPLOAD_AUDIT.md) — аудит загрузки - [`SFTP_UPLOAD_DIFFS.md`](../SFTP_UPLOAD_DIFFS.md) — диффы загрузки