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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user