feat(generator): глагол next-day — следующий модельный день от слепка

- Зачем:
  - режим кормления стенда порциями: менти триггерит «следующий день»,
    видит полный цикл DWH за один шаг (задача 13, вариант 2 — генерация
    от слепка T_end).
- Что:
  - новый ограниченный режим next-day: восстановление мира из state,
    генерация ровно [T_end, T_end+24h), публикация данные -> state ->
    манифест (манифест — точка фиксации, автоотката нет).
  - операция next-day в DAG generator_control: своя предпроверка границы
    вместо clean-guard, идемпотентность через параметр expected_t_end.
  - цепочка границ — накопительное поле boundaries в манифесте, старый
    формат читается как [T0, T_end]; импорт не изменён.
  - новая проверка цепочки (make generated-history-chain-check): непарные
    счётчики и однородность по каждой границе, явный статус нулевого
    стыка, хвост за границей по всем четырём топикам, литералы в UTC
    с микросекундами.
  - документация OPERATIONS.md: глагол, предпроверка, восстановление
    после сбоя, ограничение retention; в задаче 13 — решения двух слепых
    ревью постановки и кода с аргументами отклонений.
- Проверка:
  - make test: 204 теста генератора + 31 контракт корня, зелёные.
  - make generated-history-chain-check: зелёный, 2 внутренние границы,
    непарные счётчики нулевые; учебный цикл: DM 322 -> 10026 -> 19196
    за два next-day подряд.
  - make generated-history-runtime-check (регрессия задачи 20): зелёный.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:53:48 +03:00
co-authored by Claude Fable 5
parent 540989d358
commit f5dee26eda
17 changed files with 1501 additions and 31 deletions
@@ -13,6 +13,7 @@ from clickstream_generator.state import UnsupportedStateVersionError
logger = logging.getLogger("generator")
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
_kafka_imported = False
KafkaProducer = None
@@ -280,6 +281,63 @@ class KafkaStartupHistoryManifest:
return None
class KafkaDataTopicReader:
"""Читает устойчивый снимок data-топиков для накопительного manifest."""
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
def load(self) -> dict[str, list[dict]]:
"""Возвращает все события data-топиков после остановки live-потока."""
from kafka import KafkaConsumer, TopicPartition
consumer = KafkaConsumer(
bootstrap_servers=self.bootstrap_servers,
enable_auto_commit=False,
value_deserializer=lambda value: json.loads(value.decode("utf-8")),
)
topics = {topic: [] for topic in DATA_TOPICS}
try:
partitions = []
for topic in DATA_TOPICS:
topic_partitions = consumer.partitions_for_topic(topic) or set()
partitions.extend(
TopicPartition(topic, partition)
for partition in topic_partitions
)
if not partitions:
return topics
consumer.assign(partitions)
consumer.seek_to_beginning(*partitions)
end_offsets = consumer.end_offsets(partitions)
empty_polls = 0
while any(
consumer.position(partition) < end_offsets[partition]
for partition in partitions
):
records = consumer.poll(timeout_ms=1000)
if not records:
empty_polls += 1
if empty_polls >= 5:
raise RuntimeError(
"не удалось дочитать data-топики до зафиксированных "
"конечных смещений"
)
continue
empty_polls = 0
for partition, messages in records.items():
for message in messages:
if (
message.offset < end_offsets[partition]
and message.value is not None
):
topics[message.topic].append(message.value)
finally:
consumer.close()
return topics
def ensure_topics(bootstrap_servers: str) -> None:
"""Создаёт служебные топики, если их ещё нет."""
from kafka import KafkaAdminClient