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:
@@ -16,6 +16,7 @@ from clickstream_generator.generation import EventGenerator
|
||||
from clickstream_generator.kafka_io import (
|
||||
BatchRecord,
|
||||
KafkaBatchHistory,
|
||||
KafkaDataTopicReader,
|
||||
KafkaPublisher,
|
||||
KafkaStateManager,
|
||||
KafkaStartupHistoryManifest,
|
||||
@@ -29,8 +30,10 @@ from clickstream_generator.metrics import (
|
||||
from clickstream_generator.runtime import TickStreamGenerator
|
||||
from clickstream_generator.startup_history_artifact import (
|
||||
StartupHistoryArtifactBuilder,
|
||||
ManifestCounters,
|
||||
build_manifest,
|
||||
generation_settings_from_config,
|
||||
manifest_boundaries,
|
||||
write_startup_history_artifact,
|
||||
)
|
||||
from clickstream_generator.state import UnsupportedStateVersionError
|
||||
@@ -55,6 +58,7 @@ class GeneratorService:
|
||||
self.history: KafkaBatchHistory | None = None
|
||||
self.state_manager: KafkaStateManager | None = None
|
||||
self.manifest_manager: KafkaStartupHistoryManifest | None = None
|
||||
self.data_reader: KafkaDataTopicReader | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
self._shutdown_event = threading.Event()
|
||||
@@ -89,6 +93,8 @@ class GeneratorService:
|
||||
|
||||
if self.config.run_mode == "backfill" and not self.config.state_enabled:
|
||||
raise ValueError("GEN_STATE_ENABLED must be true for backfill")
|
||||
if self.config.run_mode == "next-day" and not self.config.state_enabled:
|
||||
raise ValueError("для next-day GEN_STATE_ENABLED должен быть true")
|
||||
|
||||
if self.config.state_enabled:
|
||||
self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers)
|
||||
@@ -102,6 +108,38 @@ class GeneratorService:
|
||||
self.stop()
|
||||
return
|
||||
|
||||
if self.config.run_mode == "next-day":
|
||||
self.manifest_manager = KafkaStartupHistoryManifest(
|
||||
self.config.kafka_bootstrap_servers
|
||||
)
|
||||
restored_state = self.state_manager.load()
|
||||
manifest = self.manifest_manager.load()
|
||||
if restored_state is None:
|
||||
raise IncompatibleStateError(
|
||||
"state генератора для next-day не найден"
|
||||
)
|
||||
mismatches = self._startup_history_mismatch_fields(
|
||||
restored_state,
|
||||
manifest,
|
||||
)
|
||||
if mismatches:
|
||||
raise IncompatibleStateError(
|
||||
"слепок next-day не согласован: " + ", ".join(mismatches)
|
||||
)
|
||||
model_t_end = self._as_aware_utc(
|
||||
datetime.fromisoformat(manifest["model_t_end"])
|
||||
)
|
||||
self.restore_from_startup_history(
|
||||
restored_state,
|
||||
model_t_end=model_t_end,
|
||||
)
|
||||
self.data_reader = KafkaDataTopicReader(
|
||||
self.config.kafka_bootstrap_servers
|
||||
)
|
||||
self._run_next_day(manifest)
|
||||
self.stop()
|
||||
return
|
||||
|
||||
if not self.config.state_reset:
|
||||
try:
|
||||
restored_state = self.state_manager.load()
|
||||
@@ -481,6 +519,117 @@ class GeneratorService:
|
||||
f"status={status}, sent_counts={sent_counts}"
|
||||
)
|
||||
|
||||
def _run_next_day(self, manifest: dict) -> None:
|
||||
"""Доливает ровно 24 модельных часа от границы manifest."""
|
||||
if not self.publisher:
|
||||
raise RuntimeError("publisher не инициализирован")
|
||||
if not self.history:
|
||||
raise RuntimeError("история batch не инициализирована")
|
||||
if not self.state_manager or not self.manifest_manager:
|
||||
raise RuntimeError("менеджеры state и manifest не инициализированы")
|
||||
|
||||
current_t_end = self._as_aware_utc(
|
||||
datetime.fromisoformat(manifest["model_t_end"])
|
||||
)
|
||||
if self._model_time != current_t_end:
|
||||
raise IncompatibleStateError(
|
||||
"восстановленное время next-day не совпадает с T_end manifest"
|
||||
)
|
||||
target_t_end = current_t_end + timedelta(hours=24)
|
||||
logger.info(
|
||||
"Запуск next-day от %s до %s",
|
||||
current_t_end.isoformat(),
|
||||
target_t_end.isoformat(),
|
||||
)
|
||||
|
||||
while self._model_time < target_t_end:
|
||||
self._tick += 1
|
||||
batch_id = f"next-day-{self._tick:08d}"
|
||||
model_time = self._model_time
|
||||
started_at = datetime.now(timezone.utc)
|
||||
events_count = self.generator._calculate_events_count(now=model_time)
|
||||
batch = self.stream.generate_tick(
|
||||
events_count,
|
||||
tick_started_at=model_time,
|
||||
)
|
||||
total_sent, sent_counts, status = self._publish_batch(batch)
|
||||
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
||||
self._write_batch_history(
|
||||
batch_id=batch_id,
|
||||
started_at=started_at,
|
||||
sent_counts=sent_counts,
|
||||
total_sent=total_sent,
|
||||
status=status,
|
||||
error_message=None,
|
||||
)
|
||||
self._advance_model_time()
|
||||
|
||||
final_batch = self.stream.drain_until(
|
||||
target_t_end,
|
||||
include_boundary=False,
|
||||
)
|
||||
if any(final_batch.values()):
|
||||
batch_id = f"next-day-{self._tick + 1:08d}-final"
|
||||
started_at = datetime.now(timezone.utc)
|
||||
total_sent, sent_counts, status = self._publish_batch(final_batch)
|
||||
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
||||
self._write_batch_history(
|
||||
batch_id=batch_id,
|
||||
started_at=started_at,
|
||||
sent_counts=sent_counts,
|
||||
total_sent=total_sent,
|
||||
status=status,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
self.publisher.flush()
|
||||
self._model_time = target_t_end
|
||||
reader = self.data_reader or KafkaDataTopicReader(
|
||||
self.config.kafka_bootstrap_servers
|
||||
)
|
||||
counters = ManifestCounters()
|
||||
counters.add_batch(reader.load())
|
||||
state_batch_id = self._startup_state_batch_id(counters)
|
||||
state = self.stream.to_state(
|
||||
tick=self._tick,
|
||||
rng_state=self.generator.rng.getstate(),
|
||||
last_batch_id=state_batch_id,
|
||||
last_timestamp=target_t_end,
|
||||
model_timestamp=target_t_end,
|
||||
wall_timestamp=datetime.now(timezone.utc),
|
||||
model_time_speed=self.config.model_time_speed,
|
||||
model_timezone=self.config.model_timezone,
|
||||
model_t0=self.config.model_t0,
|
||||
gen_seed=self.config.seed,
|
||||
)
|
||||
boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()]
|
||||
updated_manifest = build_manifest(
|
||||
self.config,
|
||||
counters,
|
||||
state,
|
||||
model_t_end=target_t_end,
|
||||
boundaries=boundaries,
|
||||
)
|
||||
|
||||
self.state_manager.save(state)
|
||||
self.state_manager.flush()
|
||||
self.manifest_manager.save(updated_manifest)
|
||||
self.manifest_manager.flush()
|
||||
|
||||
def _raise_on_next_day_publish_error(
|
||||
self,
|
||||
batch_id: str,
|
||||
status: str,
|
||||
sent_counts: dict[str, dict[str, int]],
|
||||
) -> None:
|
||||
"""Останавливает next-day до записи state и manifest при ошибке Kafka."""
|
||||
if status == "success":
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Публикация next-day не удалась для batch {batch_id}: "
|
||||
f"status={status}, sent_counts={sent_counts}"
|
||||
)
|
||||
|
||||
def _publish_batch(
|
||||
self,
|
||||
batch: dict[str, list[dict]],
|
||||
|
||||
Reference in New Issue
Block a user