feat(stand): make up наполняет стенд стартовым миром, опись сторожит его
Зачем Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому же состоянию, а в git лежит то, чем это состояние проверяется. Что - Опись мира `data/world-inventory.json`: паспорт (зерно, версия генератора, хеш каталога) и по строке на каждый из восьми дней — дата, число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит `test_inventory.py` — тем же способом, что свежесть описания выгрузки. - Разовая служба `world-init` вышла из-под профиля и играет в топик восемь дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait` считает успешно отработавшую службу упавшей. - `scripts/wait-for-world.sh` — вторая половина `make up`: приём асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения заливки. Ограниченный цикл опроса, не пауза наугад. - Девятая проверка `make check-clickhouse`: подневный счёт событий против описи, рамка по датам стартового мира, счёт через `FINAL`. При расхождении называет, где искать, — в событиях или в браке. - Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал 1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с датой. Раздел 9 спеки закрыт: открытых вопросов не осталось. - Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром (решение владельца). Оба заведены в словарь CONTEXT.md. Проверка `make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий. `make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с), `make test` — 407 тестов за 71 с, `make lint`, `make typecheck`, `make config-test` зелёные. Что проверка умеет краснеть, снято двумя поломками: снос партиции 2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье — «сломан разбор». Строки опыта убраны, день переигран, счёт вернулся. Тест свежести проверен молчаливой правкой цены в каталоге: покраснел. Ссылка: #42
This commit is contained in:
+5
-1
@@ -28,7 +28,7 @@ D0 живёт предыстория, поэтому любой день соб
|
||||
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
||||
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||
дня станут другими, счётчики канонического мира разойдутся с манифестом, и
|
||||
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||
`plan.cohort` бросаются последними.
|
||||
|
||||
@@ -78,6 +78,10 @@ D0 живёт предыстория, поэтому любой день соб
|
||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||
из контракта. Документ руками не правят — пересобирают.
|
||||
- `src/clickstream_generator/inventory.py` — сборка описи мира
|
||||
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
|
||||
и хеши восьми дней, которыми наполняется стенд. Руками не правят —
|
||||
пересобирают целью `make inventory`.
|
||||
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
||||
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
||||
и сборка визитов по задокументированным правилам. Там же побайтовое
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Зовущих трое, и все трое видны в форме команд:
|
||||
|
||||
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
||||
- заливка зернового мира (#42) — восемь дней подряд одним запуском: `--days`;
|
||||
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
|
||||
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
|
||||
|
||||
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||
|
||||
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
||||
он сам, а опись: паспорт мира, число событий по дням и хеш байтов каждого дня.
|
||||
Сам мир пересчитывается когда угодно, а опись отвечает на единственный вопрос —
|
||||
**тот ли это мир, что был вчера**. Разошлись хеши — мир уехал, и дальше уже
|
||||
неважно, чего от него ждали проверки.
|
||||
|
||||
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением
|
||||
этой же описи, а не вторым файлом.
|
||||
|
||||
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
||||
сдвинется, — версия останется прежней, а хеши покраснеют; наоборот не бывает.
|
||||
|
||||
Хеш каталога стоит здесь по третьему основанию — ни сторожить, ни описывать, а
|
||||
**объяснять**. Правка цены в `data/catalog/products.csv` меняет мир так же
|
||||
молча, как правка кода, и по одним хешам эти два случая неразличимы. С хешем
|
||||
каталога различимы: разошлись хеши дней и каталога — правили CSV; разошлись
|
||||
только дни — правили код.
|
||||
|
||||
Хеш дня — sha256 тех самых байтов, что уезжают в Kafka, с переводом строки
|
||||
после каждого события. Это ровно то, что пишет файловый приёмник, поэтому
|
||||
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
|
||||
именно — в README репозитория).
|
||||
|
||||
Собирается опись из корня репозитория целью `make inventory`, а свежесть её
|
||||
сторожит тест — как и у «описания выгрузки».
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from clickstream_generator import day as day_module
|
||||
from clickstream_generator import serialize, world
|
||||
from clickstream_generator.catalog import CATALOG_PATH
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
# Сколько дней оси заливается в стенд при подъёме. То же число стоит у службы
|
||||
# `world-init` в compose.yaml: YAML не читает Python, и одно из двух мест —
|
||||
# лишнее по построению. Расхождение поймают счётчики make check-clickhouse.
|
||||
STARTING_DAYS = 8
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
"""Опись целиком: паспорт мира и по строке на каждый его день."""
|
||||
return {
|
||||
"seed": CANONICAL_SEED,
|
||||
"generator_version": version("clickstream-generator"),
|
||||
"catalog_sha256": _digest(CATALOG_PATH.read_bytes()),
|
||||
"days": [_day(number) for number in range(STARTING_DAYS)],
|
||||
}
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
|
||||
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def _day(number: int) -> dict[str, Any]:
|
||||
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
|
||||
|
||||
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
||||
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
|
||||
стенда обрамляют счёт в `ods.event`. Соври она — подневная сверка это и
|
||||
покажет, каждый день сразу.
|
||||
"""
|
||||
payloads = serialize.events(day_module.stream(CANONICAL_SEED, number))
|
||||
return {
|
||||
"day": number,
|
||||
"date": (world.ORIGIN + timedelta(days=number)).isoformat(),
|
||||
"events": len(payloads),
|
||||
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
|
||||
}
|
||||
|
||||
|
||||
def _digest(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Собирает опись мира: паспорт, счётчики и хеши дней."
|
||||
)
|
||||
parser.add_argument("output", type=Path, help="путь к файлу описи")
|
||||
output = parser.parse_args().output
|
||||
output.write_text(render(), encoding="utf-8")
|
||||
print(f"Опись мира собрана: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,7 @@
|
||||
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
||||
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
||||
перестают что-либо говорить. Сериализация считается частью генерации: она
|
||||
рождает те самые байты, которые сторожит манифест.
|
||||
рождает те самые байты, которые сторожит опись.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -25,8 +25,8 @@ from enum import IntEnum
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
|
||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
|
||||
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
|
||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
|
||||
CANONICAL_SEED = 20260601
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
||||
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
||||
поэтому чек манифеста честно покраснеет: манифест сторожит только канонический
|
||||
мир, свои миры менти собирает без его гарантий (спека генератора, раздел 9).
|
||||
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
|
||||
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
|
||||
|
||||
Числа решены спекой и связаны между собой; связки сторожат тесты
|
||||
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
||||
@@ -26,7 +26,7 @@ COUNTER_TIMEZONE_MINUTES = 240
|
||||
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||
# зависит — иначе манифест перестал бы быть воспроизводимым.
|
||||
# зависит — иначе опись перестала бы быть воспроизводимой.
|
||||
ORIGIN = date(2026, 6, 1)
|
||||
|
||||
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Проверка описи мира: та ли она, что собирается из кода сегодня.
|
||||
|
||||
Опись собирается из кода, значит разойтись они могут только одним способом —
|
||||
код правили, опись не пересобрали. Ровно это здесь и сторожится, тем же
|
||||
способом, что свежесть «описания выгрузки».
|
||||
|
||||
Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это
|
||||
единственный способ сравнить хеши: дешевле мир не пересобрать. Зато краснеет
|
||||
она там, где надо, — сразу после правки генератора, а не через полчаса на
|
||||
поднятом стенде, где расхождение счётчиков выглядит поломкой хранилища.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from clickstream_generator.inventory import build
|
||||
|
||||
INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
|
||||
|
||||
|
||||
def test_inventory_is_up_to_date():
|
||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||
assert stored == build(), (
|
||||
"опись мира отстала от кода — пересоберите: make inventory."
|
||||
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
|
||||
" разошлись только дни — правили генератор"
|
||||
)
|
||||
@@ -98,7 +98,7 @@ def _run_apart(path) -> None:
|
||||
def test_days_play_in_a_row(tmp_path, monkeypatch):
|
||||
"""Дни идут подряд от названного, а пачка считается на весь прогон.
|
||||
|
||||
Восемь дней одним запуском — то, чем зальётся зерновой мир (#42), поэтому
|
||||
Восемь дней одним запуском — то, чем заливается стартовый мир, поэтому
|
||||
порядок дней проверяется, а не предполагается. День здесь подменён коротким:
|
||||
проверяется ход проигрывателя, а не содержимое дня, и платить за полсотни
|
||||
тысяч событий трижды незачем.
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
|
||||
|
||||
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
||||
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
||||
канона: от него зависят байты, а значит и хеши манифеста.
|
||||
канона: от него зависят байты, а значит и хеши описи.
|
||||
"""
|
||||
names = [column.name for column in schema.COLUMNS]
|
||||
for event in events:
|
||||
|
||||
Reference in New Issue
Block a user