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:
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 различает три реальные ветки."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user