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
+179
View File
@@ -1005,6 +1005,185 @@ class TestGeneratorServiceBackfill:
}
class TestGeneratorServiceNextDay:
"""Проверки ограниченной доливки следующего модельного дня."""
def test_next_day_publishes_24h_then_state_then_cumulative_manifest(
self, base_config
):
"""Next-day пишет [T_end, T_end+24h), затем state и manifest."""
from clickstream_generator.startup_history_artifact import (
StartupHistoryArtifactBuilder,
build_manifest,
)
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
current_t_end = model_t0 + timedelta(hours=1)
config = replace(
base_config,
run_mode="next-day",
model_t0=model_t0,
model_t_end=current_t_end,
tick_seconds=3600,
model_time_speed=1,
lambda_base_per_min=60,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
)
service = GeneratorService(config)
state = service.stream.to_state(
tick=1,
rng_state=service.generator.rng.getstate(),
last_batch_id="startup-history-initial",
last_timestamp=current_t_end,
model_timestamp=current_t_end,
wall_timestamp=current_t_end,
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
old_batch = {
"browser_events": [{
"event_id": "old-event",
"click_id": "old-click",
"event_timestamp": "2026-01-01 00:30:00.000000",
}],
"location_events": [{"event_id": "old-event"}],
"device_events": [{
"click_id": "old-click",
"user_domain_id": "old-user",
}],
"geo_events": [{"click_id": "old-click"}],
}
initial_builder = StartupHistoryArtifactBuilder()
initial_builder.add_batch(old_batch)
manifest = build_manifest(config, initial_builder.counters, state)
published = {topic: [] for topic in old_batch}
service.publisher = MagicMock()
def publish(topic, events):
published[topic].extend(events)
return len(events), 0
service.publisher.publish.side_effect = publish
service.history = MagicMock()
order = []
service.publisher.flush.side_effect = lambda: order.append("data")
service.state_manager = MagicMock()
service.state_manager.save.side_effect = lambda _state: order.append("state")
service.manifest_manager = MagicMock()
service.manifest_manager.save.side_effect = (
lambda _manifest: order.append("manifest")
)
class DataReader:
def load(self):
return {
topic: old_batch[topic] + published[topic]
for topic in old_batch
}
service.data_reader = DataReader()
service.restore_from_startup_history(state, model_t_end=current_t_end)
service._run_next_day(manifest)
browser_events = published["browser_events"]
timestamps = [
datetime.fromisoformat(event["event_timestamp"].replace(" ", "T"))
for event in browser_events
]
target_t_end = current_t_end + timedelta(hours=24)
saved_state = service.state_manager.save.call_args.args[0]
saved_manifest = service.manifest_manager.save.call_args.args[0]
assert browser_events
assert min(timestamps) >= current_t_end.replace(tzinfo=None)
assert max(timestamps) < target_t_end.replace(tzinfo=None)
assert saved_state.model_timestamp == target_t_end
assert saved_manifest["model_t_end"] == target_t_end.isoformat()
assert saved_manifest["boundaries"] == [
model_t0.isoformat(),
current_t_end.isoformat(),
target_t_end.isoformat(),
]
assert saved_manifest["totals"]["events"] == len(browser_events) + 1
assert order == ["data", "state", "manifest"]
first_day_events = len(browser_events)
first_day_checksum = saved_manifest["topics"]["browser_events"][
"checksum_sha256"
]
service._run_next_day(saved_manifest)
second_state = service.state_manager.save.call_args.args[0]
second_manifest = service.manifest_manager.save.call_args.args[0]
expected_builder = StartupHistoryArtifactBuilder()
expected_builder.add_batch(DataReader().load())
assert second_state.model_timestamp == target_t_end + timedelta(hours=24)
assert second_manifest["boundaries"] == [
model_t0.isoformat(),
current_t_end.isoformat(),
target_t_end.isoformat(),
(target_t_end + timedelta(hours=24)).isoformat(),
]
assert second_manifest["totals"]["events"] == len(browser_events) + 1
assert len(browser_events) > first_day_events
assert (
second_manifest["topics"]["browser_events"]["checksum_sha256"]
!= first_day_checksum
)
assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics()
assert order == [
"data",
"state",
"manifest",
"data",
"state",
"manifest",
]
def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config):
"""Ошибка data-топика оставляет обе точки фиксации без изменений."""
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
config = replace(
base_config,
run_mode="next-day",
model_t0=model_t0,
model_t_end=model_t0 + timedelta(hours=1),
tick_seconds=3600,
model_time_speed=1,
max_active_sessions=250,
population_max=251,
)
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = lambda topic, events: (
(len(events), 1) if topic == "location_events" else (len(events), 0)
)
service.history = MagicMock()
service.state_manager = MagicMock()
service.manifest_manager = MagicMock()
service._model_time = config.model_t_end
manifest = {
"model_t0": model_t0.isoformat(),
"model_t_end": config.model_t_end.isoformat(),
"boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()],
}
with pytest.raises(RuntimeError, match="Публикация next-day не удалась"):
service._run_next_day(manifest)
service.state_manager.save.assert_not_called()
service.manifest_manager.save.assert_not_called()
class TestGeneratorServiceState:
"""Тесты подключения state к сервисному запуску."""