feat(generator): добавлена команда слепков заказов
- Зачем: - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня. - Что: - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами. - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней. - опись дополнена хешами слепков и поведенческими тестами. - Проверка: - в generator выполнены make lint, make typecheck и make test: 427 тестов.
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"""Слепок заказов: окно, граница суток, запись на проводе и байты прогона.
|
||||
|
||||
Слепок — второй артефакт мира, и сторожится он тем же, чем день событий:
|
||||
формой записи и побайтовым повтором. Проверяются пять обещаний
|
||||
(docs/architecture/orders/snapshot.md):
|
||||
|
||||
1. **Окно.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и у начала оси
|
||||
усекается сам.
|
||||
2. **Граница суток.** Состояние — чтение готовой судьбы на границе D|D+1:
|
||||
моменты позже границы не учитываются, поэтому заказ «дышит» — `created` в
|
||||
одном слепке, `paid` в следующем.
|
||||
3. **Запись на проводе.** Одиннадцать ключей в порядке контракта, деньги
|
||||
строками с двумя знаками, времена RFC 3339 с миллисекундами.
|
||||
4. **Сдвиг отправки.** Прогон дня D отправляет слепок дня D−1; прогон дня 0 —
|
||||
ничего.
|
||||
5. **Повтор.** Пересъёмка слепка даёт те же байты.
|
||||
|
||||
День рождения заказа берётся из его номера, а не из `created_at`: номер
|
||||
считается в поясе счётчика, а `created_at` — абсолютная метка, и у ночной
|
||||
покупки их даты расходятся.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import closing
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from clickstream_generator import catalog, cli, commerce, player, serialize, world
|
||||
from clickstream_generator import day as day_module
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
from clickstream_generator.sinks import FileSink
|
||||
|
||||
# Первый слепок с полным окном и слепок у начала оси, где окно усечено.
|
||||
FULL_WINDOW_DAY = world.ORDER_WINDOW_DAYS - 1
|
||||
SHORT_WINDOW_DAY = 3
|
||||
|
||||
# Порядок ключей записи — порядок полей контракта (мастер-спека, раздел 2).
|
||||
CONTRACT = (
|
||||
"order_id",
|
||||
"user_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"items_total",
|
||||
"discount",
|
||||
"delivery",
|
||||
"total",
|
||||
"items",
|
||||
"snapshot_date",
|
||||
)
|
||||
ITEM = ("sku", "qty", "price")
|
||||
STATUSES = {"created", "paid", "cancelled"}
|
||||
MONEY = re.compile(r"\d+\.\d{2}")
|
||||
MOMENT = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def days() -> list[day_module.Day]:
|
||||
"""Дни окна: слепок дня 6 собирается переигровкой дней 0…6."""
|
||||
return [
|
||||
day_module.stream(CANONICAL_SEED, number)
|
||||
for number in range(world.ORDER_WINDOW_DAYS)
|
||||
]
|
||||
|
||||
|
||||
def snapshot_records(days: list[day_module.Day], number: int) -> list[dict]:
|
||||
"""Слепок дня `number` разобранным обратно из канонических байтов."""
|
||||
window = [
|
||||
today.orders for today in days[max(0, number - FULL_WINDOW_DAY) : number + 1]
|
||||
]
|
||||
return [json.loads(payload) for payload in serialize.orders(window, number)]
|
||||
|
||||
|
||||
def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
|
||||
"""Запись слепка — ровно то, что примет строгий приём хранилища.
|
||||
|
||||
Набор и порядок ключей, деньги строками с двумя знаками, времена RFC 3339
|
||||
в UTC с миллисекундами, `items` обычным массивом — всё это контракт
|
||||
провода, а не вкус: разбор в ODS сверяет набор ключей и форму значений, и
|
||||
строка мимо формы уходит в брак целиком (спецификация приёма заказов).
|
||||
|
||||
Деньги сверяются с позициями и между собой: строка на проводе обязана
|
||||
отвечать тому же миру, что и заказ в памяти. Цена позиции — цена каталога,
|
||||
а `created_at` — время той самой покупки, что уехала в трекер: строка
|
||||
заказа создаётся синхронно с ней.
|
||||
"""
|
||||
goods = catalog.catalog()
|
||||
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
|
||||
bought = _purchase_times(days[SHORT_WINDOW_DAY])
|
||||
records = snapshot_records(days, SHORT_WINDOW_DAY)
|
||||
assert records
|
||||
|
||||
for record in records:
|
||||
assert list(record) == list(CONTRACT)
|
||||
assert isinstance(record["user_id"], int)
|
||||
assert record["status"] in STATUSES
|
||||
assert MOMENT.fullmatch(record["created_at"])
|
||||
assert MOMENT.fullmatch(record["updated_at"])
|
||||
assert record["snapshot_date"] == _date(SHORT_WINDOW_DAY).isoformat()
|
||||
|
||||
assert isinstance(record["items"], list)
|
||||
assert record["items"]
|
||||
lines = Decimal(0)
|
||||
for item in record["items"]:
|
||||
assert list(item) == list(ITEM)
|
||||
assert MONEY.fullmatch(item["price"])
|
||||
assert isinstance(item["qty"], int)
|
||||
assert item["qty"] > 0
|
||||
assert Decimal(item["price"]) * 100 == price[item["sku"]]
|
||||
lines += Decimal(item["price"]) * item["qty"]
|
||||
|
||||
money = {name: record[name] for name in CONTRACT[5:9]}
|
||||
for value in money.values():
|
||||
assert MONEY.fullmatch(value)
|
||||
assert lines == Decimal(money["items_total"])
|
||||
assert Decimal(money["total"]) == (
|
||||
Decimal(money["items_total"])
|
||||
- Decimal(money["discount"])
|
||||
+ Decimal(money["delivery"])
|
||||
)
|
||||
|
||||
if _born(record) == _date(SHORT_WINDOW_DAY):
|
||||
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
|
||||
|
||||
|
||||
def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
|
||||
"""Миллисекунды — часы базы источника, а не три дописанных нуля.
|
||||
|
||||
Три знака дробной части контракт требует всегда, и `.000` формально им
|
||||
отвечают — но тогда миллисекундная точность была бы обещанием без модели за
|
||||
ним, а разбор в `DateTime64(3)` показывал бы менти ровную секундную сетку
|
||||
там, где у источника её нет (исследование формата слепка).
|
||||
|
||||
Фаза у обоих моментов одна: часы базы ставят её строке при создании, а
|
||||
судьба заказа отмеряется от неё целыми секундами.
|
||||
"""
|
||||
records = snapshot_records(days, SHORT_WINDOW_DAY)
|
||||
assert records
|
||||
|
||||
fractions = {record["created_at"][20:23] for record in records}
|
||||
assert fractions - {"000"}
|
||||
|
||||
for record in records:
|
||||
assert record["updated_at"][20:23] == record["created_at"][20:23]
|
||||
|
||||
|
||||
def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
|
||||
"""Порядок строк слепка — порядок рождения заказов, он же рост номера.
|
||||
|
||||
Хешу слепка в описи нужен именно названный порядок: детерминизм даёт его
|
||||
даром, но обещание побайтового повтора держится на нём, а не на удаче.
|
||||
"""
|
||||
numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
|
||||
assert numbers
|
||||
assert numbers == sorted(numbers)
|
||||
assert len(set(numbers)) == len(numbers)
|
||||
|
||||
|
||||
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
|
||||
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
|
||||
|
||||
Это и есть состояние на границе суток: учитываются моменты не позже
|
||||
границы, поэтому оплата следующего дня в сегодняшний слепок не попадает.
|
||||
`updated_at` — поздний учтённый момент, а без единого — `created_at`:
|
||||
заказ, с которым на границе ещё ничего не случилось, показывает своё
|
||||
рождение.
|
||||
|
||||
Граница модельных суток — полночь пояса счётчика: по нему считается
|
||||
модельный день, а `created_at` и `updated_at` уезжают абсолютной меткой.
|
||||
"""
|
||||
before = {
|
||||
record["order_id"]: record
|
||||
for record in snapshot_records(days, SHORT_WINDOW_DAY)
|
||||
}
|
||||
after = {
|
||||
record["order_id"]: record
|
||||
for record in snapshot_records(days, SHORT_WINDOW_DAY + 1)
|
||||
}
|
||||
|
||||
breathed = [
|
||||
number
|
||||
for number, record in before.items()
|
||||
if record["status"] == "created" and after[number]["status"] == "paid"
|
||||
]
|
||||
assert breathed
|
||||
|
||||
for number in breathed:
|
||||
assert before[number]["updated_at"] == before[number]["created_at"]
|
||||
assert after[number]["created_at"] == before[number]["created_at"]
|
||||
assert after[number]["updated_at"] > after[number]["created_at"]
|
||||
|
||||
edge = _boundary(SHORT_WINDOW_DAY)
|
||||
for record in before.values():
|
||||
assert record["updated_at"] <= edge
|
||||
|
||||
|
||||
def test_the_run_sends_the_snapshot_of_the_day_before(
|
||||
days: list[day_module.Day], tmp_path
|
||||
):
|
||||
"""Прогон дня D отправляет слепок дня D−1, и окно усекается у начала оси.
|
||||
|
||||
Сдвиг «ночная выгрузка за вчера» живёт в проигрывателе, поэтому и
|
||||
спрашивается с него: прогоны дней 4…7 обязаны отправить слепки дней 3…6 —
|
||||
теми же байтами, какие даёт сериализатор, по строке на заказ.
|
||||
"""
|
||||
path = tmp_path / "snapshots.jsonl"
|
||||
with closing(FileSink(path)) as sink:
|
||||
player.play_snapshots(
|
||||
sink,
|
||||
seed=CANONICAL_SEED,
|
||||
first_day=SHORT_WINDOW_DAY + 1,
|
||||
days=FULL_WINDOW_DAY - SHORT_WINDOW_DAY + 1,
|
||||
)
|
||||
|
||||
assert path.read_bytes() == b"".join(
|
||||
payload + b"\n"
|
||||
for number in range(SHORT_WINDOW_DAY, FULL_WINDOW_DAY + 1)
|
||||
for payload in serialize.orders(
|
||||
[today.orders for today in days[: number + 1]], number
|
||||
)
|
||||
)
|
||||
|
||||
sent = [json.loads(line) for line in path.read_bytes().splitlines()]
|
||||
for number in (SHORT_WINDOW_DAY, FULL_WINDOW_DAY):
|
||||
window = {
|
||||
_born(record)
|
||||
for record in sent
|
||||
if record["snapshot_date"] == _date(number).isoformat()
|
||||
}
|
||||
assert window == {
|
||||
_date(born) for born in range(max(0, number - FULL_WINDOW_DAY), number + 1)
|
||||
}
|
||||
|
||||
|
||||
def test_the_range_plays_every_day_of_its_windows_once(monkeypatch, tmp_path):
|
||||
"""Диапазон одним прогоном переигрывает каждый день окна один раз.
|
||||
|
||||
Окна соседних слепков перекрываются почти целиком, и без переиспользования
|
||||
стартовый диапазон стоил бы полусотни проигрышей вместо восьми. День здесь
|
||||
настоящий: считается не содержимое, а то, сколько раз его спросили.
|
||||
"""
|
||||
asked: list[int] = []
|
||||
honest = day_module.stream
|
||||
|
||||
def spy(seed: int, number: int) -> day_module.Day:
|
||||
asked.append(number)
|
||||
return honest(seed, number)
|
||||
|
||||
monkeypatch.setattr(player.day_module, "stream", spy)
|
||||
with closing(FileSink(tmp_path / "range.jsonl")) as sink:
|
||||
player.play_snapshots(sink, seed=CANONICAL_SEED, first_day=1, days=3)
|
||||
|
||||
assert sorted(asked) == [0, 1, 2]
|
||||
|
||||
|
||||
def test_the_first_run_sends_nothing(tmp_path):
|
||||
"""На старте оси дня −1 нет: прогону дня 0 отправлять нечего.
|
||||
|
||||
Не ошибка и не пустой слепок, а отсутствие выгрузки: первый слепок — дня 0
|
||||
— уезжает прогоном дня 1.
|
||||
"""
|
||||
path = tmp_path / "day-zero.jsonl"
|
||||
assert cli.main(["snapshot", "--day", "0", "--file", str(path)]) == 0
|
||||
assert path.read_bytes() == b""
|
||||
|
||||
|
||||
def test_two_runs_give_the_same_snapshot(tmp_path):
|
||||
"""Пересъёмка слепка даёт те же байты — тем и лечится пропущенный день.
|
||||
|
||||
Прогоны идут разными процессами по тому же доводу, что и у дня событий:
|
||||
внутри одного интерпретатора общее зерно хеширования спрятало бы
|
||||
зависимость канона от порядка обхода множества.
|
||||
"""
|
||||
first, second = tmp_path / "first.jsonl", tmp_path / "second.jsonl"
|
||||
_run_apart(first)
|
||||
_run_apart(second)
|
||||
|
||||
assert first.read_bytes()
|
||||
assert _digest(first) == _digest(second)
|
||||
|
||||
|
||||
def _run_apart(path) -> None:
|
||||
"""Снять слепок отдельным процессом; окружение он берёт от нас."""
|
||||
finished = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"clickstream_generator",
|
||||
"snapshot",
|
||||
"--day",
|
||||
"2",
|
||||
"--file",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
assert finished.returncode == 0, finished.stderr
|
||||
|
||||
|
||||
def _purchase_times(today: day_module.Day) -> dict[str, str]:
|
||||
"""Когда покупка уехала в трекер — по номеру заказа, абсолютной меткой."""
|
||||
here = today.columns["EventType"] == commerce.PURCHASE
|
||||
numbers = [cell[0] for cell in today.columns["purchaseID"][here]]
|
||||
times = np.datetime_as_string(
|
||||
today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
|
||||
)
|
||||
return dict(zip(numbers, times.tolist(), strict=True))
|
||||
|
||||
|
||||
def _born(record: dict) -> date:
|
||||
"""День рождения заказа: его номер начинается датой дня покупки."""
|
||||
return datetime.strptime(record["order_id"].split("-")[0], "%Y%m%d").date()
|
||||
|
||||
|
||||
def _date(number: int) -> date:
|
||||
return world.ORIGIN + timedelta(days=number)
|
||||
|
||||
|
||||
def _boundary(number: int) -> str:
|
||||
"""Граница суток `number`|`number+1` — полночь пояса счётчика в UTC."""
|
||||
midnight = datetime.combine(_date(number + 1), time.min, UTC)
|
||||
edge = midnight - timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES)
|
||||
return edge.strftime("%Y-%m-%dT%H:%M:%S.") + f"{edge.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
def _digest(path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
Reference in New Issue
Block a user