SilverElixir commited on
Commit
c43d58c
·
1 Parent(s): 13103aa

Gate webhook updates until startup state loads and shut down gracefully on SIGTERM

Browse files
Files changed (4) hide show
  1. bot.py +59 -7
  2. lumen_admin.py +5 -0
  3. lumen_chat_state.py +5 -0
  4. tests/test_bot_admin.py +88 -1
bot.py CHANGED
@@ -24,7 +24,9 @@ import os
24
  import queue
25
  import re
26
  import secrets
 
27
  import sys
 
28
  import time
29
  from pathlib import Path
30
  from datetime import date
@@ -1469,9 +1471,17 @@ from lumen_message_core import (
1469
  SETWEBHOOK_MAX_ATTEMPTS = 5
1470
  SETWEBHOOK_BACKOFF_SEC = 5.0
1471
 
 
 
 
 
 
 
1472
  async def _webhook_startup() -> None:
 
1473
  # Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
1474
  await asyncio.to_thread(load_state_from_disk)
 
1475
  log.info("Bot startup: webhook mode.")
1476
  # На Space кап можно переопределить env: без этой строки в логах не видно,
1477
  # какой лимит реально работает (разбор 26.10.2026).
@@ -1669,8 +1679,32 @@ def _require_bot_token() -> str:
1669
  return BOT_TOKEN
1670
 
1671
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1672
  async def main() -> None:
1673
- global bot, client
1674
 
1675
  _require_bot_token()
1676
 
@@ -1688,6 +1722,7 @@ async def main() -> None:
1688
  bot = Bot(token=BOT_TOKEN, session=sess)
1689
  client = genai.Client(api_key=GEMINI_API_KEY)
1690
 
 
1691
  startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
1692
  flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
1693
  srv_config = uvicorn.Config(
@@ -1697,6 +1732,9 @@ async def main() -> None:
1697
  app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
1698
  )
1699
  server = uvicorn.Server(srv_config)
 
 
 
1700
  try:
1701
  serve_task = asyncio.create_task(server.serve(), name="uvicorn_serve")
1702
  done, _pending = await asyncio.wait(
@@ -1720,8 +1758,11 @@ async def main() -> None:
1720
  if exc is not None:
1721
  log.error("[serve] HTTP server failed, stopping: %s", exc)
1722
  raise SystemExit(1) from exc
1723
- log.error("[serve] HTTP server exited unexpectedly, stopping")
1724
- raise SystemExit(1)
 
 
 
1725
  finally:
1726
  startup_task.cancel()
1727
  flush_task.cancel()
@@ -1737,10 +1778,21 @@ async def main() -> None:
1737
  # Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
1738
  # bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
1739
  # Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
1740
- if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
1741
- await asyncio.to_thread(_flush_state_now)
1742
- if lumen_chat_state._quota_dirty:
1743
- await asyncio.to_thread(save_global_quota)
 
 
 
 
 
 
 
 
 
 
 
1744
  await _close_sessions()
1745
 
1746
  if __name__ == "__main__":
 
24
  import queue
25
  import re
26
  import secrets
27
+ import signal
28
  import sys
29
+ import threading
30
  import time
31
  from pathlib import Path
32
  from datetime import date
 
1471
  SETWEBHOOK_MAX_ATTEMPTS = 5
1472
  SETWEBHOOK_BACKOFF_SEC = 5.0
1473
 
1474
+ # /webhook отвечает 503, пока старт не загрузил состояние: иначе апдейт,
1475
+ # пришедший во время загрузки, создаёт пустой чат и перезаписывает удалённую
1476
+ # историю/индекс (аудит 28.10.2026).
1477
+ _state_ready = True
1478
+
1479
+
1480
  async def _webhook_startup() -> None:
1481
+ global _state_ready
1482
  # Синхронное дисковое чтение в потоке: иначе старт стопорит приём /webhook.
1483
  await asyncio.to_thread(load_state_from_disk)
1484
+ _state_ready = True
1485
  log.info("Bot startup: webhook mode.")
1486
  # На Space кап можно переопределить env: без этой строки в логах не видно,
1487
  # какой лимит реально работает (разбор 26.10.2026).
 
1679
  return BOT_TOKEN
1680
 
1681
 
1682
+ @contextlib.contextmanager
1683
+ def _capture_signals_gracefully(server: Any):
1684
+ """SIGTERM/SIGINT: uvicorn по умолчанию пере-поднимает сигнал после
1685
+ serve(), и finally с финальным сбросом состояния не выполняется
1686
+ (аудит 28.10.2026). Перехватываем сигнал и только просим сервер выйти."""
1687
+ if threading.current_thread() is not threading.main_thread():
1688
+ yield
1689
+ return
1690
+ original = {}
1691
+
1692
+ def _stop(signum, frame):
1693
+ server.should_exit = True
1694
+
1695
+ for sig in (signal.SIGINT, signal.SIGTERM):
1696
+ with contextlib.suppress(Exception):
1697
+ original[sig] = signal.signal(sig, _stop)
1698
+ try:
1699
+ yield
1700
+ finally:
1701
+ for sig, handler in original.items():
1702
+ with contextlib.suppress(Exception):
1703
+ signal.signal(sig, handler)
1704
+
1705
+
1706
  async def main() -> None:
1707
+ global bot, client, _state_ready
1708
 
1709
  _require_bot_token()
1710
 
 
1722
  bot = Bot(token=BOT_TOKEN, session=sess)
1723
  client = genai.Client(api_key=GEMINI_API_KEY)
1724
 
1725
+ _state_ready = False
1726
  startup_task = asyncio.create_task(_webhook_startup(), name="webhook_startup")
1727
  flush_task = asyncio.create_task(_flush_dirty_state(), name="state_flush")
1728
  srv_config = uvicorn.Config(
 
1732
  app=app, host="0.0.0.0", port=7860, log_level=None, loop="asyncio", log_config=None,
1733
  )
1734
  server = uvicorn.Server(srv_config)
1735
+ # Не даём uvicorn пере-поднять SIGTERM после serve(): иначе финальный
1736
+ # сброс состояния и закрытие сессий не выполняются.
1737
+ server.capture_signals = lambda: _capture_signals_gracefully(server)
1738
  try:
1739
  serve_task = asyncio.create_task(server.serve(), name="uvicorn_serve")
1740
  done, _pending = await asyncio.wait(
 
1758
  if exc is not None:
1759
  log.error("[serve] HTTP server failed, stopping: %s", exc)
1760
  raise SystemExit(1) from exc
1761
+ if getattr(server, "should_exit", False):
1762
+ log.info("[serve] HTTP server stopped gracefully")
1763
+ else:
1764
+ log.error("[serve] HTTP server exited unexpectedly, stopping")
1765
+ raise SystemExit(1)
1766
  finally:
1767
  startup_task.cancel()
1768
  flush_task.cancel()
 
1778
  # Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
1779
  # bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
1780
  # Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
1781
+ if lumen_chat_state._state_load_failed:
1782
+ # Стартовая загрузка не удалась: не затираем удалённые данные
1783
+ # (аудит 28.10.2026).
1784
+ log.warning('[state] Startup load failed: skipping the final flush to keep remote data intact.')
1785
+ else:
1786
+ if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
1787
+ try:
1788
+ await asyncio.wait_for(asyncio.to_thread(_flush_state_now), timeout=15.0)
1789
+ except asyncio.TimeoutError:
1790
+ log.warning('[state] Final flush exceeded 15s; abandoning the remainder.')
1791
+ if lumen_chat_state._quota_dirty:
1792
+ try:
1793
+ await asyncio.wait_for(asyncio.to_thread(save_global_quota), timeout=10.0)
1794
+ except asyncio.TimeoutError:
1795
+ log.warning('[state] Final quota save exceeded 10s; abandoning the remainder.')
1796
  await _close_sessions()
1797
 
1798
  if __name__ == "__main__":
lumen_admin.py CHANGED
@@ -155,6 +155,11 @@ async def webhook_handler(request: Request) -> Any:
155
  if not bot.WEBHOOK_SECRET or not token or not hmac.compare_digest(token.encode(), bot.WEBHOOK_SECRET.encode()):
156
  _log_webhook_denied("invalid secret token")
157
  return {"ok": False}
 
 
 
 
 
158
  # Тело апдейта маленькое: отсекаем мусор по заголовку до чтения.
159
  content_length = request.headers.get("Content-Length")
160
  if content_length is not None:
 
155
  if not bot.WEBHOOK_SECRET or not token or not hmac.compare_digest(token.encode(), bot.WEBHOOK_SECRET.encode()):
156
  _log_webhook_denied("invalid secret token")
157
  return {"ok": False}
158
+ # Состояние ещё грузится: 503, чтобы Telegram повторил позже, а не создал
159
+ # пустой чат и не перезаписал удалённую историю (аудит 28.10.2026).
160
+ if not getattr(bot, "_state_ready", True):
161
+ log.info("[webhook] State is still loading, asking Telegram to retry")
162
+ return JSONResponse(status_code=503, content={"ok": False, "retry": True})
163
  # Тело апдейта маленькое: отсекаем мусор по заголовку до чтения.
164
  content_length = request.headers.get("Content-Length")
165
  if content_length is not None:
lumen_chat_state.py CHANGED
@@ -675,6 +675,11 @@ async def _flush_dirty_state_once() -> None:
675
  '[state] %d chat deletion(s) failed, will retry next cycle: %s',
676
  len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
677
  )
 
 
 
 
 
678
  if _dirty_chat_ids:
679
  to_save = list(_dirty_chat_ids)
680
  _dirty_chat_ids.clear()
 
675
  '[state] %d chat deletion(s) failed, will retry next cycle: %s',
676
  len(failed_deletes), ", ".join(str(c) for c in sorted(failed_deletes)),
677
  )
678
+ if _state_load_failed and _dirty_chat_ids:
679
+ # Удалённые истории не затёрты: файл в памяти — это либо новый чат,
680
+ # либо всё, что осталось после сбоя загрузки; индекс и квота при
681
+ # этом не пишутся (аудит A5-2, 28.10.2026).
682
+ log.warning('[state] Startup load failed; saving chats from memory, index stays untouched.')
683
  if _dirty_chat_ids:
684
  to_save = list(_dirty_chat_ids)
685
  _dirty_chat_ids.clear()
tests/test_bot_admin.py CHANGED
@@ -10,7 +10,7 @@ import os
10
  import threading
11
  import time
12
  import pytest
13
- from unittest.mock import Mock
14
  from tests.bot_test_helpers import (
15
  _FakeAdminRequest,
16
  _FakeWebhookRequest,
@@ -24,9 +24,11 @@ def _clear_webhook_update_dedup():
24
  import lumen_admin
25
  lumen_admin._seen_update_ids.clear()
26
  lumen_admin._seen_update_id_set.clear()
 
27
  yield
28
  lumen_admin._seen_update_ids.clear()
29
  lumen_admin._seen_update_id_set.clear()
 
30
 
31
 
32
  def test_public_log_formatter_hashes_long_numeric_ids():
@@ -162,6 +164,33 @@ def test_webhook_handler_tracks_dispatched_task_for_shutdown():
162
  bot._process_raw_update = original_process
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  def test_webhook_handler_ignores_duplicate_update_id():
166
  # Telegram повторяет доставку при таймауте/сбое: один update_id не должен
167
  # давать второй ответ, скачивание и трату квоты (аудит 28.10.2026).
@@ -800,6 +829,64 @@ def test_main_exits_nonzero_when_server_dies_first(monkeypatch, caplog):
800
  lumen_chat_state._quota_dirty = orig_quota_dirty
801
 
802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
804
  # Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
805
  class _FakeResp:
 
10
  import threading
11
  import time
12
  import pytest
13
+ from unittest.mock import AsyncMock, Mock
14
  from tests.bot_test_helpers import (
15
  _FakeAdminRequest,
16
  _FakeWebhookRequest,
 
24
  import lumen_admin
25
  lumen_admin._seen_update_ids.clear()
26
  lumen_admin._seen_update_id_set.clear()
27
+ bot._state_ready = True
28
  yield
29
  lumen_admin._seen_update_ids.clear()
30
  lumen_admin._seen_update_id_set.clear()
31
+ bot._state_ready = True
32
 
33
 
34
  def test_public_log_formatter_hashes_long_numeric_ids():
 
164
  bot._process_raw_update = original_process
165
 
166
 
167
+ def test_webhook_returns_503_until_state_is_ready():
168
+ # Апдейт во время загрузки состояния не должен создавать пустой чат и
169
+ # перезаписывать удалённую историю (аудит 28.10.2026).
170
+ original_secret = bot.WEBHOOK_SECRET
171
+ original_bot_obj = bot.bot
172
+ original_process = bot._process_raw_update
173
+ original_ready = bot._state_ready
174
+ bot.WEBHOOK_SECRET = "real-webhook-secret"
175
+ bot.bot = object()
176
+ bot._state_ready = False
177
+ process = AsyncMock()
178
+ bot._process_raw_update = process
179
+ try:
180
+ req = _FakeWebhookRequest(
181
+ headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"},
182
+ body={"update_id": 778},
183
+ )
184
+ result = asyncio.run(_run_webhook_handler(req))
185
+ assert getattr(result, "status_code", None) == 503
186
+ process.assert_not_called()
187
+ finally:
188
+ bot.WEBHOOK_SECRET = original_secret
189
+ bot.bot = original_bot_obj
190
+ bot._process_raw_update = original_process
191
+ bot._state_ready = original_ready
192
+
193
+
194
  def test_webhook_handler_ignores_duplicate_update_id():
195
  # Telegram повторяет доставку при таймауте/сбое: один update_id не должен
196
  # давать второй ответ, скачивание и трату квоты (аудит 28.10.2026).
 
829
  lumen_chat_state._quota_dirty = orig_quota_dirty
830
 
831
 
832
+ def test_main_flushes_on_graceful_server_exit(monkeypatch, caplog):
833
+ # SIGTERM/SIGINT: сервер завершился штатно — финальный сброс состояния и
834
+ # закрытие сессий обязаны выполниться (аудит 28.10.2026).
835
+ from types import SimpleNamespace
836
+ from unittest.mock import AsyncMock
837
+ import lumen_chat_state
838
+ monkeypatch.setattr(bot, "_require_bot_token", lambda: "123:abc")
839
+ monkeypatch.setattr(bot, "Bot", lambda *a, **k: SimpleNamespace())
840
+ monkeypatch.setattr(bot, "IPv4AiohttpSession", lambda *a, **k: SimpleNamespace())
841
+ monkeypatch.setattr(bot.genai, "Client", lambda *a, **k: SimpleNamespace())
842
+
843
+ async def _never():
844
+ await asyncio.sleep(3600)
845
+
846
+ monkeypatch.setattr(bot, "_webhook_startup", _never)
847
+ monkeypatch.setattr(bot, "_flush_dirty_state", _never)
848
+ monkeypatch.setattr(bot, "_drain_inflight_tasks", AsyncMock())
849
+ monkeypatch.setattr(bot, "_close_sessions", AsyncMock())
850
+ monkeypatch.setattr(bot.uvicorn, "Config", lambda *a, **k: SimpleNamespace())
851
+
852
+ class _StoppedServer:
853
+ should_exit = True
854
+
855
+ async def serve(self):
856
+ return None
857
+
858
+ monkeypatch.setattr(bot.uvicorn, "Server", lambda cfg: _StoppedServer())
859
+ emitted = []
860
+ monkeypatch.setattr(bot, "_flush_state_now", lambda: emitted.append("chats"))
861
+ monkeypatch.setattr(bot, "save_global_quota", lambda: emitted.append("quota"))
862
+ orig_bot, orig_client = bot.bot, bot.client
863
+ orig_ready = bot._state_ready
864
+ orig_dirty, orig_deletions = set(bot._dirty_chat_ids), set(bot._pending_chat_deletions)
865
+ orig_index_dirty, orig_quota_dirty = lumen_chat_state._index_dirty, lumen_chat_state._quota_dirty
866
+ orig_load_failed = lumen_chat_state._state_load_failed
867
+ bot._dirty_chat_ids.clear()
868
+ bot._dirty_chat_ids.add(999001)
869
+ bot._pending_chat_deletions.clear()
870
+ lumen_chat_state._index_dirty = False
871
+ lumen_chat_state._quota_dirty = True
872
+ lumen_chat_state._state_load_failed = False
873
+ try:
874
+ with caplog.at_level(logging.INFO, logger="bot"):
875
+ asyncio.run(bot.main())
876
+ assert "[serve] HTTP server stopped gracefully" in caplog.text
877
+ assert "chats" in emitted and "quota" in emitted
878
+ finally:
879
+ bot.bot, bot.client = orig_bot, orig_client
880
+ bot._state_ready = orig_ready
881
+ bot._dirty_chat_ids.clear()
882
+ bot._dirty_chat_ids.update(orig_dirty)
883
+ bot._pending_chat_deletions.clear()
884
+ bot._pending_chat_deletions.update(orig_deletions)
885
+ lumen_chat_state._index_dirty = orig_index_dirty
886
+ lumen_chat_state._quota_dirty = orig_quota_dirty
887
+ lumen_chat_state._state_load_failed = orig_load_failed
888
+
889
+
890
  def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
891
  # Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
892
  class _FakeResp: