feat(generator): инкрементальные счётчики manifest без перечитки Kafka

- Зачем:
  - world_next_day перечитывал всю историю топиков Kafka ради
    накопительных счётчиков — время прогона росло с возрастом мира
    (issue #5, находка F9).
- Что:
  - счётчики засеваются при import из уже прочитанного артефакта и при
    backfill из потока; next-day продвигает их только событиями нового
    дня, полного чтения Kafka больше нет;
  - катящаяся контрольная сумма — сумма SHA-256 событий по модулю 2^256
    (инкремент равен полному пересчёту), старый формат артефакта
    принимается без изменений;
  - точные множества click_id/user_domain_id вынесены из manifest в
    цепочку контент-адресуемых фрагментов (<=10 000 ID, SHA-256-цепочка,
    отдельный топик counter_chunks) — потолок сообщения Kafka не грозит,
    предел 900 000 байт проверяется явно с понятной ошибкой;
  - порядок записи всюду: фрагменты -> manifest -> state; старое локальное
    состояние отклоняется с подсказкой перезапустить import;
  - документация manifest/state обновлена (ARCHITECTURE, OPERATIONS,
    runbook startup-history).
- Проверка:
  - make test (216+31) и make lint зелёные;
  - живая приёмка на чистом стенде: import 235 с; три прогона
    world_next_day — 716/718/716 с (плоское время, O(нового дня));
    мир 3->6 дней, 561 942 события; make generated-history-chain-check —
    все порции и стыки однородны;
  - тест равенства инкремента и полного пересчёта:
    test_incremental_counters_equal_full_recompute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:02 +03:00
co-authored by Claude Fable 5
parent f4971e94ca
commit 103ac021c8
12 changed files with 845 additions and 54 deletions
+6 -1
View File
@@ -670,7 +670,12 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
**DAG `world_next_day`** без параметров пакетно добавляет следующий модельный **DAG `world_next_day`** без параметров пакетно добавляет следующий модельный
день, запускает `etl_pipeline` с `full_refresh` и сверяет manifest. У него задано день, запускает `etl_pipeline` с `full_refresh` и сверяет manifest. У него задано
расписание каждые 30 минут, но DAG создаётся на паузе и не выполняет пропущенные интервалы. расписание каждые 30 минут, но DAG создаётся на паузе и не выполняет пропущенные
интервалы. Счётчики manifest продолжаются из compact-топиков state/manifest и
обновляются событиями нового дня; старые data-топики Kafka не перечитываются.
Точные множества идентификаторов лежат отдельными неизменяемыми фрагментами, а
основная запись manifest хранит только числа и ссылку на их цепочку. Полная
пересборка ETL при этом сохраняется намеренно.
**Подключение к ClickHouse:** **Подключение к ClickHouse:**
- Connection: `clickhouse_default` - Connection: `clickhouse_default`
+11 -10
View File
@@ -82,7 +82,6 @@ Backfill/import требуют чистый стенд: пустые data-топ
- Один запуск восстанавливает мир из state, добавляет 24 модельных часа, - Один запуск восстанавливает мир из state, добавляет 24 модельных часа,
запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest. запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest.
- Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`. - Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`.
Не включайте расписание до внедрения накопительных счётчиков manifest.
- `max_active_runs=1` не даёт двум доливкам выполняться параллельно. - `max_active_runs=1` не даёт двум доливкам выполняться параллельно.
`world_next_day` работает на непустом стенде и не использует проверку чистоты. `world_next_day` работает на непустом стенде и не использует проверку чистоты.
@@ -107,13 +106,15 @@ data-топики, state, manifest. Автоматического отката
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
последний исправный портативный артефакт, затем повторите запуск `world_next_day`. последний исправный портативный артефакт, затем повторите запуск `world_next_day`.
Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей `next-day` не перечитывает старые data-топики Kafka. Основная запись manifest
доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти хранит суммы, катящуюся контрольную сумму и SHA-256-ссылку на цепочку точных
каждой доливки растут вместе с историей. Стандартный срок хранения Kafka тоже множеств. Сами `click_id` и `user_domain_id` разбиты на небольшие неизменяемые
ограничивает долгую работу стенда. Для ручного учебного цикла запускайте фрагменты в отдельном compact-топике. Новый день записывает только новый
соседние дни без долгих пауз. Перед включением расписания отдельная задача фрагмент, поэтому размер одной записи не растёт с возрастом мира, а загрузка
должна выбрать одно из решений: бессрочное хранение data-топиков или новый основного manifest не сканирует всю цепочку.
формат накопительного состояния manifest. State ссылается на основную запись manifest, чтобы две точки продолжения нельзя
было случайно смешать. Если локальный state создан старым кодом и раздела в нём
нет, повторно запустите `world_init` с операцией `import` на чистом стенде.
Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если
стоит, задача падает сразу с подсказкой снять паузу в UI или командой: стоит, задача падает сразу с подсказкой снять паузу в UI или командой:
@@ -294,8 +295,8 @@ GEN_HISTORY_DURATION=2d make generated-history-analytics
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты. что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.
Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day` Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day`
добавляет одну границу, сдвигает `model_t_end` и пересчитывает накопительные добавляет одну границу, сдвигает `model_t_end` и дополняет накопительные
счётчики и контрольные суммы по всей истории. счётчики только событиями нового дня. Полной перечитки Kafka в этом пути нет.
Если историю нужно сохранить в файл и восстановить на чистом стенде без новой Если историю нужно сохранить в файл и восстановить на чистом стенде без новой
генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md). генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md).
+17 -1
View File
@@ -78,7 +78,8 @@ make startup-history-import
5. Когда нужен ещё один модельный день, запустите `world_next_day` с пустой формой. 5. Когда нужен ещё один модельный день, запустите `world_next_day` с пустой формой.
`world_next_day` имеет расписание каждые 30 минут, но по умолчанию стоит на паузе. `world_next_day` имеет расписание каждые 30 минут, но по умолчанию стоит на паузе.
Не включайте расписание до внедрения накопительных счётчиков manifest. Один запуск добавляет один модельный день; `max_active_runs=1` не допускает
параллельных доливок.
## Операции сопровождающего ## Операции сопровождающего
@@ -157,6 +158,21 @@ CHECK_LIVE_SEAM=0 make generated-history-check
compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и
Materialized View, затем batch строит ODS, DDS и DM. Materialized View, затем batch строит ODS, DDS и DM.
Во время импорта генератор за один проход по событиям создаёт локальное
накопительное состояние manifest. Эталонный файл не меняется: в нём этого
служебного раздела нет. Основная запись manifest в Kafka хранит суммы, катящуюся
контрольную сумму и ссылку на цепочку точных множеств. `click_id` и
`user_domain_id` лежат небольшими неизменяемыми фрагментами в отдельном
compact-топике. State хранит SHA-256-ссылку на основную запись.
`backfill` создаёт такое состояние сразу по ходу генерации.
`world_next_day` восстанавливает эти числа и добавляет только события нового
дня, не читая старую историю Kafka и не перезаписывая прежние фрагменты.
Контрольная сумма складывает 256-битные
SHA-256-отпечатки записей по модулю 2^256, поэтому результат не зависит от
границ пакетов и совпадает с полным пересчётом. Если старый локальный state не
содержит ссылку на накопительные числа, очистите стенд и повторите `import`.
Импорт запускайте с тем же профилем, на котором создан артефакт. Для служебного Импорт запускайте с тем же профилем, на котором создан артефакт. Для служебного
артефакта `ci` профиль нужно задать явно: артефакта `ci` профиль нужно задать явно:
@@ -21,8 +21,11 @@ from clickstream_generator.service import GeneratorService
from clickstream_generator.startup_history_artifact import ( from clickstream_generator.startup_history_artifact import (
KafkaRawPublisher, KafkaRawPublisher,
KafkaTopicInspector, KafkaTopicInspector,
ManifestCounters,
compare_clickhouse_stats_to_manifest, compare_clickhouse_stats_to_manifest,
cumulative_counter_reference,
import_startup_history_artifact, import_startup_history_artifact,
known_counter_ids_from_state,
load_startup_history_artifact, load_startup_history_artifact,
manifest_boundaries, manifest_boundaries,
validate_startup_history_artifact, validate_startup_history_artifact,
@@ -171,6 +174,38 @@ def assert_next_day_snapshot(manifest: dict | None, state) -> None:
raise RuntimeError( raise RuntimeError(
f"state не согласован с T_end manifest: {exc}" f"state не согласован с T_end manifest: {exc}"
) from exc ) from exc
counter_reference = state.cumulative_manifest_counters
counter_state = manifest.get("cumulative_manifest_counters")
if counter_reference is None or counter_state is None:
raise RuntimeError(
"В state нет накопительных счётчиков manifest; "
"повторно запустите import эталонного мира."
)
if cumulative_counter_reference(counter_state) != counter_reference:
raise RuntimeError(
"Накопительные счётчики state и manifest не совпадают; "
"повторно запустите import эталонного мира."
)
known_click_ids, known_user_ids = known_counter_ids_from_state(state)
try:
counters = ManifestCounters.from_state(
counter_state,
known_click_ids=known_click_ids,
known_user_ids=known_user_ids,
)
except ValueError as exc:
raise RuntimeError(
"Накопительные счётчики manifest повреждены; "
"повторно запустите import эталонного мира."
) from exc
if (
counters.to_manifest_topics() != manifest.get("topics")
or counters.to_manifest_totals() != manifest.get("totals")
):
raise RuntimeError(
"Накопительные счётчики не совпадают с числами manifest; "
"повторно запустите import эталонного мира."
)
def _parse_utc_timestamp(value: str): def _parse_utc_timestamp(value: str):
@@ -1,5 +1,6 @@
"""Kafka-интеграция генератора.""" """Kafka-интеграция генератора."""
import hashlib
import json import json
import logging import logging
import sys import sys
@@ -14,6 +15,7 @@ from clickstream_generator.state import UnsupportedStateVersionError
logger = logging.getLogger("generator") logger = logging.getLogger("generator")
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events") DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
MAX_COMPACT_MESSAGE_BYTES = 900_000
_kafka_imported = False _kafka_imported = False
KafkaProducer = None KafkaProducer = None
@@ -207,7 +209,9 @@ class KafkaStartupHistoryManifest:
"""Хранение манифеста стартовой истории в Kafka compact topic.""" """Хранение манифеста стартовой истории в Kafka compact topic."""
MANIFEST_TOPIC = "generator_startup_history_manifest" MANIFEST_TOPIC = "generator_startup_history_manifest"
COUNTER_TOPIC = "generator_startup_history_counter_chunks"
MANIFEST_KEY = "default" MANIFEST_KEY = "default"
COUNTER_CHUNK_KEY_PREFIX = "id-set:"
def __init__(self, bootstrap_servers: str): def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers self.bootstrap_servers = bootstrap_servers
@@ -227,6 +231,8 @@ class KafkaStartupHistoryManifest:
def save(self, manifest: dict) -> None: def save(self, manifest: dict) -> None:
"""Сохраняет манифест стартовой истории.""" """Сохраняет манифест стартовой истории."""
self._assert_message_size(manifest, "manifest")
def _do_send(): def _do_send():
self.producer.send( self.producer.send(
self.MANIFEST_TOPIC, self.MANIFEST_TOPIC,
@@ -236,6 +242,40 @@ class KafkaStartupHistoryManifest:
_retry(_do_send, max_retries=3, base_delay=0.5) _retry(_do_send, max_retries=3, base_delay=0.5)
def save_counter_chunk(self, chunk_sha256: str, chunk: dict) -> None:
"""Сохраняет неизменяемый фрагмент точных множеств идентификаторов."""
if not isinstance(chunk_sha256, str) or len(chunk_sha256) != 64:
raise ValueError("хеш фрагмента множеств идентификаторов неверен")
actual_sha256 = hashlib.sha256(
json.dumps(
chunk,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
).hexdigest()
if actual_sha256 != chunk_sha256:
raise ValueError("хеш фрагмента множеств идентификаторов не совпадает")
self._assert_message_size(chunk, "фрагмент множеств идентификаторов")
def _do_send():
self.producer.send(
self.COUNTER_TOPIC,
key=self.COUNTER_CHUNK_KEY_PREFIX + chunk_sha256,
value=chunk,
)
_retry(_do_send, max_retries=3, base_delay=0.5)
@staticmethod
def _assert_message_size(value: dict, label: str) -> None:
size = len(json.dumps(value, ensure_ascii=True).encode("utf-8"))
if size > MAX_COMPACT_MESSAGE_BYTES:
raise ValueError(
f"{label} занимает {size} байт и превышает безопасный предел "
f"{MAX_COMPACT_MESSAGE_BYTES} байт одного сообщения Kafka"
)
def flush(self) -> None: def flush(self) -> None:
"""Сбрасывает буфер с retry.""" """Сбрасывает буфер с retry."""
def _do_flush(): def _do_flush():
@@ -372,8 +412,23 @@ def ensure_topics(bootstrap_servers: str) -> None:
"delete.retention.ms": "100", "delete.retention.ms": "100",
}, },
) )
counter_topic = NewTopic(
name=KafkaStartupHistoryManifest.COUNTER_TOPIC,
num_partitions=1,
replication_factor=1,
topic_configs={
"cleanup.policy": "compact",
"min.cleanable.dirty.ratio": "0.1",
"delete.retention.ms": "100",
},
)
for topic in [history_topic, state_topic, manifest_topic]: for topic in [
history_topic,
state_topic,
manifest_topic,
counter_topic,
]:
try: try:
admin_client.create_topics([topic]) admin_client.create_topics([topic])
logger.info(f"Created topic: {topic.name}") logger.info(f"Created topic: {topic.name}")
+63 -14
View File
@@ -16,7 +16,6 @@ from clickstream_generator.generation import EventGenerator
from clickstream_generator.kafka_io import ( from clickstream_generator.kafka_io import (
BatchRecord, BatchRecord,
KafkaBatchHistory, KafkaBatchHistory,
KafkaDataTopicReader,
KafkaPublisher, KafkaPublisher,
KafkaStateManager, KafkaStateManager,
KafkaStartupHistoryManifest, KafkaStartupHistoryManifest,
@@ -32,7 +31,9 @@ from clickstream_generator.startup_history_artifact import (
StartupHistoryArtifactBuilder, StartupHistoryArtifactBuilder,
ManifestCounters, ManifestCounters,
build_manifest, build_manifest,
cumulative_counter_reference,
generation_settings_from_config, generation_settings_from_config,
known_counter_ids_from_state,
manifest_boundaries, manifest_boundaries,
write_startup_history_artifact, write_startup_history_artifact,
) )
@@ -58,7 +59,6 @@ class GeneratorService:
self.history: KafkaBatchHistory | None = None self.history: KafkaBatchHistory | None = None
self.state_manager: KafkaStateManager | None = None self.state_manager: KafkaStateManager | None = None
self.manifest_manager: KafkaStartupHistoryManifest | None = None self.manifest_manager: KafkaStartupHistoryManifest | None = None
self.data_reader: KafkaDataTopicReader | None = None
self._running = False self._running = False
self._stop_requested = False self._stop_requested = False
self._shutdown_event = threading.Event() self._shutdown_event = threading.Event()
@@ -133,10 +133,7 @@ class GeneratorService:
restored_state, restored_state,
model_t_end=model_t_end, model_t_end=model_t_end,
) )
self.data_reader = KafkaDataTopicReader( self._run_next_day(manifest, restored_state)
self.config.kafka_bootstrap_servers
)
self._run_next_day(manifest)
self.stop() self.stop()
return return
@@ -471,13 +468,24 @@ class GeneratorService:
model_t0=self.config.model_t0, model_t0=self.config.model_t0,
gen_seed=self.config.seed, gen_seed=self.config.seed,
) )
cumulative_state, counter_chunks = (
artifact_builder.counters.to_state_with_chunks()
)
state.cumulative_manifest_counters = cumulative_counter_reference(
cumulative_state
)
manifest = build_manifest( manifest = build_manifest(
config=self.config, config=self.config,
counters=artifact_builder.counters, counters=artifact_builder.counters,
state=state, state=state,
cumulative_state=cumulative_state,
) )
if self.manifest_manager: if self.manifest_manager:
for chunk_sha256, chunk in counter_chunks:
self.manifest_manager.save_counter_chunk(chunk_sha256, chunk)
if counter_chunks:
self.manifest_manager.flush()
self.manifest_manager.save(manifest) self.manifest_manager.save(manifest)
self.manifest_manager.flush() self.manifest_manager.flush()
@@ -519,7 +527,7 @@ class GeneratorService:
f"status={status}, sent_counts={sent_counts}" f"status={status}, sent_counts={sent_counts}"
) )
def _run_next_day(self, manifest: dict) -> None: def _run_next_day(self, manifest: dict, restored_state) -> None:
"""Доливает ровно 24 модельных часа от границы manifest.""" """Доливает ровно 24 модельных часа от границы manifest."""
if not self.publisher: if not self.publisher:
raise RuntimeError("publisher не инициализирован") raise RuntimeError("publisher не инициализирован")
@@ -528,6 +536,41 @@ class GeneratorService:
if not self.state_manager or not self.manifest_manager: if not self.state_manager or not self.manifest_manager:
raise RuntimeError("менеджеры state и manifest не инициализированы") raise RuntimeError("менеджеры state и manifest не инициализированы")
counter_reference = restored_state.cumulative_manifest_counters
counter_state = manifest.get("cumulative_manifest_counters")
if counter_reference is None or counter_state is None:
raise IncompatibleStateError(
"в state нет накопительных счётчиков manifest; "
"повторно запустите import эталонного мира"
)
if cumulative_counter_reference(counter_state) != counter_reference:
raise IncompatibleStateError(
"накопительные счётчики state и manifest не совпадают; "
"повторно запустите import эталонного мира"
)
known_click_ids, known_user_ids = known_counter_ids_from_state(
restored_state
)
try:
counters = ManifestCounters.from_state(
counter_state,
known_click_ids=known_click_ids,
known_user_ids=known_user_ids,
)
except ValueError as exc:
raise IncompatibleStateError(
"накопительные счётчики manifest повреждены; "
"повторно запустите import эталонного мира"
) from exc
if (
counters.to_manifest_topics() != manifest.get("topics")
or counters.to_manifest_totals() != manifest.get("totals")
):
raise IncompatibleStateError(
"накопительные счётчики не совпадают с числами manifest; "
"повторно запустите import эталонного мира"
)
current_t_end = self._as_aware_utc( current_t_end = self._as_aware_utc(
datetime.fromisoformat(manifest["model_t_end"]) datetime.fromisoformat(manifest["model_t_end"])
) )
@@ -554,6 +597,7 @@ class GeneratorService:
) )
total_sent, sent_counts, status = self._publish_batch(batch) total_sent, sent_counts, status = self._publish_batch(batch)
self._raise_on_next_day_publish_error(batch_id, status, sent_counts) self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
counters.add_batch(batch)
self._write_batch_history( self._write_batch_history(
batch_id=batch_id, batch_id=batch_id,
started_at=started_at, started_at=started_at,
@@ -573,6 +617,7 @@ class GeneratorService:
started_at = datetime.now(timezone.utc) started_at = datetime.now(timezone.utc)
total_sent, sent_counts, status = self._publish_batch(final_batch) total_sent, sent_counts, status = self._publish_batch(final_batch)
self._raise_on_next_day_publish_error(batch_id, status, sent_counts) self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
counters.add_batch(final_batch)
self._write_batch_history( self._write_batch_history(
batch_id=batch_id, batch_id=batch_id,
started_at=started_at, started_at=started_at,
@@ -584,11 +629,6 @@ class GeneratorService:
self.publisher.flush() self.publisher.flush()
self._model_time = target_t_end 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_batch_id = self._startup_state_batch_id(counters)
state = self.stream.to_state( state = self.stream.to_state(
tick=self._tick, tick=self._tick,
@@ -602,6 +642,10 @@ class GeneratorService:
model_t0=self.config.model_t0, model_t0=self.config.model_t0,
gen_seed=self.config.seed, gen_seed=self.config.seed,
) )
cumulative_state, counter_chunks = counters.to_state_with_chunks()
state.cumulative_manifest_counters = cumulative_counter_reference(
cumulative_state
)
boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()] boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()]
updated_manifest = build_manifest( updated_manifest = build_manifest(
self.config, self.config,
@@ -609,12 +653,17 @@ class GeneratorService:
state, state,
model_t_end=target_t_end, model_t_end=target_t_end,
boundaries=boundaries, boundaries=boundaries,
cumulative_state=cumulative_state,
) )
self.state_manager.save(state) for chunk_sha256, chunk in counter_chunks:
self.state_manager.flush() self.manifest_manager.save_counter_chunk(chunk_sha256, chunk)
if counter_chunks:
self.manifest_manager.flush()
self.manifest_manager.save(updated_manifest) self.manifest_manager.save(updated_manifest)
self.manifest_manager.flush() self.manifest_manager.flush()
self.state_manager.save(state)
self.state_manager.flush()
def _raise_on_next_day_publish_error( def _raise_on_next_day_publish_error(
self, self,
@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import hashlib import hashlib
import json import json
import lzma import lzma
import logging import logging
import zlib
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -28,6 +30,11 @@ logger = logging.getLogger("generator")
ARTIFACT_KIND = "clickstream_generator_startup_history" ARTIFACT_KIND = "clickstream_generator_startup_history"
ARTIFACT_VERSION = "1.0" ARTIFACT_VERSION = "1.0"
TOPICS = ("browser_events", "location_events", "device_events", "geo_events") TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
COUNTER_STATE_VERSION = "2.0"
CHECKSUM_MODULUS = 1 << 256
ID_SET_CHUNK_VERSION = "1.0"
ID_SET_CHUNK_SIZE = 10_000
ID_SET_STORAGE = "manifest-topic-sha256-chain-v1"
IMPORT_TOPICS = ( IMPORT_TOPICS = (
"browser_events", "browser_events",
"location_events", "location_events",
@@ -35,6 +42,7 @@ IMPORT_TOPICS = (
"geo_events", "geo_events",
KafkaStateManager.STATE_TOPIC, KafkaStateManager.STATE_TOPIC,
KafkaStartupHistoryManifest.MANIFEST_TOPIC, KafkaStartupHistoryManifest.MANIFEST_TOPIC,
KafkaStartupHistoryManifest.COUNTER_TOPIC,
) )
@@ -45,17 +53,20 @@ class _TopicManifestStats:
self.rows = 0 self.rows = 0
self.min_event_timestamp: str | None = None self.min_event_timestamp: str | None = None
self.max_event_timestamp: str | None = None self.max_event_timestamp: str | None = None
self._checksum = hashlib.sha256() self._checksum_sum = 0
@property @property
def checksum(self) -> str: def checksum(self) -> str:
return self._checksum.hexdigest() return f"{self._checksum_sum:064x}"
def add(self, event: dict, event_timestamp: str | None = None) -> None: def add(self, event: dict, event_timestamp: str | None = None) -> None:
self.rows += 1 self.rows += 1
self._checksum.update( event_digest = hashlib.sha256(
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8") json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
) ).digest()
self._checksum_sum = (
self._checksum_sum + int.from_bytes(event_digest, "big")
) % CHECKSUM_MODULUS
timestamp = ( timestamp = (
event_timestamp event_timestamp
if event_timestamp is not None if event_timestamp is not None
@@ -79,6 +90,55 @@ class _TopicManifestStats:
"checksum_sha256": self.checksum, "checksum_sha256": self.checksum,
} }
@classmethod
def from_state(cls, payload: dict) -> "_TopicManifestStats":
"""Восстанавливает накопительные числа одного топика."""
if not isinstance(payload, dict):
raise ValueError("счётчики топика должны быть объектом")
rows = payload.get("rows")
checksum = payload.get("checksum_sha256")
if isinstance(rows, bool) or not isinstance(rows, int) or rows < 0:
raise ValueError("rows в накопительных счётчиках должен быть целым")
if not isinstance(checksum, str) or len(checksum) != 64:
raise ValueError("checksum_sha256 в накопительных счётчиках неверен")
try:
checksum_sum = int(checksum, 16)
except ValueError as exc:
raise ValueError(
"checksum_sha256 в накопительных счётчиках неверен"
) from exc
stats = cls()
stats.rows = rows
stats.min_event_timestamp = payload.get("min_event_timestamp")
stats.max_event_timestamp = payload.get("max_event_timestamp")
stats._checksum_sum = checksum_sum
return stats
class _LegacyTopicManifestStats(_TopicManifestStats):
"""Прежняя контрольная сумма для проверки неизменённого артефакта."""
def __init__(self):
super().__init__()
self._legacy_checksum = hashlib.sha256()
@property
def checksum(self) -> str:
return self._legacy_checksum.hexdigest()
def add(self, event: dict, event_timestamp: str | None = None) -> None:
self.rows += 1
self._legacy_checksum.update(
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
)
timestamp = (
event_timestamp
if event_timestamp is not None
else event.get("event_timestamp")
)
self.add_timestamp(timestamp)
class ManifestCounters: class ManifestCounters:
"""Счётчики стартовой истории для manifest.""" """Счётчики стартовой истории для manifest."""
@@ -87,6 +147,11 @@ class ManifestCounters:
self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS} self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS}
self.click_ids: set[str] = set() self.click_ids: set[str] = set()
self.user_ids: set[str] = set() self.user_ids: set[str] = set()
self._new_click_ids: set[str] = set()
self._new_user_ids: set[str] = set()
self._visit_count = 0
self._user_count = 0
self._previous_id_set_chain = _empty_id_set_chain()
def add_batch(self, batch: dict[str, list[dict]]) -> None: def add_batch(self, batch: dict[str, list[dict]]) -> None:
browser_events = batch.get("browser_events", []) browser_events = batch.get("browser_events", [])
@@ -117,11 +182,23 @@ class ManifestCounters:
stats.add_timestamp(max(timestamps)) stats.add_timestamp(max(timestamps))
stats.add(event, event_timestamp=event_timestamp) stats.add(event, event_timestamp=event_timestamp)
click_id = event.get("click_id") click_id = event.get("click_id")
if topic == "browser_events" and click_id: if (
topic == "browser_events"
and click_id
and click_id not in self.click_ids
):
self.click_ids.add(click_id) self.click_ids.add(click_id)
self._new_click_ids.add(click_id)
self._visit_count += 1
user_id = event.get("user_domain_id") user_id = event.get("user_domain_id")
if topic == "device_events" and user_id: if (
topic == "device_events"
and user_id
and user_id not in self.user_ids
):
self.user_ids.add(user_id) self.user_ids.add(user_id)
self._new_user_ids.add(user_id)
self._user_count += 1
def to_manifest_topics(self) -> dict: def to_manifest_topics(self) -> dict:
return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()} return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()}
@@ -130,12 +207,214 @@ class ManifestCounters:
browser_stats = self.topic_stats["browser_events"] browser_stats = self.topic_stats["browser_events"]
return { return {
"events": browser_stats.rows, "events": browser_stats.rows,
"visits": len(self.click_ids), "visits": self._visit_count,
"users": len(self.user_ids), "users": self._user_count,
"min_event_timestamp": browser_stats.min_event_timestamp, "min_event_timestamp": browser_stats.min_event_timestamp,
"max_event_timestamp": browser_stats.max_event_timestamp, "max_event_timestamp": browser_stats.max_event_timestamp,
} }
def to_state(self) -> dict:
"""Возвращает JSON-сериализуемое накопительное состояние."""
state, _chunks = self.to_state_with_chunks()
return state
def to_state_with_chunks(self) -> tuple[dict, list[tuple[str, dict]]]:
"""Возвращает компактное состояние и новые фрагменты точных множеств."""
id_set_chain, chunks = _extend_id_set_chain(
self._previous_id_set_chain,
self._new_click_ids,
self._new_user_ids,
)
state = {
"version": COUNTER_STATE_VERSION,
"checksum_algorithm": "sha256-sum-v1",
"topics": self.to_manifest_topics(),
"totals": {
"visits": self._visit_count,
"users": self._user_count,
},
"id_sets": id_set_chain,
}
return state, chunks
@classmethod
def from_state(
cls,
payload: dict,
*,
known_click_ids: set[str] | None = None,
known_user_ids: set[str] | None = None,
) -> "ManifestCounters":
"""Продолжает счётчики из state без чтения старых событий."""
if not isinstance(payload, dict):
raise ValueError("накопительные счётчики должны быть объектом")
if payload.get("version") != COUNTER_STATE_VERSION:
raise ValueError("версия накопительных счётчиков не поддерживается")
if payload.get("checksum_algorithm") != "sha256-sum-v1":
raise ValueError("алгоритм накопительной контрольной суммы не поддерживается")
topic_payloads = payload.get("topics")
if not isinstance(topic_payloads, dict) or set(topic_payloads) != set(TOPICS):
raise ValueError("в накопительных счётчиках нет всех топиков")
totals = payload.get("totals")
if not isinstance(totals, dict):
raise ValueError("в накопительных счётчиках нет итогов")
visits = _non_negative_int(totals.get("visits"), "visits")
users = _non_negative_int(totals.get("users"), "users")
id_sets = payload.get("id_sets")
_validate_id_set_chain(id_sets, visits=visits, users=users)
counters = cls()
counters.topic_stats = {
topic: _TopicManifestStats.from_state(topic_payloads[topic])
for topic in TOPICS
}
counters.click_ids = set(known_click_ids or ())
counters.user_ids = set(known_user_ids or ())
counters._new_click_ids = set()
counters._new_user_ids = set()
counters._visit_count = visits
counters._user_count = users
counters._previous_id_set_chain = dict(id_sets)
return counters
def cumulative_counter_reference(counter_state: dict) -> dict:
"""Строит компактную ссылку state на полный набор из manifest."""
canonical = json.dumps(
counter_state,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
return {
"version": COUNTER_STATE_VERSION,
"manifest_sha256": hashlib.sha256(canonical).hexdigest(),
}
def known_counter_ids_from_state(state: GeneratorState) -> tuple[set[str], set[str]]:
"""Возвращает идентификаторы, которые уже успели попасть в data-топики."""
click_ids = {visit["click_id"] for visit in state.active_visits}
user_ids = {
user["user_domain_id"]
for user in state.population
if user.get("active_click_id") is not None
or user.get("last_finished_at") is not None
}
return click_ids, user_ids
def _empty_id_set_chain() -> dict:
return {
"storage": ID_SET_STORAGE,
"latest_sha256": None,
"chunks": 0,
"click_ids": 0,
"user_ids": 0,
}
def _non_negative_int(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{label} в накопительных счётчиках должен быть целым")
return value
def _validate_id_set_chain(payload: Any, *, visits: int, users: int) -> None:
if not isinstance(payload, dict):
raise ValueError("в накопительных счётчиках нет множеств идентификаторов")
if payload.get("storage") != ID_SET_STORAGE:
raise ValueError("хранилище множеств идентификаторов не поддерживается")
chunks = _non_negative_int(payload.get("chunks"), "chunks")
click_ids = _non_negative_int(payload.get("click_ids"), "click_ids")
user_ids = _non_negative_int(payload.get("user_ids"), "user_ids")
latest = payload.get("latest_sha256")
if latest is not None:
if not isinstance(latest, str) or len(latest) != 64:
raise ValueError("ссылка на фрагмент множеств идентификаторов неверна")
try:
int(latest, 16)
except ValueError as exc:
raise ValueError(
"ссылка на фрагмент множеств идентификаторов неверна"
) from exc
if (chunks == 0) != (latest is None):
raise ValueError("цепочка множеств идентификаторов повреждена")
if click_ids != visits or user_ids != users:
raise ValueError("размеры множеств идентификаторов не совпадают с итогами")
def _extend_id_set_chain(
previous: dict,
click_ids: set[str],
user_ids: set[str],
) -> tuple[dict, list[tuple[str, dict]]]:
_validate_id_set_chain(
previous,
visits=_non_negative_int(previous.get("click_ids"), "click_ids"),
users=_non_negative_int(previous.get("user_ids"), "user_ids"),
)
tagged_ids = [("click_id", value) for value in sorted(click_ids)]
tagged_ids.extend(("user_id", value) for value in sorted(user_ids))
previous_sha256 = previous["latest_sha256"]
chunk_count = previous["chunks"]
chunks: list[tuple[str, dict]] = []
for offset in range(0, len(tagged_ids), ID_SET_CHUNK_SIZE):
part = tagged_ids[offset : offset + ID_SET_CHUNK_SIZE]
part_click_ids = {
value for kind, value in part if kind == "click_id"
}
part_user_ids = {
value for kind, value in part if kind == "user_id"
}
chunk = {
"version": ID_SET_CHUNK_VERSION,
"encoding": "json-zlib-base64-v1",
"sequence": chunk_count + 1,
"previous_sha256": previous_sha256,
"click_ids": _encode_id_set(part_click_ids),
"user_ids": _encode_id_set(part_user_ids),
}
digest = hashlib.sha256(_canonical_json(chunk)).hexdigest()
chunks.append((digest, chunk))
previous_sha256 = digest
chunk_count += 1
return (
{
"storage": ID_SET_STORAGE,
"latest_sha256": previous_sha256,
"chunks": chunk_count,
"click_ids": previous["click_ids"] + len(click_ids),
"user_ids": previous["user_ids"] + len(user_ids),
},
chunks,
)
def _canonical_json(value: dict) -> bytes:
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
def _encode_id_set(values: set[str]) -> str:
raw = json.dumps(sorted(values), separators=(",", ":")).encode("utf-8")
return base64.b64encode(zlib.compress(raw, level=9)).decode("ascii")
class _LegacyManifestCounters(ManifestCounters):
"""Полный пересчёт старой суммы только при проверке артефакта."""
def __init__(self):
super().__init__()
self.topic_stats = {
topic: _LegacyTopicManifestStats() for topic in TOPICS
}
class StartupHistoryArtifactBuilder: class StartupHistoryArtifactBuilder:
"""Собирает сообщения топиков для портативного файла.""" """Собирает сообщения топиков для портативного файла."""
@@ -158,12 +437,16 @@ class StartupHistoryArtifactBuilder:
) )
def to_artifact(self, manifest: dict, state: GeneratorState) -> dict: def to_artifact(self, manifest: dict, state: GeneratorState) -> dict:
artifact_manifest = deepcopy(manifest)
artifact_manifest.pop("cumulative_manifest_counters", None)
artifact_state = state.to_dict()
artifact_state.pop("cumulative_manifest_counters", None)
return { return {
"artifact_version": ARTIFACT_VERSION, "artifact_version": ARTIFACT_VERSION,
"kind": ARTIFACT_KIND, "kind": ARTIFACT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
"manifest": deepcopy(manifest), "manifest": artifact_manifest,
"state": state.to_dict(), "state": artifact_state,
"topics": deepcopy(self.topics), "topics": deepcopy(self.topics),
"raw_topics": deepcopy(self.raw_topics), "raw_topics": deepcopy(self.raw_topics),
} }
@@ -295,6 +578,7 @@ def build_manifest(
*, *,
model_t_end: datetime | None = None, model_t_end: datetime | None = None,
boundaries: list[str] | None = None, boundaries: list[str] | None = None,
cumulative_state: dict | None = None,
) -> dict: ) -> dict:
"""Строит manifest стартовой истории.""" """Строит manifest стартовой истории."""
selected_t_end = model_t_end or config.model_t_end selected_t_end = model_t_end or config.model_t_end
@@ -325,6 +609,7 @@ def build_manifest(
}, },
"topics": counters.to_manifest_topics(), "topics": counters.to_manifest_topics(),
"totals": counters.to_manifest_totals(), "totals": counters.to_manifest_totals(),
"cumulative_manifest_counters": cumulative_state or counters.to_state(),
} }
manifest_boundaries(manifest) manifest_boundaries(manifest)
return manifest return manifest
@@ -437,6 +722,16 @@ def import_startup_history_artifact(
artifact, artifact,
expected_config=expected_config, expected_config=expected_config,
) )
counters = ManifestCounters()
counters.add_batch(topics)
cumulative_state, counter_chunks = counters.to_state_with_chunks()
state.cumulative_manifest_counters = cumulative_counter_reference(
cumulative_state
)
manifest = deepcopy(manifest)
manifest["topics"] = counters.to_manifest_topics()
manifest["totals"] = counters.to_manifest_totals()
manifest["cumulative_manifest_counters"] = cumulative_state
if topic_inspector is not None: if topic_inspector is not None:
topic_inspector.assert_data_topics_empty() topic_inspector.assert_data_topics_empty()
snapshot = ( snapshot = (
@@ -471,6 +766,10 @@ def import_startup_history_artifact(
sent_by_topic[topic] = sent sent_by_topic[topic] = sent
publisher.flush() publisher.flush()
for chunk_sha256, chunk in counter_chunks:
manifest_manager.save_counter_chunk(chunk_sha256, chunk)
if counter_chunks:
manifest_manager.flush()
manifest_manager.save(manifest) manifest_manager.save(manifest)
manifest_manager.flush() manifest_manager.flush()
state_manager.save(state) state_manager.save(state)
@@ -548,10 +847,15 @@ def _validate_manifest_topics(manifest: dict, topics: dict[str, list[dict]]) ->
counters.add_batch({topic: topics[topic] for topic in TOPICS}) counters.add_batch({topic: topics[topic] for topic in TOPICS})
expected_topics = manifest.get("topics") or {} expected_topics = manifest.get("topics") or {}
expected_totals = manifest.get("totals") or {} expected_totals = manifest.get("totals") or {}
if counters.to_manifest_topics() != expected_topics:
raise ValueError("startup history artifact mismatch: topic checksums")
if counters.to_manifest_totals() != expected_totals: if counters.to_manifest_totals() != expected_totals:
raise ValueError("startup history artifact mismatch: totals") raise ValueError("startup history artifact mismatch: totals")
if counters.to_manifest_topics() == expected_topics:
return
legacy_counters = _LegacyManifestCounters()
legacy_counters.add_batch({topic: topics[topic] for topic in TOPICS})
if legacy_counters.to_manifest_topics() != expected_topics:
raise ValueError("startup history artifact mismatch: topic checksums")
def _validate_raw_topics( def _validate_raw_topics(
+10 -1
View File
@@ -204,6 +204,7 @@ class GeneratorState:
population: list[dict] = field(default_factory=list) population: list[dict] = field(default_factory=list)
active_visits: list[dict] = field(default_factory=list) active_visits: list[dict] = field(default_factory=list)
pending_visit_births: float = 0.0 pending_visit_births: float = 0.0
cumulative_manifest_counters: dict | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.model_timestamp is None: if self.model_timestamp is None:
@@ -221,7 +222,7 @@ class GeneratorState:
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Конвертирует в словарь для JSON-сериализации.""" """Конвертирует в словарь для JSON-сериализации."""
return { payload = {
"tick": self.tick, "tick": self.tick,
"rng_state": self.rng_state, "rng_state": self.rng_state,
"last_batch_id": self.last_batch_id, "last_batch_id": self.last_batch_id,
@@ -237,6 +238,11 @@ class GeneratorState:
"active_visits": self.active_visits, "active_visits": self.active_visits,
"pending_visit_births": self.pending_visit_births, "pending_visit_births": self.pending_visit_births,
} }
if self.cumulative_manifest_counters is not None:
payload["cumulative_manifest_counters"] = (
self.cumulative_manifest_counters
)
return payload
@classmethod @classmethod
def from_dict(cls, data: dict) -> "GeneratorState": def from_dict(cls, data: dict) -> "GeneratorState":
@@ -290,6 +296,9 @@ class GeneratorState:
population=data.get("population", []), population=data.get("population", []),
active_visits=data.get("active_visits", []), active_visits=data.get("active_visits", []),
pending_visit_births=data.get("pending_visit_births", 0.0), pending_visit_births=data.get("pending_visit_births", 0.0),
cumulative_manifest_counters=data.get(
"cumulative_manifest_counters"
),
) )
except UnsupportedStateVersionError: except UnsupportedStateVersionError:
raise raise
+5
View File
@@ -149,6 +149,11 @@ def test_next_day_precheck_requires_manifest_and_matching_state():
with pytest.raises(RuntimeError, match="state.*T_end"): with pytest.raises(RuntimeError, match="state.*T_end"):
assert_next_day_snapshot(manifest, state) assert_next_day_snapshot(manifest, state)
manifest["model_t_end"] = state.model_timestamp.isoformat()
manifest["state"]["model_timestamp"] = state.model_timestamp.isoformat()
with pytest.raises(RuntimeError, match="повторно запустите import"):
assert_next_day_snapshot(manifest, state)
def test_target_dag_trigger_error_covers_missing_paused_and_ready_states(): def test_target_dag_trigger_error_covers_missing_paused_and_ready_states():
"""Проверка зависимого DAG различает три реальные ветки.""" """Проверка зависимого DAG различает три реальные ветки."""
+97 -14
View File
@@ -25,6 +25,7 @@ from generator import (
main, main,
) )
from clickstream_generator.state import UnsupportedStateVersionError from clickstream_generator.state import UnsupportedStateVersionError
from clickstream_generator.service import IncompatibleStateError
class TestBatchRecordWithDictConversion: class TestBatchRecordWithDictConversion:
@@ -425,6 +426,10 @@ class TestGeneratorServiceBackfill:
self, base_config self, base_config
): ):
"""Backfill пишет [T0, T_end), state на T_end и повторяемый manifest.""" """Backfill пишет [T0, T_end), state на T_end и повторяемый manifest."""
from clickstream_generator.startup_history_artifact import (
cumulative_counter_reference,
)
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=3) model_t_end = model_t0 + timedelta(minutes=3)
config = replace( config = replace(
@@ -463,6 +468,11 @@ class TestGeneratorServiceBackfill:
assert manifest["model_t0"] == model_t0.isoformat() assert manifest["model_t0"] == model_t0.isoformat()
assert manifest["model_t_end"] == model_t_end.isoformat() assert manifest["model_t_end"] == model_t_end.isoformat()
assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id
assert saved_state.cumulative_manifest_counters == (
cumulative_counter_reference(
manifest["cumulative_manifest_counters"]
)
)
assert manifest["topics"]["browser_events"]["rows"] == len(browser_events) assert manifest["topics"]["browser_events"]["rows"] == len(browser_events)
for topic in ( for topic in (
"browser_events", "browser_events",
@@ -516,6 +526,8 @@ class TestGeneratorServiceBackfill:
self._run_backfill(config) self._run_backfill(config)
artifact = load_startup_history_artifact(artifact_path) artifact = load_startup_history_artifact(artifact_path)
assert "cumulative_manifest_counters" not in artifact["state"]
assert "cumulative_manifest_counters" not in artifact["manifest"]
state, manifest, topics, _ = validate_startup_history_artifact( state, manifest, topics, _ = validate_startup_history_artifact(
artifact, artifact,
expected_config=config, expected_config=config,
@@ -960,7 +972,8 @@ class TestGeneratorServiceBackfill:
with pytest.raises(RuntimeError, match="flush down"): with pytest.raises(RuntimeError, match="flush down"):
service._run_backfill() service._run_backfill()
service.manifest_manager.save.assert_called_once() service.manifest_manager.save_counter_chunk.assert_called()
service.manifest_manager.save.assert_not_called()
service.manifest_manager.flush.assert_called_once() service.manifest_manager.flush.assert_called_once()
service.state_manager.save.assert_not_called() service.state_manager.save.assert_not_called()
service.state_manager.flush.assert_not_called() service.state_manager.flush.assert_not_called()
@@ -1008,13 +1021,14 @@ class TestGeneratorServiceBackfill:
class TestGeneratorServiceNextDay: class TestGeneratorServiceNextDay:
"""Проверки ограниченной доливки следующего модельного дня.""" """Проверки ограниченной доливки следующего модельного дня."""
def test_next_day_publishes_24h_then_state_then_cumulative_manifest( def test_next_day_publishes_24h_then_chunks_manifest_and_state(
self, base_config self, base_config
): ):
"""Next-day пишет [T_end, T_end+24h), затем state и manifest.""" """Next-day пишет день, фрагменты множеств, manifest и затем state."""
from clickstream_generator.startup_history_artifact import ( from clickstream_generator.startup_history_artifact import (
StartupHistoryArtifactBuilder, StartupHistoryArtifactBuilder,
build_manifest, build_manifest,
cumulative_counter_reference,
) )
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
@@ -1062,6 +1076,9 @@ class TestGeneratorServiceNextDay:
} }
initial_builder = StartupHistoryArtifactBuilder() initial_builder = StartupHistoryArtifactBuilder()
initial_builder.add_batch(old_batch) initial_builder.add_batch(old_batch)
state.cumulative_manifest_counters = cumulative_counter_reference(
initial_builder.counters.to_state()
)
manifest = build_manifest(config, initial_builder.counters, state) manifest = build_manifest(config, initial_builder.counters, state)
published = {topic: [] for topic in old_batch} published = {topic: [] for topic in old_batch}
@@ -1078,21 +1095,21 @@ class TestGeneratorServiceNextDay:
service.state_manager = MagicMock() service.state_manager = MagicMock()
service.state_manager.save.side_effect = lambda _state: order.append("state") service.state_manager.save.side_effect = lambda _state: order.append("state")
service.manifest_manager = MagicMock() service.manifest_manager = MagicMock()
service.manifest_manager.save_counter_chunk.side_effect = (
lambda _chunk_sha256, _chunk: order.append("counter_chunk")
)
service.manifest_manager.save.side_effect = ( service.manifest_manager.save.side_effect = (
lambda _manifest: order.append("manifest") lambda _manifest: order.append("manifest")
) )
class DataReader: class DataReader:
def load(self): def load(self):
return { raise AssertionError("next-day не должен перечитывать Kafka")
topic: old_batch[topic] + published[topic]
for topic in old_batch
}
service.data_reader = DataReader() service.data_reader = DataReader()
service.restore_from_startup_history(state, model_t_end=current_t_end) service.restore_from_startup_history(state, model_t_end=current_t_end)
service._run_next_day(manifest) service._run_next_day(manifest, state)
browser_events = published["browser_events"] browser_events = published["browser_events"]
timestamps = [ timestamps = [
@@ -1114,18 +1131,21 @@ class TestGeneratorServiceNextDay:
target_t_end.isoformat(), target_t_end.isoformat(),
] ]
assert saved_manifest["totals"]["events"] == len(browser_events) + 1 assert saved_manifest["totals"]["events"] == len(browser_events) + 1
assert order == ["data", "state", "manifest"] assert order == ["data", "counter_chunk", "manifest", "state"]
first_day_events = len(browser_events) first_day_events = len(browser_events)
first_day_checksum = saved_manifest["topics"]["browser_events"][ first_day_checksum = saved_manifest["topics"]["browser_events"][
"checksum_sha256" "checksum_sha256"
] ]
service._run_next_day(saved_manifest) service._run_next_day(saved_manifest, saved_state)
second_state = service.state_manager.save.call_args.args[0] second_state = service.state_manager.save.call_args.args[0]
second_manifest = service.manifest_manager.save.call_args.args[0] second_manifest = service.manifest_manager.save.call_args.args[0]
expected_builder = StartupHistoryArtifactBuilder() expected_builder = StartupHistoryArtifactBuilder()
expected_builder.add_batch(DataReader().load()) expected_builder.add_batch({
topic: old_batch[topic] + published[topic]
for topic in old_batch
})
assert second_state.model_timestamp == target_t_end + timedelta(hours=24) assert second_state.model_timestamp == target_t_end + timedelta(hours=24)
assert second_manifest["boundaries"] == [ assert second_manifest["boundaries"] == [
model_t0.isoformat(), model_t0.isoformat(),
@@ -1140,15 +1160,57 @@ class TestGeneratorServiceNextDay:
!= first_day_checksum != first_day_checksum
) )
assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics() assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics()
assert second_manifest["totals"] == expected_builder.counters.to_manifest_totals()
assert second_state.cumulative_manifest_counters == (
cumulative_counter_reference(
second_manifest["cumulative_manifest_counters"]
)
)
assert order == [ assert order == [
"data", "data",
"state", "counter_chunk",
"manifest", "manifest",
"state",
"data", "data",
"state", "counter_chunk",
"manifest", "manifest",
"state",
] ]
def test_next_day_requires_cumulative_counters_from_import(self, base_config):
"""Старый локальный state требует повторного импорта, а не чтения Kafka."""
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),
)
service = GeneratorService(config)
state = service.stream.to_state(
tick=1,
rng_state=service.generator.rng.getstate(),
last_batch_id="startup-history-old",
last_timestamp=config.model_t_end,
model_timestamp=config.model_t_end,
model_t0=config.model_t0,
)
service.publisher = MagicMock()
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(IncompatibleStateError, match="повторно запустите import"):
service._run_next_day(manifest, state)
service.publisher.publish.assert_not_called()
def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config): def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config):
"""Ошибка data-топика оставляет обе точки фиксации без изменений.""" """Ошибка data-топика оставляет обе точки фиксации без изменений."""
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
@@ -1177,8 +1239,29 @@ class TestGeneratorServiceNextDay:
"boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()], "boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()],
} }
from clickstream_generator.startup_history_artifact import (
ManifestCounters,
cumulative_counter_reference,
)
state = service.stream.to_state(
tick=1,
rng_state=service.generator.rng.getstate(),
last_batch_id="startup-history-test",
last_timestamp=config.model_t_end,
model_timestamp=config.model_t_end,
model_t0=config.model_t0,
)
empty_counters = ManifestCounters()
manifest["cumulative_manifest_counters"] = empty_counters.to_state()
manifest["topics"] = empty_counters.to_manifest_topics()
manifest["totals"] = empty_counters.to_manifest_totals()
state.cumulative_manifest_counters = cumulative_counter_reference(
manifest["cumulative_manifest_counters"]
)
with pytest.raises(RuntimeError, match="Публикация next-day не удалась"): with pytest.raises(RuntimeError, match="Публикация next-day не удалась"):
service._run_next_day(manifest) service._run_next_day(manifest, state)
service.state_manager.save.assert_not_called() service.state_manager.save.assert_not_called()
service.manifest_manager.save.assert_not_called() service.manifest_manager.save.assert_not_called()
@@ -4,6 +4,7 @@
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone from datetime import datetime, timezone
import hashlib
import json import json
import lzma import lzma
@@ -110,6 +111,144 @@ def _batch() -> dict[str, list[dict]]:
} }
def test_incremental_counters_equal_full_recompute():
"""Накопление по частям даёт те же числа, что полный пересчёт мира."""
from clickstream_generator.startup_history_artifact import ManifestCounters
first_day = _batch()
second_day = {
"browser_events": [
{
"event_id": "event-2",
"click_id": "click-1",
"user_domain_id": "user-1",
"event_timestamp": "2026-01-02 00:00:00.000000",
},
{
"event_id": "event-3",
"click_id": "click-2",
"user_domain_id": "user-2",
"event_timestamp": "2026-01-02 00:01:00.000000",
},
],
"location_events": [
{"event_id": "event-2", "page_url_path": "/cart"},
{"event_id": "event-3", "page_url_path": "/home"},
],
"device_events": [
{"click_id": "click-1", "user_domain_id": "user-1"},
{"click_id": "click-2", "user_domain_id": "user-2"},
],
"geo_events": [
{"click_id": "click-1", "country": "RU"},
{"click_id": "click-2", "country": "KZ"},
],
}
incremental = ManifestCounters()
incremental.add_batch(first_day)
incremental = ManifestCounters.from_state(
incremental.to_state(),
known_click_ids={"click-1"},
known_user_ids={"user-1"},
)
incremental.add_batch(second_day)
full = ManifestCounters()
full.add_batch({
topic: first_day[topic] + second_day[topic]
for topic in first_day
})
assert incremental.to_manifest_topics() == full.to_manifest_topics()
assert incremental.to_manifest_totals() == full.to_manifest_totals()
def test_exact_id_sets_are_split_into_bounded_hash_chain(monkeypatch):
"""Manifest ссылается на цепочку малых точных фрагментов, а не растёт сам."""
from clickstream_generator import startup_history_artifact as module
monkeypatch.setattr(module, "ID_SET_CHUNK_SIZE", 2)
counters = module.ManifestCounters()
batch = _batch()
batch["browser_events"].extend([
{
"event_id": "event-2",
"click_id": "click-2",
"event_timestamp": "2026-01-01 00:01:00.000000",
},
{
"event_id": "event-3",
"click_id": "click-3",
"event_timestamp": "2026-01-01 00:02:00.000000",
},
])
counters.add_batch(batch)
state, chunks = counters.to_state_with_chunks()
assert len(chunks) == 2
assert state["totals"] == {"visits": 3, "users": 1}
assert state["id_sets"] == {
"storage": "manifest-topic-sha256-chain-v1",
"latest_sha256": chunks[-1][0],
"chunks": 2,
"click_ids": 3,
"user_ids": 1,
}
assert chunks[0][1]["previous_sha256"] is None
assert chunks[1][1]["previous_sha256"] == chunks[0][0]
assert "click-1" not in json.dumps(state)
restored = module.ManifestCounters.from_state(
state,
known_click_ids={"click-3"},
known_user_ids={"user-1"},
)
restored.add_batch({
"browser_events": [
{
"event_id": "event-4",
"click_id": "click-3",
"event_timestamp": "2026-01-02 00:00:00.000000",
},
{
"event_id": "event-5",
"click_id": "click-4",
"event_timestamp": "2026-01-02 00:01:00.000000",
},
],
"location_events": [],
"device_events": [{"click_id": "click-4", "user_domain_id": "user-2"}],
"geo_events": [],
})
next_state, next_chunks = restored.to_state_with_chunks()
assert next_state["totals"] == {"visits": 4, "users": 2}
assert len(next_chunks) == 1
assert next_chunks[0][1]["previous_sha256"] == chunks[-1][0]
assert next_state["id_sets"]["chunks"] == 3
def test_counter_chunk_has_explicit_kafka_size_and_hash_guards():
"""Большой или неверно адресованный фрагмент падает до отправки в Kafka."""
from clickstream_generator.kafka_io import (
MAX_COMPACT_MESSAGE_BYTES,
KafkaStartupHistoryManifest,
)
manager = object.__new__(KafkaStartupHistoryManifest)
with pytest.raises(ValueError, match="безопасный предел"):
manager._assert_message_size(
{"payload": "x" * MAX_COMPACT_MESSAGE_BYTES},
"фрагмент",
)
with pytest.raises(ValueError, match="хеш.*не совпадает"):
manager.save_counter_chunk("0" * 64, {"version": "1.0"})
assert manager.COUNTER_TOPIC != manager.MANIFEST_TOPIC
def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config): def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config):
"""Артефакт хранит события, state и manifest одним проверяемым набором.""" """Артефакт хранит события, state и manifest одним проверяемым набором."""
from clickstream_generator.startup_history_artifact import ( from clickstream_generator.startup_history_artifact import (
@@ -173,6 +312,43 @@ def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
def test_legacy_artifact_checksum_remains_valid(base_config):
"""Эталонный артефакт со старой суммой не требует пересборки."""
from clickstream_generator.startup_history_artifact import (
StartupHistoryArtifactBuilder,
build_manifest,
validate_startup_history_artifact,
)
state = _state()
config = replace(
base_config,
model_t0=state.model_t0,
model_t_end=state.model_timestamp,
model_time_speed=1,
model_timezone="UTC",
seed=42,
)
builder = StartupHistoryArtifactBuilder()
builder.add_batch(_batch())
manifest = build_manifest(config, builder.counters, state)
for topic, events in builder.topics.items():
checksum = hashlib.sha256()
for event in events:
checksum.update(
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
)
manifest["topics"][topic]["checksum_sha256"] = checksum.hexdigest()
artifact = builder.to_artifact(manifest=manifest, state=state)
_, loaded_manifest, _, _ = validate_startup_history_artifact(
artifact,
expected_config=config,
)
assert loaded_manifest["topics"] == artifact["manifest"]["topics"]
def test_manifest_and_artifact_show_launch_profile(base_config): def test_manifest_and_artifact_show_launch_profile(base_config):
"""Manifest и файл артефакта показывают выбранный профиль запуска.""" """Manifest и файл артефакта показывают выбранный профиль запуска."""
from clickstream_generator.startup_history_artifact import ( from clickstream_generator.startup_history_artifact import (
@@ -372,6 +548,8 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config):
manifest=build_manifest(config=config, counters=builder.counters, state=state), manifest=build_manifest(config=config, counters=builder.counters, state=state),
state=state, state=state,
) )
assert "cumulative_manifest_counters" not in artifact["state"]
assert "cumulative_manifest_counters" not in artifact["manifest"]
raw_value = ( raw_value = (
'{"event_timestamp":"2026-01-01 00:00:00.000000",' '{"event_timestamp":"2026-01-01 00:00:00.000000",'
'"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}' '"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}'
@@ -391,9 +569,15 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config):
return None return None
class CompactWriter: class CompactWriter:
def __init__(self):
self.chunks = []
def save(self, value): def save(self, value):
self.saved = value self.saved = value
def save_counter_chunk(self, chunk_sha256, chunk):
self.chunks.append((chunk_sha256, chunk))
def flush(self): def flush(self):
return None return None
@@ -609,8 +793,10 @@ def test_import_rolls_back_kafka_topics_after_partial_publish(base_config):
def test_import_replays_events_and_compact_topics(base_config): def test_import_replays_events_and_compact_topics(base_config):
"""Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse.""" """Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse."""
from clickstream_generator.startup_history_artifact import ( from clickstream_generator.startup_history_artifact import (
ManifestCounters,
StartupHistoryArtifactBuilder, StartupHistoryArtifactBuilder,
build_manifest, build_manifest,
cumulative_counter_reference,
import_startup_history_artifact, import_startup_history_artifact,
) )
@@ -646,10 +832,14 @@ def test_import_replays_events_and_compact_topics(base_config):
def __init__(self): def __init__(self):
self.saved = None self.saved = None
self.flushed = False self.flushed = False
self.chunks = []
def save(self, value): def save(self, value):
self.saved = value self.saved = value
def save_counter_chunk(self, chunk_sha256, chunk):
self.chunks.append((chunk_sha256, chunk))
def flush(self): def flush(self):
self.flushed = True self.flushed = True
@@ -678,7 +868,27 @@ def test_import_replays_events_and_compact_topics(base_config):
) )
assert result["events"] == 4 assert result["events"] == 4
assert state_manager.saved.last_batch_id == state.last_batch_id assert state_manager.saved.last_batch_id == state.last_batch_id
assert state_manager.saved.cumulative_manifest_counters is not None
assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id
assert state_manager.saved.cumulative_manifest_counters == (
cumulative_counter_reference(
manifest_manager.saved["cumulative_manifest_counters"]
)
)
seeded = ManifestCounters.from_state(
manifest_manager.saved["cumulative_manifest_counters"],
known_click_ids={"click-1"},
known_user_ids={"user-1"},
)
assert seeded.click_ids == {"click-1"}
assert seeded.user_ids == {"user-1"}
assert len(manifest_manager.chunks) == 1
assert (
manifest_manager.saved["cumulative_manifest_counters"]["id_sets"][
"latest_sha256"
]
== manifest_manager.chunks[0][0]
)
assert publisher.flushed assert publisher.flushed
assert state_manager.flushed assert state_manager.flushed
assert manifest_manager.flushed assert manifest_manager.flushed
+19
View File
@@ -227,6 +227,25 @@ class TestGeneratorState:
assert next_values == values_after assert next_values == values_after
def test_state_roundtrip_keeps_cumulative_manifest_counters(self):
"""State сохраняет накопительные числа для следующего дня."""
counter_reference = {
"version": "1.0",
"manifest_sha256": "a" * 64,
}
state = GeneratorState(
tick=1,
rng_state=_make_valid_rng_state(),
last_batch_id="startup-history-test",
last_timestamp=datetime.now(timezone.utc),
population=_minimal_population(),
cumulative_manifest_counters=counter_reference,
)
restored = GeneratorState.from_dict(json.loads(json.dumps(state.to_dict())))
assert restored.cumulative_manifest_counters == counter_reference
def test_state_roundtrip_keeps_population_and_active_visits(self): def test_state_roundtrip_keeps_population_and_active_visits(self):
"""State хранит популяцию и активные визиты в JSON.""" """State хранит популяцию и активные визиты в JSON."""
rng_state = _make_valid_rng_state(42) rng_state = _make_valid_rng_state(42)