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

562 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План исправления 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) — диффы загрузки