Перезапуск бота больше не повторяет рассылку

Расписание common.check в 00:35 совпало с выкаткой: старый процесс успел отправить цепочку, новый после старта отправил её ещё раз. Теперь каждая отработанная минута атомарно отмечается в bot.schedule_runs; если отметка уже есть, рассылка пропускается. Старые отметки чистятся через 7 дней.
This commit is contained in:
MpakobecEx 2026-10-26 02:58:31 +05:00
parent 1ab434d8b0
commit 769424ea23
7 changed files with 105 additions and 5 deletions

View File

@ -73,7 +73,9 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`). база недоступна — использует расписание из кода (совпадает с сидом `V16`). Каждая
отправленная минута фиксируется в `bot.schedule_runs`, поэтому перезапуск бота в ту же
минуту не повторяет рассылку.
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка: Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения, уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
@ -173,7 +175,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.8`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.9`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -12,7 +12,7 @@ import asyncio
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
@ -45,6 +45,7 @@ class ScheduledMessage:
template_key: str template_key: str
weekdays: tuple[int, ...] = () weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow" timezone: str = "Europe/Moscow"
schedule_id: int | None = None
def matches(self, now: datetime) -> bool: def matches(self, now: datetime) -> bool:
"""Совпадает ли момент ``now`` (aware) с этим расписанием.""" """Совпадает ли момент ``now`` (aware) с этим расписанием."""
@ -76,6 +77,7 @@ def _from_row(row: dict) -> ScheduledMessage:
template_key=str(row["template_key"]), template_key=str(row["template_key"]),
weekdays=tuple(int(day) for day in (row["weekdays"] or ())), weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
timezone=str(row["timezone"] or "Europe/Moscow"), timezone=str(row["timezone"] or "Europe/Moscow"),
schedule_id=int(row["id"]) if row.get("id") is not None else None,
) )
@ -114,6 +116,14 @@ async def _tick(bot: Bot, now: datetime) -> None:
for schedule in await _load_schedules(): for schedule in await _load_schedules():
if not schedule.matches(now): if not schedule.matches(now):
continue continue
# Защита от повторной отправки после перезапуска в ту же минуту.
if schedule.schedule_id is not None:
run_at = now.astimezone(UTC).replace(second=0, microsecond=0)
if not await schedules_repo.claim_run(schedule.schedule_id, run_at):
logger.info("Рассылка %s за %s уже отправлена — пропускаю", schedule.schedule_id, run_at)
continue
subscribers = await users_service.list_subscribers() subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, schedule.template_key, subscribers) await _send_broadcast(bot, schedule.template_key, subscribers)

View File

@ -50,6 +50,27 @@ async def list_active() -> list[dict[str, Any]]:
return await cur.fetchall() return await cur.fetchall()
async def claim_run(schedule_id: int, run_at: Any) -> bool:
"""Атомарно отметить минуту как отработанную.
``True`` — эту минуту ещё никто не отправлял; ``False`` — уже была отправка
(например, до перезапуска бота).
"""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
INSERT INTO bot.schedule_runs (schedule_id, run_at)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(schedule_id, run_at),
)
claimed = bool(cur.rowcount)
if claimed:
await conn.execute("DELETE FROM bot.schedule_runs WHERE run_at < now() - interval '7 days'")
return claimed
async def get_schedule(schedule_id: int) -> dict[str, Any] | None: async def get_schedule(schedule_id: int) -> dict[str, Any] | None:
async with get_pool().connection() as conn: async with get_pool().connection() as conn:
cur = await conn.execute( cur = await conn.execute(

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.6.8" __version__ = "0.6.9"

View File

@ -0,0 +1,11 @@
-- Отметки отправленных минут: защита от повторной рассылки после перезапуска бота.
CREATE TABLE IF NOT EXISTS bot.schedule_runs (
schedule_id bigint NOT NULL,
run_at timestamptz NOT NULL,
PRIMARY KEY (schedule_id, run_at)
);
CREATE INDEX IF NOT EXISTS idx_schedule_runs_run_at
ON bot.schedule_runs (run_at);
COMMENT ON TABLE bot.schedule_runs IS 'Какие минуты рассылок уже отработаны (атомарная защита от дублей)';

View File

@ -6,6 +6,7 @@ import re
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from bot.services import notifications
from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql" SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql"
@ -66,3 +67,58 @@ def test_default_schedules_match_seed() -> None:
) )
assert seeded == list(DEFAULT_SCHEDULES) assert seeded == list(DEFAULT_SCHEDULES)
async def test_tick_skips_already_sent_minute(monkeypatch) -> None:
schedule = ScheduledMessage(12, 0, "notify.blago", schedule_id=1)
sent: list[str] = []
async def load() -> tuple[ScheduledMessage, ...]:
return (schedule,)
async def claim(schedule_id: int, run_at: datetime) -> bool:
return False
async def subscribers() -> list[dict]:
return []
async def broadcast(bot, key: str, rows: list[dict]) -> None:
sent.append(key)
monkeypatch.setattr(notifications, "_load_schedules", load)
monkeypatch.setattr(notifications.schedules_repo, "claim_run", claim)
monkeypatch.setattr(notifications.users_service, "list_subscribers", subscribers)
monkeypatch.setattr(notifications, "_send_broadcast", broadcast)
await notifications._tick(None, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))
assert sent == []
async def test_tick_sends_once_when_claimed(monkeypatch) -> None:
schedule = ScheduledMessage(12, 0, "notify.blago", schedule_id=1)
sent: list[str] = []
claims: list[tuple[int, datetime]] = []
async def load() -> tuple[ScheduledMessage, ...]:
return (schedule,)
async def claim(schedule_id: int, run_at: datetime) -> bool:
claims.append((schedule_id, run_at))
return True
async def subscribers() -> list[dict]:
return []
async def broadcast(bot, key: str, rows: list[dict]) -> None:
sent.append(key)
monkeypatch.setattr(notifications, "_load_schedules", load)
monkeypatch.setattr(notifications.schedules_repo, "claim_run", claim)
monkeypatch.setattr(notifications.users_service, "list_subscribers", subscribers)
monkeypatch.setattr(notifications, "_send_broadcast", broadcast)
await notifications._tick(None, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))
assert sent == ["notify.blago"]
assert claims == [(1, datetime(2026, 1, 1, 9, 0, tzinfo=UTC))]

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.6.8" assert __version__ == "0.6.9"