feat(ddl): пояс назван явно — в типах колонок и в разборе строки

- Зачем:
  - конвенция #63 записана, а код её не достиг: колонки времени стояли без
    пояса, и сходилось всё лишь потому, что пояс сервера — UTC.
- Что:
  - UTCEventTime объявлен DateTime('UTC'), служебные метки _load_ts и
    kafka_timestamp — DateTime64(3, 'UTC') в STG и ODS.
  - parseDateTimeOrNull получил третьим аргументом 'UTC': маска сверяет
    суффикс Z как букву, зоны из строки не берёт вовсе.
  - контракт схемы и описание выгрузки несут тип с поясом; имя пояса
    Europe/Samara встало рядом со смещением в world.py, сходимость сверяет
    тест.
  - учебный комментарий о линзе — у первой колонки с явным поясом.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9
  - замер тикета повторён: под session_timezone='Europe/Samara' колонка
    показана 2026-05-31 23:37:00, как и без настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:30:50 +03:00
co-authored by Claude Opus 5
parent e6f300a66e
commit d67e697821
11 changed files with 73 additions and 40 deletions
@@ -107,7 +107,7 @@ COLUMNS: tuple[Column, ...] = (
),
Column(
name="UTCEventTime",
clickhouse_type="DateTime",
clickhouse_type="DateTime('UTC')",
numpy_dtype="datetime64[s]",
normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS,
+9 -5
View File
@@ -16,11 +16,15 @@ from datetime import date
# Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
# у ночных событий (спека генератора, раздел 9).
# Часовой пояс счётчика — Самара, UTC+4 — записан двумя способами, потому что
# стороны просят разное. Генератору нужны минуты: в этом поясе считаются
# модельные сутки, как в выгрузке Метрики — `EventDate` дата в поясе счётчика,
# `UTCEventTime` абсолютная метка. Отсюда следствие, о котором сторона
# хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` у ночных
# событий (спека генератора, раздел 9). Хранилищу нужно имя из базы поясов: его
# просят `toDate` и типы колонок DDS (docs/architecture/storage.md, «Часовые
# пояса»).
COUNTER_TIMEZONE = "Europe/Samara"
COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
+1 -1
View File
@@ -31,7 +31,7 @@ NUMPY_BY_CLICKHOUSE_TYPE = {
"String": "object",
"LowCardinality(String)": "object",
"Date": "datetime64[D]",
"DateTime": "datetime64[s]",
"DateTime('UTC')": "datetime64[s]",
}
METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
+10
View File
@@ -6,6 +6,9 @@
«в среднем 34 возврата» и «средняя кука активна 1,9 дня».
"""
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from clickstream_generator import catalog, world
@@ -27,6 +30,13 @@ def test_origin_is_a_monday():
assert world.ORIGIN.weekday() == 0
def test_the_counter_timezone_name_and_offset_say_the_same_thing():
"""Имя пояса просит хранилище, минуты — генератор; расходиться им нельзя."""
midnight = datetime.combine(world.ORIGIN, time())
named = ZoneInfo(world.COUNTER_TIMEZONE).utcoffset(midnight)
assert named == timedelta(minutes=world.COUNTER_TIMEZONE_MINUTES)
def test_weekly_profile_covers_a_week_and_averages_to_one():
assert len(world.WEEKLY_PROFILE_PERCENT) == 7
assert sum(world.WEEKLY_PROFILE_PERCENT) == 700