fix(generator): устранён флаки runtime-гейта стыка backfill/live

- Зачем:
  - гейт generated-history-runtime-check падал через раз: docker compose
    stop убивал генератор SIGKILL'ом посреди batch (SIGTERM не ловился),
    непарные строки маскировались под «смену фактуры» (задача 20).
- Что:
  - генератор грациозно завершается по SIGTERM: текущий batch дописывается
    во все топики с flush и записью history; compose даёт минуту grace.
  - runtime-check ждёт пересекающий визит в STG (предусловие проверки),
    seam-check получил precheck непарных live-строк; в STG-запросах
    закреплён 'UTC' против сдвига наивных меток в поясе сервера.
  - контрактные тесты усилены, задача 20 закрыта, блокер задачи 13 снят.
- Проверка:
  - тесты: 192 passed (generator), 21 passed (контракты);
  - стенд: 3 подряд зелёных make generated-history-runtime-check;
    красный сценарий (искажение referer_url пересекающего визита) валит
    гейт прежним сообщением при нулевых непарных счётчиках.
This commit is contained in:
2026-07-12 19:33:28 +03:00
parent 55a3feb63d
commit dab120a07e
9 changed files with 433 additions and 30 deletions
+15 -2
View File
@@ -1,7 +1,9 @@
"""Основной сервисный цикл генератора."""
import logging
import signal
import sys
import threading
import time
import uuid
from datetime import datetime, timedelta, timezone
@@ -54,6 +56,8 @@ class GeneratorService:
self.state_manager: KafkaStateManager | None = None
self.manifest_manager: KafkaStartupHistoryManifest | None = None
self._running = False
self._stop_requested = False
self._shutdown_event = threading.Event()
self._tick = 0
self._model_time = config.model_t0
@@ -168,7 +172,7 @@ class GeneratorService:
else:
logger.info("State management disabled")
self._running = True
self._running = not self._stop_requested
try:
self._main_loop()
@@ -181,6 +185,7 @@ class GeneratorService:
"""Останавливает сервис."""
logger.info("Stopping generator service...")
self._running = False
self._shutdown_event.set()
if self.publisher:
self.publisher.close()
if self.history:
@@ -190,6 +195,13 @@ class GeneratorService:
if self.manifest_manager:
self.manifest_manager.close()
def request_stop(self, signum, _frame) -> None:
"""Просит завершить сервис после текущего batch."""
logger.info("Received shutdown signal %s", signum)
self._stop_requested = True
self._running = False
self._shutdown_event.set()
def restore_from_startup_history(
self,
state,
@@ -642,7 +654,7 @@ class GeneratorService:
sleep_time = max(0, self.config.tick_seconds - elapsed)
if sleep_time > 0:
logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick")
time.sleep(sleep_time)
self._shutdown_event.wait(sleep_time)
def _advance_model_time(self) -> None:
"""Сдвигает модельное время после успешного live-тика."""
@@ -656,6 +668,7 @@ def main():
try:
config = Config()
service = GeneratorService(config)
signal.signal(signal.SIGTERM, service.request_stop)
service.start()
except Exception as e:
logger.exception(f"Fatal error: {e}")
+82 -4
View File
@@ -6,6 +6,7 @@ import logging
import random
import hashlib
import json
import signal
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from time import sleep as real_sleep
@@ -21,6 +22,7 @@ from generator import (
GeneratorState,
KafkaBatchHistory,
TickStreamGenerator,
main,
)
from clickstream_generator.state import UnsupportedStateVersionError
@@ -210,8 +212,9 @@ class TestGeneratorServiceSteadyStream:
service._running = False
with caplog.at_level(logging.INFO, logger="generator"), \
patch(
"clickstream_generator.service.time.sleep",
patch.object(
service._shutdown_event,
"wait",
side_effect=stop_after_first_tick,
):
service._main_loop()
@@ -221,6 +224,77 @@ class TestGeneratorServiceSteadyStream:
assert "completed:" not in caplog.text
assert "sent" not in caplog.text
def test_sigterm_during_publish_finishes_current_batch(self, base_config):
"""SIGTERM посреди публикации не обрывает связанные топики batch."""
config = replace(
base_config,
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=3,
metrics_enabled=False,
state_enabled=False,
)
service = GeneratorService(config)
publisher = MagicMock()
history = MagicMock()
published_topics = []
def publish_and_request_stop(topic, events):
published_topics.append(topic)
if len(published_topics) == 1:
service.request_stop(signal.SIGTERM, None)
return len(events), 0
publisher.publish.side_effect = publish_and_request_stop
with patch(
"clickstream_generator.service.start_http_server",
side_effect=AssertionError("тест не должен открывать порт метрик"),
), \
patch("clickstream_generator.service.ensure_topics"), \
patch(
"clickstream_generator.service.KafkaPublisher",
return_value=publisher,
), \
patch(
"clickstream_generator.service.KafkaBatchHistory",
return_value=history,
), \
patch.object(
service.generator,
"_calculate_events_count",
return_value=int(EXPECTED_VISIT_EVENTS),
):
service.start()
assert published_topics == [
"browser_events",
"location_events",
"device_events",
"geo_events",
]
publisher.flush.assert_called_once_with()
history.add.assert_called_once()
history.flush.assert_called_once_with()
publisher.close.assert_called_once_with()
history.close.assert_called_once_with()
assert service._tick == 1
def test_main_connects_sigterm_to_graceful_stop(self, base_config):
"""Точка входа передаёт SIGTERM сервису как штатный запрос остановки."""
service = MagicMock()
with patch("clickstream_generator.service.Config", return_value=base_config), \
patch("clickstream_generator.service.GeneratorService", return_value=service), \
patch("clickstream_generator.service.signal.signal") as register_signal:
main()
register_signal.assert_called_once_with(signal.SIGTERM, service.request_stop)
service.start.assert_called_once_with()
def _run_service_ticks(self, config, wall_now: datetime, ticks_count: int):
service = GeneratorService(config)
service.publisher = MagicMock()
@@ -256,7 +330,11 @@ class TestGeneratorServiceSteadyStream:
"_calculate_events_count",
side_effect=calculate_events_count,
), \
patch("clickstream_generator.service.time.sleep", side_effect=stop_after_tick):
patch.object(
service._shutdown_event,
"wait",
side_effect=stop_after_tick,
):
service._main_loop()
published = {}
@@ -294,7 +372,7 @@ class TestGeneratorServiceSteadyStream:
service.generator,
"_visit_pause_seconds",
return_value=0.05,
), patch("clickstream_generator.service.time.sleep") as sleep_mock:
), patch.object(service._shutdown_event, "wait") as sleep_mock:
sleep_mock.side_effect = stop_after_second_tick
service._main_loop()