feat(generator): добавлена стартовая история через backfill
- Зачем: - стенду нужна повторяемая история с живым продолжением от модельной границы без дублей и разрыва визитов. - Что: - добавлен backfill-режим с `GEN_MODEL_T_END`, manifest и state на `T_end`. - live-запуск восстанавливается из manifest без настенной дельты и проверяет совместимость state. - добавлены SQL-проверки формы данных, повторяемости и стыка backfill с live. - Проверка: - make generator-test. - два чистых ClickHouse-прогона backfill дали одинаковые manifest checksums и digest. - reviewer gate issue 05 пройден после исправлений state/manifest.
This commit is contained in:
@@ -78,6 +78,11 @@ class Config:
|
||||
os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00")
|
||||
)
|
||||
)
|
||||
model_t_end: datetime | None = field(
|
||||
default_factory=lambda: _parse_optional_model_timestamp(
|
||||
os.getenv("GEN_MODEL_T_END")
|
||||
)
|
||||
)
|
||||
model_timezone: str = field(
|
||||
default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC")
|
||||
)
|
||||
@@ -122,3 +127,21 @@ class Config:
|
||||
raise ValueError("GEN_MODEL_TIME_SPEED must be > 0")
|
||||
if self.run_mode not in {"live", "backfill"}:
|
||||
raise ValueError("GEN_RUN_MODE must be live or backfill")
|
||||
if self.model_t_end is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"model_t_end",
|
||||
self.model_t_end.astimezone(timezone.utc),
|
||||
)
|
||||
if self.run_mode == "backfill":
|
||||
if self.model_t_end is None:
|
||||
raise ValueError("GEN_MODEL_T_END is required for backfill")
|
||||
if self.model_t_end <= self.model_t0:
|
||||
raise ValueError("GEN_MODEL_T_END must be after GEN_MODEL_T0")
|
||||
|
||||
|
||||
def _parse_optional_model_timestamp(value: str | None) -> datetime | None:
|
||||
"""Разбирает необязательную ISO-метку модельного времени."""
|
||||
if not value:
|
||||
return None
|
||||
return _parse_model_timestamp(value)
|
||||
|
||||
@@ -181,6 +181,84 @@ class KafkaStateManager:
|
||||
return None
|
||||
|
||||
|
||||
class KafkaStartupHistoryManifest:
|
||||
"""Хранение манифеста стартовой истории в Kafka compact topic."""
|
||||
|
||||
MANIFEST_TOPIC = "generator_startup_history_manifest"
|
||||
MANIFEST_KEY = "default"
|
||||
|
||||
def __init__(self, bootstrap_servers: str):
|
||||
self.bootstrap_servers = bootstrap_servers
|
||||
KafkaProducerCls, _ = _kafka_importer()()
|
||||
|
||||
logger.info(
|
||||
f"Connecting to Kafka for startup history manifest at {self.bootstrap_servers}"
|
||||
)
|
||||
self.producer = KafkaProducerCls(
|
||||
bootstrap_servers=self.bootstrap_servers,
|
||||
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
||||
retries=3,
|
||||
retry_backoff_ms=1000,
|
||||
)
|
||||
logger.info("Connected to Kafka for startup history manifest successfully")
|
||||
|
||||
def save(self, manifest: dict) -> None:
|
||||
"""Сохраняет манифест стартовой истории."""
|
||||
def _do_send():
|
||||
self.producer.send(
|
||||
self.MANIFEST_TOPIC,
|
||||
key=self.MANIFEST_KEY,
|
||||
value=manifest,
|
||||
)
|
||||
|
||||
_retry(_do_send, max_retries=3, base_delay=0.5)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Сбрасывает буфер с retry."""
|
||||
def _do_flush():
|
||||
self.producer.flush()
|
||||
|
||||
_retry(_do_flush, max_retries=3, base_delay=0.5)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрывает соединение."""
|
||||
try:
|
||||
self.producer.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing manifest producer (ignored): {e}")
|
||||
|
||||
def load(self) -> dict | None:
|
||||
"""Загружает последний манифест стартовой истории."""
|
||||
from kafka import KafkaConsumer
|
||||
|
||||
logger.info(f"Loading startup history manifest from topic {self.MANIFEST_TOPIC}")
|
||||
|
||||
def _do_load():
|
||||
consumer = KafkaConsumer(
|
||||
self.MANIFEST_TOPIC,
|
||||
bootstrap_servers=self.bootstrap_servers,
|
||||
auto_offset_reset="earliest",
|
||||
enable_auto_commit=False,
|
||||
consumer_timeout_ms=5000,
|
||||
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
|
||||
)
|
||||
|
||||
last_manifest = None
|
||||
for message in consumer:
|
||||
if message.key and message.key.decode("utf-8") == self.MANIFEST_KEY:
|
||||
last_manifest = message.value
|
||||
|
||||
consumer.close()
|
||||
return last_manifest
|
||||
|
||||
try:
|
||||
return _retry(_do_load, max_retries=3, base_delay=0.5)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load startup history manifest: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def ensure_topics(bootstrap_servers: str) -> None:
|
||||
"""Создаёт служебные топики, если их ещё нет."""
|
||||
from kafka import KafkaAdminClient
|
||||
@@ -205,8 +283,18 @@ def ensure_topics(bootstrap_servers: str) -> None:
|
||||
"delete.retention.ms": "100",
|
||||
},
|
||||
)
|
||||
manifest_topic = NewTopic(
|
||||
name=KafkaStartupHistoryManifest.MANIFEST_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]:
|
||||
for topic in [history_topic, state_topic, manifest_topic]:
|
||||
try:
|
||||
admin_client.create_topics([topic])
|
||||
logger.info(f"Created topic: {topic.name}")
|
||||
|
||||
@@ -394,6 +394,24 @@ class TickStreamGenerator:
|
||||
|
||||
return tick_batch
|
||||
|
||||
def drain_until(
|
||||
self,
|
||||
cutoff_at: datetime,
|
||||
include_boundary: bool = False,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Выпускает созревшие события без рождения новых визитов."""
|
||||
cutoff_time = _normalize_tick_time(cutoff_at)
|
||||
tick_batch = _empty_batch()
|
||||
|
||||
self._release_due_events(
|
||||
cutoff_time,
|
||||
tick_batch,
|
||||
include_boundary=include_boundary,
|
||||
)
|
||||
self._drop_finished_visits()
|
||||
|
||||
return tick_batch
|
||||
|
||||
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
|
||||
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
||||
self._pending_visit_births = 0.0
|
||||
@@ -436,9 +454,17 @@ class TickStreamGenerator:
|
||||
self,
|
||||
tick_time: datetime,
|
||||
tick_batch: dict[str, list[dict]],
|
||||
include_boundary: bool = True,
|
||||
) -> None:
|
||||
for visit in self.active_visits:
|
||||
while not visit.is_finished and visit.timestamps[visit.next_index] <= tick_time:
|
||||
while not visit.is_finished:
|
||||
next_timestamp = visit.timestamps[visit.next_index]
|
||||
if include_boundary:
|
||||
is_due = next_timestamp <= tick_time
|
||||
else:
|
||||
is_due = next_timestamp < tick_time
|
||||
if not is_due:
|
||||
break
|
||||
event_index = visit.next_index
|
||||
for topic in TOPICS:
|
||||
tick_batch[topic].append(visit.batch[topic][event_index])
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Основной сервисный цикл генератора."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
@@ -16,6 +18,7 @@ from clickstream_generator.kafka_io import (
|
||||
KafkaBatchHistory,
|
||||
KafkaPublisher,
|
||||
KafkaStateManager,
|
||||
KafkaStartupHistoryManifest,
|
||||
ensure_topics,
|
||||
)
|
||||
from clickstream_generator.metrics import (
|
||||
@@ -29,6 +32,105 @@ from clickstream_generator.runtime import TickStreamGenerator
|
||||
logger = logging.getLogger("generator")
|
||||
|
||||
|
||||
class _TopicManifestStats:
|
||||
"""Накопительные счётчики одного Kafka-топика для manifest."""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = 0
|
||||
self.min_event_timestamp: str | None = None
|
||||
self.max_event_timestamp: str | None = None
|
||||
self._checksum = hashlib.sha256()
|
||||
|
||||
@property
|
||||
def checksum(self) -> str:
|
||||
return self._checksum.hexdigest()
|
||||
|
||||
def add(self, event: dict, event_timestamp: str | None = None) -> None:
|
||||
self.rows += 1
|
||||
self._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)
|
||||
|
||||
def add_timestamp(self, timestamp: str | None) -> None:
|
||||
if timestamp is None:
|
||||
return
|
||||
if self.min_event_timestamp is None or timestamp < self.min_event_timestamp:
|
||||
self.min_event_timestamp = timestamp
|
||||
if self.max_event_timestamp is None or timestamp > self.max_event_timestamp:
|
||||
self.max_event_timestamp = timestamp
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"rows": self.rows,
|
||||
"min_event_timestamp": self.min_event_timestamp,
|
||||
"max_event_timestamp": self.max_event_timestamp,
|
||||
"checksum_sha256": self.checksum,
|
||||
}
|
||||
|
||||
|
||||
class _ManifestCounters:
|
||||
"""Счётчики стартовой истории для manifest."""
|
||||
|
||||
def __init__(self):
|
||||
self.topic_stats = {
|
||||
topic: _TopicManifestStats()
|
||||
for topic in ("browser_events", "location_events", "device_events", "geo_events")
|
||||
}
|
||||
self.click_ids: set[str] = set()
|
||||
self.user_ids: set[str] = set()
|
||||
|
||||
def add_batch(self, batch: dict[str, list[dict]]) -> None:
|
||||
browser_events = batch.get("browser_events", [])
|
||||
event_timestamps = {
|
||||
event["event_id"]: event.get("event_timestamp")
|
||||
for event in browser_events
|
||||
if event.get("event_id") and event.get("event_timestamp")
|
||||
}
|
||||
click_timestamps: dict[str, list[str]] = {}
|
||||
for event in browser_events:
|
||||
click_id = event.get("click_id")
|
||||
timestamp = event.get("event_timestamp")
|
||||
if click_id and timestamp:
|
||||
click_timestamps.setdefault(click_id, []).append(timestamp)
|
||||
|
||||
for topic, events in batch.items():
|
||||
stats = self.topic_stats[topic]
|
||||
for event in events:
|
||||
event_timestamp = event.get("event_timestamp")
|
||||
if event_timestamp is None and topic == "location_events":
|
||||
event_timestamp = event_timestamps.get(event.get("event_id"))
|
||||
if event_timestamp is None and topic in ("device_events", "geo_events"):
|
||||
timestamps = click_timestamps.get(event.get("click_id"))
|
||||
if timestamps:
|
||||
event_timestamp = min(timestamps)
|
||||
stats.add_timestamp(max(timestamps))
|
||||
stats.add(event, event_timestamp=event_timestamp)
|
||||
click_id = event.get("click_id")
|
||||
if topic == "browser_events" and click_id:
|
||||
self.click_ids.add(click_id)
|
||||
user_id = event.get("user_domain_id")
|
||||
if topic == "device_events" and user_id:
|
||||
self.user_ids.add(user_id)
|
||||
|
||||
def to_manifest_topics(self) -> dict:
|
||||
return {
|
||||
topic: stats.to_dict()
|
||||
for topic, stats in self.topic_stats.items()
|
||||
}
|
||||
|
||||
def to_manifest_totals(self) -> dict:
|
||||
browser_stats = self.topic_stats["browser_events"]
|
||||
return {
|
||||
"events": browser_stats.rows,
|
||||
"visits": len(self.click_ids),
|
||||
"users": len(self.user_ids),
|
||||
"min_event_timestamp": browser_stats.min_event_timestamp,
|
||||
"max_event_timestamp": browser_stats.max_event_timestamp,
|
||||
}
|
||||
|
||||
|
||||
class GeneratorService:
|
||||
"""Основной сервис генератора."""
|
||||
|
||||
@@ -40,6 +142,7 @@ class GeneratorService:
|
||||
self.publisher: KafkaPublisher | None = None
|
||||
self.history: KafkaBatchHistory | None = None
|
||||
self.state_manager: KafkaStateManager | None = None
|
||||
self.manifest_manager: KafkaStartupHistoryManifest | None = None
|
||||
self._running = False
|
||||
self._tick = 0
|
||||
self._model_time = config.model_t0
|
||||
@@ -66,17 +169,46 @@ class GeneratorService:
|
||||
self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers)
|
||||
self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers)
|
||||
|
||||
if self.config.run_mode == "backfill" and not self.config.state_enabled:
|
||||
raise ValueError("GEN_STATE_ENABLED must be true for backfill")
|
||||
|
||||
if self.config.state_enabled:
|
||||
self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers)
|
||||
|
||||
if self.config.run_mode == "backfill":
|
||||
self.manifest_manager = KafkaStartupHistoryManifest(
|
||||
self.config.kafka_bootstrap_servers
|
||||
)
|
||||
logger.info("Backfill mode starts from a fresh generator state")
|
||||
self._run_backfill()
|
||||
self.stop()
|
||||
return
|
||||
|
||||
if not self.config.state_reset:
|
||||
restored_state = self.state_manager.load()
|
||||
if restored_state:
|
||||
try:
|
||||
self._restore_live_state(
|
||||
restored_state,
|
||||
wall_now_utc=datetime.now(timezone.utc),
|
||||
self.manifest_manager = KafkaStartupHistoryManifest(
|
||||
self.config.kafka_bootstrap_servers
|
||||
)
|
||||
manifest = self.manifest_manager.load()
|
||||
if self._is_startup_history_state(restored_state, manifest):
|
||||
model_t_end = self._as_aware_utc(
|
||||
datetime.fromisoformat(manifest["model_t_end"])
|
||||
)
|
||||
self.restore_from_startup_history(
|
||||
restored_state,
|
||||
model_t_end=model_t_end,
|
||||
)
|
||||
elif self._is_startup_history_marker(restored_state):
|
||||
raise ValueError(
|
||||
"startup-history state without matching manifest"
|
||||
)
|
||||
else:
|
||||
self._restore_live_state(
|
||||
restored_state,
|
||||
wall_now_utc=datetime.now(timezone.utc),
|
||||
)
|
||||
logger.info(
|
||||
f"Restored state: continuing from tick {self._tick}, "
|
||||
f"model_time={self._model_time.isoformat()}, "
|
||||
@@ -113,6 +245,8 @@ class GeneratorService:
|
||||
self.history.close()
|
||||
if self.state_manager:
|
||||
self.state_manager.close()
|
||||
if self.manifest_manager:
|
||||
self.manifest_manager.close()
|
||||
|
||||
def restore_from_startup_history(
|
||||
self,
|
||||
@@ -169,6 +303,54 @@ class GeneratorService:
|
||||
"state config mismatch: " + ", ".join(mismatches)
|
||||
)
|
||||
|
||||
def _is_startup_history_state(self, state, manifest: dict | None) -> bool:
|
||||
"""Проверяет, что state совпадает со слепком стартовой истории."""
|
||||
if not manifest or manifest.get("run_mode") != "backfill":
|
||||
return False
|
||||
|
||||
expected_state = manifest.get("state") or {}
|
||||
if expected_state.get("last_batch_id") != state.last_batch_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
model_t_end = self._as_aware_utc(
|
||||
datetime.fromisoformat(manifest["model_t_end"])
|
||||
)
|
||||
model_t0 = self._as_aware_utc(
|
||||
datetime.fromisoformat(manifest["model_t0"])
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
if self._as_aware_utc(state.model_timestamp) != model_t_end:
|
||||
return False
|
||||
if self._as_aware_utc(state.model_t0) != model_t0:
|
||||
return False
|
||||
if state.gen_seed != manifest.get("gen_seed"):
|
||||
return False
|
||||
if state.model_timezone != manifest.get("model_timezone"):
|
||||
return False
|
||||
if (
|
||||
self.config.model_t_end is not None
|
||||
and self.config.model_t_end != model_t_end
|
||||
):
|
||||
return False
|
||||
if model_t0 != self.config.model_t0:
|
||||
return False
|
||||
if manifest.get("gen_seed") != self.config.seed:
|
||||
return False
|
||||
if manifest.get("model_timezone") != self.config.model_timezone:
|
||||
return False
|
||||
|
||||
settings = manifest.get("generation_settings") or {}
|
||||
if abs(state.model_time_speed - settings.get("model_time_speed", -1)) > 1e-9:
|
||||
return False
|
||||
return self._generation_settings() == settings
|
||||
|
||||
def _is_startup_history_marker(self, state) -> bool:
|
||||
"""Отличает state стартовой истории от обычного live-state."""
|
||||
return str(state.last_batch_id).startswith("startup-history-")
|
||||
|
||||
@staticmethod
|
||||
def _as_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
@@ -204,6 +386,220 @@ class GeneratorService:
|
||||
logger.warning(f"Failed to save state: {e}")
|
||||
METRICS_ERRORS_TOTAL.labels(topic="state").inc()
|
||||
|
||||
def _run_backfill(self) -> None:
|
||||
"""Проматывает стартовую историю без сна до GEN_MODEL_T_END."""
|
||||
if self.config.model_t_end is None:
|
||||
raise ValueError("GEN_MODEL_T_END is required for backfill")
|
||||
if not self.publisher:
|
||||
raise RuntimeError("publisher is not initialized")
|
||||
if not self.history:
|
||||
raise RuntimeError("history is not initialized")
|
||||
|
||||
logger.info(
|
||||
"Running backfill from %s to %s",
|
||||
self.config.model_t0.isoformat(),
|
||||
self.config.model_t_end.isoformat(),
|
||||
)
|
||||
counters = _ManifestCounters()
|
||||
|
||||
while self._model_time < self.config.model_t_end:
|
||||
self._tick += 1
|
||||
batch_id = f"backfill-{self._tick:08d}"
|
||||
model_time = self._model_time
|
||||
started_at = datetime.now(timezone.utc)
|
||||
|
||||
events_count = self.generator._calculate_events_count(now=model_time)
|
||||
batch = self.stream.generate_tick(
|
||||
events_count,
|
||||
tick_started_at=model_time,
|
||||
)
|
||||
total_sent, sent_counts, status = self._publish_batch(batch)
|
||||
self._raise_on_backfill_publish_error(batch_id, status, sent_counts)
|
||||
counters.add_batch(batch)
|
||||
self._write_batch_history(
|
||||
batch_id=batch_id,
|
||||
started_at=started_at,
|
||||
sent_counts=sent_counts,
|
||||
total_sent=total_sent,
|
||||
status=status,
|
||||
error_message=None if status == "success" else "Backfill publish error",
|
||||
)
|
||||
self._advance_model_time()
|
||||
|
||||
final_batch = self.stream.drain_until(
|
||||
self.config.model_t_end,
|
||||
include_boundary=False,
|
||||
)
|
||||
final_sent = 0
|
||||
final_status = "success"
|
||||
if any(final_batch.values()):
|
||||
batch_id = f"backfill-{self._tick + 1:08d}-final"
|
||||
started_at = datetime.now(timezone.utc)
|
||||
final_sent, sent_counts, final_status = self._publish_batch(final_batch)
|
||||
self._raise_on_backfill_publish_error(
|
||||
batch_id,
|
||||
final_status,
|
||||
sent_counts,
|
||||
)
|
||||
counters.add_batch(final_batch)
|
||||
self._write_batch_history(
|
||||
batch_id=batch_id,
|
||||
started_at=started_at,
|
||||
sent_counts=sent_counts,
|
||||
total_sent=final_sent,
|
||||
status=final_status,
|
||||
error_message=None if final_status == "success" else "Backfill publish error",
|
||||
)
|
||||
|
||||
if self.publisher:
|
||||
self.publisher.flush()
|
||||
|
||||
self._model_time = self.config.model_t_end
|
||||
state_batch_id = self._startup_state_batch_id(counters)
|
||||
state = self.stream.to_state(
|
||||
tick=self._tick,
|
||||
rng_state=self.generator.rng.getstate(),
|
||||
last_batch_id=state_batch_id,
|
||||
last_timestamp=self.config.model_t_end,
|
||||
model_timestamp=self.config.model_t_end,
|
||||
wall_timestamp=datetime.now(timezone.utc),
|
||||
model_time_speed=self.config.model_time_speed,
|
||||
model_timezone=self.config.model_timezone,
|
||||
model_t0=self.config.model_t0,
|
||||
gen_seed=self.config.seed,
|
||||
)
|
||||
|
||||
manifest = self._build_startup_history_manifest(
|
||||
counters=counters,
|
||||
state=state,
|
||||
)
|
||||
if self.manifest_manager:
|
||||
self.manifest_manager.save(manifest)
|
||||
self.manifest_manager.flush()
|
||||
|
||||
if self.state_manager and self.config.state_enabled:
|
||||
self.state_manager.save(state)
|
||||
self.state_manager.flush()
|
||||
|
||||
logger.info(
|
||||
"Backfill completed: events=%s, visits=%s, users=%s, final_sent=%s",
|
||||
manifest["totals"]["events"],
|
||||
manifest["totals"]["visits"],
|
||||
manifest["totals"]["users"],
|
||||
final_sent,
|
||||
)
|
||||
|
||||
def _raise_on_backfill_publish_error(
|
||||
self,
|
||||
batch_id: str,
|
||||
status: str,
|
||||
sent_counts: dict[str, dict[str, int]],
|
||||
) -> None:
|
||||
"""Останавливает backfill до записи state/manifest при ошибке Kafka."""
|
||||
if status == "success":
|
||||
return
|
||||
|
||||
raise RuntimeError(
|
||||
f"Backfill publish failed for batch {batch_id}: "
|
||||
f"status={status}, sent_counts={sent_counts}"
|
||||
)
|
||||
|
||||
def _publish_batch(
|
||||
self,
|
||||
batch: dict[str, list[dict]],
|
||||
) -> tuple[int, dict[str, dict[str, int]], str]:
|
||||
"""Публикует batch и возвращает счётчики отправки."""
|
||||
total_sent = 0
|
||||
total_errors = 0
|
||||
sent_counts = {}
|
||||
|
||||
for topic, events in batch.items():
|
||||
if events:
|
||||
sent, errors = self.publisher.publish(topic, events)
|
||||
sent_counts[topic] = {"sent": sent, "errors": errors}
|
||||
total_sent += sent
|
||||
total_errors += errors
|
||||
|
||||
if total_errors == 0:
|
||||
status = "success"
|
||||
elif total_sent > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "error"
|
||||
|
||||
return total_sent, sent_counts, status
|
||||
|
||||
def _write_batch_history(
|
||||
self,
|
||||
batch_id: str,
|
||||
started_at: datetime,
|
||||
sent_counts: dict[str, dict[str, int]],
|
||||
total_sent: int,
|
||||
status: str,
|
||||
error_message: str | None,
|
||||
) -> None:
|
||||
"""Пишет служебную историю batch."""
|
||||
if not self.history:
|
||||
return
|
||||
try:
|
||||
record = BatchRecord(
|
||||
batch_id=batch_id,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
sent_total=total_sent,
|
||||
sent_browser=sent_counts.get("browser_events", {}).get("sent", 0),
|
||||
sent_location=sent_counts.get("location_events", {}).get("sent", 0),
|
||||
sent_device=sent_counts.get("device_events", {}).get("sent", 0),
|
||||
sent_geo=sent_counts.get("geo_events", {}).get("sent", 0),
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
)
|
||||
self.history.add(record)
|
||||
self.history.flush()
|
||||
except Exception as hist_err:
|
||||
logger.warning(f"Failed to write batch history: {hist_err}")
|
||||
METRICS_ERRORS_TOTAL.labels(topic="history").inc()
|
||||
|
||||
def _startup_state_batch_id(self, counters) -> str:
|
||||
digest = counters.topic_stats["browser_events"].checksum[:12]
|
||||
return f"startup-history-{digest}"
|
||||
|
||||
def _build_startup_history_manifest(self, counters, state) -> dict:
|
||||
return {
|
||||
"manifest_version": "1.0",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"gen_seed": self.config.seed,
|
||||
"model_t0": self.config.model_t0.isoformat(),
|
||||
"model_t_end": self.config.model_t_end.isoformat(),
|
||||
"model_timezone": self.config.model_timezone,
|
||||
"run_mode": "backfill",
|
||||
"generation_settings": self._generation_settings(),
|
||||
"state_version": state.version,
|
||||
"state": {
|
||||
"topic": KafkaStateManager.STATE_TOPIC,
|
||||
"key": KafkaStateManager.STATE_KEY,
|
||||
"last_batch_id": state.last_batch_id,
|
||||
"model_timestamp": state.model_timestamp.isoformat(),
|
||||
},
|
||||
"topics": counters.to_manifest_topics(),
|
||||
"totals": counters.to_manifest_totals(),
|
||||
}
|
||||
|
||||
def _generation_settings(self) -> dict:
|
||||
return {
|
||||
"tick_seconds": self.config.tick_seconds,
|
||||
"lambda_base_per_min": self.config.lambda_base_per_min,
|
||||
"jitter_pct": self.config.jitter_pct,
|
||||
"min_events_per_tick": self.config.min_events_per_tick,
|
||||
"max_events_per_tick": self.config.max_events_per_tick,
|
||||
"max_session_events": self.config.max_session_events,
|
||||
"max_active_sessions": self.config.max_active_sessions,
|
||||
"population_max": self.config.population_max,
|
||||
"p_new_user": self.config.p_new_user,
|
||||
"min_return_minutes": self.config.min_return_minutes,
|
||||
"model_time_speed": self.config.model_time_speed,
|
||||
}
|
||||
|
||||
def _main_loop(self):
|
||||
"""Основной цикл тиков."""
|
||||
while self._running:
|
||||
|
||||
Reference in New Issue
Block a user