Files
server-manager/plans/redis-fixes.md
2026-02-28 07:15:46 -05:00

20 KiB
Raw Blame History

План исправления Redis — диффы с объяснениями

Дата: 2026-02-28 Аудит: 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

ДО:

    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 ""

ПОСЛЕ:

    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

ДО:

    try:
        parts = command.split()
        if not parts:
            print("ERROR: Empty Redis command")
            sys.exit(1)
        result = r.execute_command(*parts)

ПОСЛЕ:

    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)

Результат

# Было:
--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

ДО:

    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()

ПОСЛЕ:

    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()

ДО:

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,
            )

ПОСЛЕ:

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

ДО:

    "redis":      ["password", "db_index"],

ПОСЛЕ:

    "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

ДО:

    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()

ПОСЛЕ:

    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()

Результат

# Было:
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

ДО:

    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

ПОСЛЕ:

    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

ДО:

    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"

ПОСЛЕ:

    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

ДОБАВИТЬ:

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),
    )

Затем заменить во всех трёх функциях:

# Было (в каждой из 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-й дубликат).


Связанные документы