Recover stalled database connections and skip unreachable chat recipients
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -14,6 +14,8 @@ DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@postgre
|
||||
COMPOSE_PROFILES=local-db
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
FSM_TTL_SECONDS=86400
|
||||
# Short connection lifetime verified against the production network.
|
||||
DB_POOL_RECYCLE=15
|
||||
|
||||
# ID администраторов (через запятую)
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
@@ -52,10 +52,10 @@ Drone: `https://drone.smartsoltech.kr`, цель: `trevor@192.168.40.112:/opt/ne
|
||||
- Создаёт каталог `releases/COMMIT-BUILD`, собирает образ с тегом commit и поднимает Redis/необходимую локальную БД.
|
||||
- Делает PostgreSQL backup в `backups/COMMIT-BUILD-UTC_TIMESTAMP.dump` с ограниченными правами. Дальнейшие действия прекращаются, если backup не получен.
|
||||
- Останавливает старого polling-бота, выполняет `alembic upgrade head` и проверку схемы.
|
||||
- Запускает новый образ, ожидает healthcheck, проверяет heartbeat приложения и только затем переключает `current` и записывает `last-build`.
|
||||
- Запускает новый образ, ожидает healthcheck, затем ещё 120 секунд проверяет heartbeat и отсутствие перезапусков. Только после этого переключает `current` и записывает `last-build`.
|
||||
- При ошибке после остановки пытается вернуть прежний образ. **Это откат приложения:** схема БД автоматически не откатывается; используется env текущего deployment. Восстановление БД из backup требует отдельного решения. При первом запуске предыдущего образа может не быть.
|
||||
|
||||
Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Сбой heartbeat останавливает приложение, после чего действует Docker restart policy. Один только Docker healthcheck не является проверкой каждого бизнес-сценария или факта доставки всех сообщений.
|
||||
Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Отдельный watchdog-поток завершает процесс при устаревшем heartbeat, даже если завис драйвер или event loop; после этого действует Docker restart policy. На рабочем сетевом маршруте длительное повторное использование соединений зависало: в `lottery_env` установлен `DB_POOL_RECYCLE=15`, сохраняющий ограниченный пул с коротким сроком жизни соединения. Один только начальный Docker healthcheck не является проверкой длительной работы или доставки всех сообщений.
|
||||
|
||||
## Проверка результата
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
| Средний | Excel мог содержать формулы, чрезмерно большой архив или строку, срывающую весь импорт | Экспорт строк как текста; ограничения размера/числа строк; безопасный XML; отдельная транзакция для каждой строки; импорт не назначает административные роли |
|
||||
| Средний | HTML в имени/тексте ломал пересылку, длинный текст переставал помещаться с заголовком | Экранирование и сохранение Telegram entities; для предельного размера заголовок отправляется отдельно |
|
||||
| Средний | Разные интерфейсы бана/разбана расходились; повторная запись о блокировке ломала рассылку и задачу неактивности | Оба механизма блокировки согласованы; служебные записи обновляются атомарно |
|
||||
| Средний | Чат повторно отправлял сообщения недоступным адресатам, включая собственную старую запись бота | Собственный ID исключён; отказ Telegram помечает недоступного получателя для пропуска последующих отправок. Входящее событие пользователя восстанавливает доставку; ошибка содержимого не блокирует получателя |
|
||||
| Высокий | При длительном повторном использовании соединения с рабочей БД зависала проверка здоровья; Docker сам не перезапускает unhealthy-контейнер | Настроен короткий срок жизни соединений в рабочем пуле. Отдельный watchdog завершает процесс при остановке heartbeat, включая зависание event loop или отмены запроса драйвера |
|
||||
| Средний | Ошибки SQL/исключений уходили пользователю; были обращения к удалённому `User.account_number` и несуществующим методам | Общие сообщения об ошибке без технических деталей, исправленные обращения к Account и сервисам, критическая статическая проверка в CI |
|
||||
| Средний | CI скрывал ошибки, а наличие процесса считалось успешным деплоем | Проверки останавливают pipeline при ошибке; проверяются миграции, схема, доступ к БД, запуск Telegram и heartbeat приложения |
|
||||
| Средний | Установленные зависимости содержали известные уязвимости; старый aioredis несовместим с Python 3.12 | Обновлены aiogram/aiohttp/python-dotenv, удалён неиспользуемый aioredis; выполнен pip-audit |
|
||||
@@ -51,7 +53,7 @@
|
||||
|
||||
CI запускает тесты на SQLite и отдельной PostgreSQL 16, соответствующей рабочему серверу. Проверка Alembic создаёт дополнительную временную БД, выполняет полную миграцию и обновление с рабочей ревизии, повторяет upgrade head и проверяет наличие ожидаемых таблиц/полей. SQLite используется для тестов сервисов через SQLAlchemy metadata; историческая цепочка миграций предназначена для PostgreSQL.
|
||||
|
||||
Итоговый локальный прогон с поддержкой премиум-эмодзи: **62 passed на PostgreSQL 15**; **60 passed, 2 skipped на SQLite** (пропущены PostgreSQL-тесты миграций). Оба прогона выдают по шесть предупреждений openpyxl об устаревающем `datetime.utcnow`; падений тестов нет. Поддержка эмодзи описана в [EMOJI_SYSTEM.md](EMOJI_SYSTEM.md).
|
||||
Основной прогон с поддержкой премиум-эмодзи: **62 passed на PostgreSQL 15 локально и PostgreSQL 16 в Drone**. После добавления проверок watchdog и недоступных получателей локально: **65 passed, 2 skipped на SQLite** (пропущены PostgreSQL-тесты миграций). Прогоны выдают по шесть предупреждений openpyxl об устаревающем `datetime.utcnow`; падений тестов нет. Поддержка эмодзи описана в [EMOJI_SYSTEM.md](EMOJI_SYSTEM.md).
|
||||
|
||||
Дополнительно прошли compileall, Ruff для критических ошибок, проверка состава релизного архива, поиск секретов в отслеживаемых файлах и синтаксическая проверка shell-скрипта. `pip-audit -r requirements.txt` сообщил `No known vulnerabilities found`. Это результат проверки известных уязвимостей зависимостей на дату аудита, а не гарантия безопасности всего приложения.
|
||||
|
||||
@@ -59,7 +61,7 @@ CI запускает тесты на SQLite и отдельной PostgreSQL 16
|
||||
|
||||
1. **Drone и сервер.** Проверены SSH-доступ, права Docker, активность репозитория `trevor/new_lottery_bot` в `https://drone.smartsoltech.kr`. Настроены пять deployment-секретов. Успех удалённого deployment подтверждается отдельно после push; локальный зелёный прогон его не заменяет.
|
||||
2. **Рабочая инфраструктура.** Docker-образ собран на целевом хосте. Эффективные настройки и volumes сопоставлены с действующим контейнером; PostgreSQL 16.15 на `192.168.20.2` доступен. Реальная проверка восстановления выявила несовместимость клиента pg_dump 17 с версией рабочей БД 16; образ закрепляет клиент 16 из официального PostgreSQL APT-репозитория. После исправления production backup успешно восстановлен в изолированном PostgreSQL 16, применена новая миграция и проверена схема; рабочая БД этой проверкой не изменялась. Автоматический откат приложения при искусственном сбое пока не проверялся.
|
||||
3. **История секретов.** Удаление файла из будущего коммита не отзывает ранее записанные токен бота и пароль БД. Их нужно сменить и передать новые значения в Drone. История Git не переписывалась.
|
||||
3. **История секретов.** Runtime-файл удалён из текущей версии Git. Действующий токен бота отличается от удалённого файла, но пароль рабочей БД совпадает с ранее опубликованным. Пароль БД ещё требует согласованной замены во всех потребителях; история Git не переписывалась.
|
||||
4. **Подтверждение клубной карты.** Текущая бизнес-логика принимает введённый пользователем номер карты без проверки по внешней системе или отдельного подтверждения кассиром. Она не доказывает, что карта принадлежит заявителю. Для устранения этого риска требуется определить источник проверки владельца; автоматического подтверждения такой принадлежности сейчас нет.
|
||||
5. **Нагрузка и внешние сбои.** Тесты используют подменённые ответы Telegram и отдельные тестовые БД. Длительная нагрузка с реальными пользователями и потеря сети/Redis ещё не проверены. Рассылки не имеют долговременной очереди восстановления после рестарта; незавершённую рассылку может потребоваться повторить.
|
||||
6. **Производственные данные.** Проверка перед выпуском не обнаружила дубликатов участников или призовых мест. Новая миграция повторяет эту проверку и останавливается при неоднозначных данных; она не удаляет их автоматически.
|
||||
|
||||
10
main.py
10
main.py
@@ -336,8 +336,9 @@ async def main():
|
||||
|
||||
# Запускаем polling
|
||||
try:
|
||||
from src.core.health import HEARTBEAT, heartbeat
|
||||
from src.core.health import HEARTBEAT, heartbeat, start_watchdog
|
||||
HEARTBEAT.unlink(missing_ok=True)
|
||||
watchdog = start_watchdog()
|
||||
if REDIS_URL:
|
||||
await storage.redis.ping()
|
||||
from sqlalchemy import select
|
||||
@@ -345,12 +346,15 @@ async def main():
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(select(User).limit(0))
|
||||
await bot.get_me()
|
||||
health_task = asyncio.create_task(heartbeat())
|
||||
health_task = asyncio.create_task(heartbeat(), name="database-heartbeat")
|
||||
logger.info("Бот запущен")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot))
|
||||
completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in completed:
|
||||
await task
|
||||
except Exception:
|
||||
logger.exception("Runtime supervision failed; stopping the application")
|
||||
raise
|
||||
finally:
|
||||
from src.core.database import close_db
|
||||
from contextlib import suppress
|
||||
@@ -366,6 +370,8 @@ async def main():
|
||||
await bot.session.close()
|
||||
await storage.close()
|
||||
await close_db()
|
||||
if "watchdog" in locals():
|
||||
watchdog.set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -54,6 +54,12 @@ compose run --rm --no-deps bot python -m alembic upgrade head
|
||||
compose run --rm --no-deps bot python scripts/check_schema.py
|
||||
compose up -d --no-build --wait --wait-timeout 180 bot
|
||||
docker exec lottery_bot python -m src.core.health
|
||||
echo "Verifying application health and restart count for 120 seconds"
|
||||
for attempt in 1 2 3 4 5 6 7 8; do
|
||||
sleep 15
|
||||
docker exec lottery_bot python -m src.core.health
|
||||
test "$(docker inspect --format '{{.RestartCount}}' lottery_bot)" -eq 0
|
||||
done
|
||||
ln -s "$release" "$root/current.$build"
|
||||
mv -Tf "$root/current.$build" "$root/current"
|
||||
printf '%s\n' "$build" > "$root/last-build"
|
||||
|
||||
@@ -133,7 +133,7 @@ class ActivityService:
|
||||
.where(
|
||||
and_(
|
||||
BlockedUser.telegram_id == telegram_id,
|
||||
BlockedUser.error_type == 'inactive',
|
||||
BlockedUser.error_type.in_(['inactive', 'blocked_bot', 'deactivated', 'not_found', 'chat_not_found']),
|
||||
BlockedUser.is_active == True
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Health reflects a live event loop and a usable database connection."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,9 +15,12 @@ HEARTBEAT = Path(os.getenv("HEARTBEAT_FILE", "/tmp/lottery-heartbeat"))
|
||||
|
||||
async def heartbeat():
|
||||
while True:
|
||||
async with asyncio.timeout(20):
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
HEARTBEAT.write_text(str(time.time()), encoding="ascii")
|
||||
pending = HEARTBEAT.with_name(HEARTBEAT.name + ".tmp")
|
||||
pending.write_text(str(time.time()), encoding="ascii")
|
||||
pending.replace(HEARTBEAT)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
@@ -26,5 +31,20 @@ def healthy():
|
||||
return False
|
||||
|
||||
|
||||
def start_watchdog(grace_seconds=75, interval=10):
|
||||
"""Exit even when the event loop or driver cancellation itself is stuck."""
|
||||
stopped = threading.Event()
|
||||
started = time.monotonic()
|
||||
|
||||
def watch():
|
||||
while not stopped.wait(interval):
|
||||
if time.monotonic() - started >= grace_seconds and not healthy():
|
||||
logging.getLogger(__name__).critical("Heartbeat expired; restarting the application")
|
||||
os._exit(1)
|
||||
|
||||
threading.Thread(target=watch, name="heartbeat-watchdog", daemon=True).start()
|
||||
return stopped
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if healthy() else 1)
|
||||
|
||||
@@ -3,6 +3,7 @@ from src.utils.errors import public_error
|
||||
from src.core.access import is_admin
|
||||
from src.utils.delivery import background_delivery
|
||||
from aiogram import Router, F
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -384,10 +385,10 @@ def _is_message_processed(chat_id: int, message_id: int) -> bool:
|
||||
|
||||
async def get_all_active_users(session: AsyncSession) -> List:
|
||||
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
|
||||
users = await UserService.get_all_users(session)
|
||||
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным
|
||||
# Они все имеют право общаться в чате (главное - что они вошли в чат)
|
||||
return users
|
||||
from sqlalchemy import select
|
||||
from src.core.models import BlockedUser, User
|
||||
unavailable = select(BlockedUser.telegram_id).where(BlockedUser.is_active.is_(True))
|
||||
return list((await session.scalars(select(User).where(User.telegram_id.not_in(unavailable)))).all())
|
||||
|
||||
|
||||
async def broadcast_message_with_scheduler(
|
||||
@@ -415,6 +416,7 @@ async def broadcast_message_with_scheduler(
|
||||
|
||||
async with async_session_maker() as session:
|
||||
users = await get_all_active_users(session)
|
||||
users = [user for user in users if user.telegram_id != message.bot.id]
|
||||
|
||||
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
||||
|
||||
@@ -502,6 +504,20 @@ async def _copy_with_sender(message: Message, recipient_id: int, sender_info: st
|
||||
try:
|
||||
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
|
||||
return sent.message_id
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as error:
|
||||
error_text = str(error).lower()
|
||||
unavailable = isinstance(error, TelegramForbiddenError) or any(
|
||||
reason in error_text for reason in ("chat not found", "user not found", "user is deactivated")
|
||||
)
|
||||
if unavailable:
|
||||
from src.core.broadcast_services import BroadcastService
|
||||
reason = "blocked_bot" if isinstance(error, TelegramForbiddenError) else "chat_not_found"
|
||||
async with async_session_maker() as session:
|
||||
await BroadcastService().mark_user_blocked(session, recipient_id, reason, str(error))
|
||||
return None
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Telegram rejected chat message content: %s", error)
|
||||
return None
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Chat delivery failed")
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
@@ -95,3 +96,27 @@ async def test_inactivity_job_preserves_existing_delivery_block_and_continues():
|
||||
assert await ActivityService.mark_inactive_users(session) == 1
|
||||
assert await ActivityService.mark_inactive_users(session) == 0
|
||||
assert await session.scalar(select(BlockedUser.error_type).where(BlockedUser.telegram_id == 201)) == "blocked_bot"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_kind", ["forbidden", "chat_not_found", "invalid_content"])
|
||||
async def test_chat_skips_unreachable_recipients_and_restores_them_on_incoming_activity(monkeypatch, error_kind):
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.methods import SendMessage
|
||||
from src.core.activity_service import ActivityService
|
||||
from src.handlers import chat_handlers
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await UserService.get_or_create_user(session, 301)
|
||||
await UserService.get_or_create_user(session, 302)
|
||||
error = (TelegramForbiddenError if error_kind == "forbidden" else TelegramBadRequest)(
|
||||
method=SendMessage(chat_id=301, text="test"),
|
||||
message={"forbidden": "bot was blocked by the user", "chat_not_found": "chat not found",
|
||||
"invalid_content": "CUSTOM_EMOJI_INVALID"}[error_kind],
|
||||
)
|
||||
monkeypatch.setattr(chat_handlers, "copy_preserving_entities", AsyncMock(side_effect=error))
|
||||
assert await chat_handlers._copy_with_sender(SimpleNamespace(), 301, "Sender") is None
|
||||
async with async_session_maker() as session:
|
||||
recipients = {user.telegram_id for user in await chat_handlers.get_all_active_users(session)}
|
||||
assert recipients == ({301, 302} if error_kind == "invalid_content" else {302})
|
||||
await ActivityService.reactivate_user(session, 301)
|
||||
assert {user.telegram_id for user in await chat_handlers.get_all_active_users(session)} == {301, 302}
|
||||
|
||||
31
tests/test_health.py
Normal file
31
tests/test_health.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_watchdog_exits_a_process_with_stalled_event_loop(tmp_path):
|
||||
# A blocked event loop cannot run coroutine-based timeout handlers.
|
||||
code = """import time
|
||||
from src.core.health import start_watchdog
|
||||
start_watchdog(grace_seconds=0.1, interval=0.02)
|
||||
time.sleep(10)
|
||||
"""
|
||||
result = subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1],
|
||||
env=dict(os.environ, HEARTBEAT_FILE=str(tmp_path / "absent")),
|
||||
capture_output=True, timeout=8)
|
||||
assert result.returncode == 1
|
||||
assert b"Heartbeat expired" in result.stderr
|
||||
|
||||
|
||||
def test_stopped_watchdog_allows_normal_shutdown(tmp_path):
|
||||
code = """import time
|
||||
from src.core.health import start_watchdog
|
||||
stopped = start_watchdog(grace_seconds=0.1, interval=0.02)
|
||||
stopped.set()
|
||||
time.sleep(0.2)
|
||||
"""
|
||||
result = subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1],
|
||||
env=dict(os.environ, HEARTBEAT_FILE=str(tmp_path / "absent")),
|
||||
capture_output=True, timeout=8)
|
||||
assert result.returncode == 0
|
||||
Reference in New Issue
Block a user