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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user