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
@@ -1,5 +1,6 @@
"""Kafka-интеграция генератора."""
import hashlib
import json
import logging
import sys
@@ -14,6 +15,7 @@ from clickstream_generator.state import UnsupportedStateVersionError
logger = logging.getLogger("generator")
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
MAX_COMPACT_MESSAGE_BYTES = 900_000
_kafka_imported = False
KafkaProducer = None
@@ -207,7 +209,9 @@ class KafkaStartupHistoryManifest:
"""Хранение манифеста стартовой истории в Kafka compact topic."""
MANIFEST_TOPIC = "generator_startup_history_manifest"
COUNTER_TOPIC = "generator_startup_history_counter_chunks"
MANIFEST_KEY = "default"
COUNTER_CHUNK_KEY_PREFIX = "id-set:"
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
@@ -227,6 +231,8 @@ class KafkaStartupHistoryManifest:
def save(self, manifest: dict) -> None:
"""Сохраняет манифест стартовой истории."""
self._assert_message_size(manifest, "manifest")
def _do_send():
self.producer.send(
self.MANIFEST_TOPIC,
@@ -236,6 +242,40 @@ class KafkaStartupHistoryManifest:
_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:
"""Сбрасывает буфер с retry."""
def _do_flush():
@@ -372,8 +412,23 @@ def ensure_topics(bootstrap_servers: str) -> None:
"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:
admin_client.create_topics([topic])
logger.info(f"Created topic: {topic.name}")