feat(generator): добавлен артефакт стартовой истории

- Зачем:
  - чистый стенд должен восстанавливать стартовую историю без повторной генерации.
- Что:
  - добавлены export/import артефакта через Kafka и compact-топики.
  - добавлена manifest-aware сверка ClickHouse и защита от смешения state.
  - добавлен runbook использования стартовой истории.
- Проверка:
  - uv run --with-requirements generator/requirements.txt pytest generator/tests -q.
  - bash -n scripts/check_startup_history_manifest.sh scripts/export_startup_history_artifact.sh scripts/import_startup_history_artifact.sh.
  - git diff --check.
This commit is contained in:
2026-07-04 18:23:54 +03:00
parent ee4c9f5857
commit 3c38465d89
16 changed files with 1746 additions and 177 deletions
+142 -13
View File
@@ -369,6 +369,46 @@ class TestGeneratorServiceBackfill:
assert first["digest"] == second["digest"]
assert first["manifest_digest"] == second["manifest_digest"]
def test_backfill_writes_portable_artifact_when_requested(
self, base_config, tmp_path
):
"""Backfill пишет переносимый файл с событиями, state и manifest."""
from clickstream_generator.startup_history_artifact import (
load_startup_history_artifact,
validate_startup_history_artifact,
)
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=1)
artifact_path = tmp_path / "startup-history.json"
config = replace(
base_config,
run_mode="backfill",
model_t0=model_t0,
model_t_end=model_t_end,
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
state_enabled=True,
startup_history_artifact=artifact_path,
)
self._run_backfill(config)
artifact = load_startup_history_artifact(artifact_path)
state, manifest, topics, _ = validate_startup_history_artifact(
artifact,
expected_config=config,
)
assert topics["browser_events"]
assert state.last_batch_id == manifest["state"]["last_batch_id"]
assert manifest["totals"]["events"] == len(topics["browser_events"])
def test_live_start_uses_startup_manifest_without_wall_delta(
self, base_config, event_dictionary
):
@@ -403,6 +443,7 @@ class TestGeneratorServiceBackfill:
"model_t_end": model_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state_version": state.version,
"state": {"last_batch_id": state.last_batch_id},
}
state_manager = MagicMock()
@@ -468,6 +509,7 @@ class TestGeneratorServiceBackfill:
"model_t_end": manifest_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state_version": state.version,
"state": {"last_batch_id": state.last_batch_id},
}
state_manager = MagicMock()
@@ -506,12 +548,61 @@ class TestGeneratorServiceBackfill:
patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(config)
service.start()
with pytest.raises(ValueError, match="GEN_MODEL_T_END.*GEN_STATE_RESET=true"):
service.start()
restore_from_startup_history.assert_not_called()
restore_live_state.assert_not_called()
assert service._tick == 0
assert service._model_time == config.model_t0
def test_live_start_fails_loudly_on_readable_incompatible_state(
self, base_config, event_dictionary
):
"""Читаемый state другого мира при продолжении даёт жёсткий отказ."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
config = replace(
base_config,
model_t0=model_t0,
tick_seconds=60,
model_time_speed=10,
state_reset=False,
)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="live-state",
last_timestamp=model_t0,
model_timestamp=model_t0,
wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed + 1,
)
state_manager = MagicMock()
state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \
patch("clickstream_generator.service.KafkaPublisher"), \
patch("clickstream_generator.service.KafkaBatchHistory"), \
patch(
"clickstream_generator.service.KafkaStateManager",
return_value=state_manager,
), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(config)
with pytest.raises(ValueError, match="GEN_SEED.*GEN_STATE_RESET=true"):
service.start()
def test_live_start_rejects_orphan_startup_state_without_live_restore(
self, base_config, event_dictionary, caplog
@@ -564,13 +655,14 @@ class TestGeneratorServiceBackfill:
caplog.at_level(logging.WARNING, logger="generator"):
service = GeneratorService(config)
service.start()
with pytest.raises(
ValueError,
match="generator_startup_history_manifest.*GEN_STATE_RESET=true",
):
service.start()
restore_from_startup_history.assert_not_called()
restore_live_state.assert_not_called()
assert service._tick == 0
assert service._model_time == model_t0
assert "startup-history state without matching manifest" in caplog.text
def test_startup_history_state_checks_state_fields(
self, base_config, event_dictionary
@@ -601,6 +693,44 @@ class TestGeneratorServiceBackfill:
"model_t_end": model_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state_version": state.version,
"state": {"last_batch_id": state.last_batch_id},
}
service = GeneratorService(config)
assert not service._is_startup_history_state(state, manifest)
def test_startup_history_state_checks_state_version(
self, base_config, event_dictionary
):
"""Startup-history state сверяется с версией state из manifest."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=5)
config = replace(base_config, model_t0=model_t0, model_time_speed=10)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="startup-history-state",
last_timestamp=model_t_end,
model_timestamp=model_t_end,
wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
manifest = {
"run_mode": "backfill",
"gen_seed": config.seed,
"model_t0": model_t0.isoformat(),
"model_t_end": model_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state_version": "1.0",
"state": {"last_batch_id": state.last_batch_id},
}
@@ -930,8 +1060,8 @@ class TestGeneratorServiceStateV2:
assert saved_state.active_visits
service.state_manager.flush.assert_called_once()
def test_incompatible_seed_state_starts_fresh(self, base_config, event_dictionary, caplog):
"""State от другого GEN_SEED не смешивается с текущим запуском."""
def test_incompatible_seed_state_fails_loudly(self, base_config, event_dictionary, caplog):
"""State от другого GEN_SEED даёт жёсткий отказ при продолжении."""
source_config = replace(base_config, seed=7)
source_generator = EventGenerator(event_dictionary, source_config)
source_stream = TickStreamGenerator(source_generator)
@@ -970,11 +1100,10 @@ class TestGeneratorServiceStateV2:
caplog.at_level(logging.WARNING, logger="generator"):
service = GeneratorService(base_config)
service.start()
with pytest.raises(ValueError, match="GEN_SEED.*GEN_STATE_RESET=true"):
service.start()
assert service._tick == 0
assert service._model_time == base_config.model_t0
assert "state config mismatch: gen_seed" in caplog.text
assert "Readable generator state is incompatible" in caplog.text
def test_state_reset_skips_loading_saved_state(self, base_config):
"""GEN_STATE_RESET=true запускает сервис с чистого состояния."""