fix(ods): метка времени разбирается по названному формату, а не best-effort

Зачем: parseDateTimeBestEffort на непонятной строке не краснеет, а достраивает
недостающее — обрезанное «20:00:21» становится первым января текущего года.
Такое сообщение проходило строгий приём с тихо неверным временем, то есть с
той самой порчей, ради которой класс key_field_unparsed и заведён.

Что:
- В обеих матвью разбор метки идёт parseDateTimeOrNull по формату
  '%Y-%m-%dT%H:%i:%SZ'. Форма на проводе одна и каноническая, поэтому широта
  best-effort не нужна вовсе, а платится за неё отключённой проверкой.
- Замеры в ADR 0005: три записи, которые best-effort достраивает; проверка,
  что настройка cast_string_to_date_time_mode не спасает JSONExtract; сверка
  на настоящих данных — по всем 101 252 строкам сырья модельного дня точный
  формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
- Записано наблюдение стенда: пересозданная на живом чтеце матвью пропускает
  ближайшее сообщение мимо ODS, через минуту то же сообщение разбирается.
  Воспроизведено дважды; на нём я сам споткнулся при проверке этой правки.

Проверка: опыт строгого приёма прогнан заново — три сообщения дали событие и
два key_field_unparsed, включая обрезанную метку, которая раньше проходила
годной. DDL применяется на живом кластере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:48:37 +03:00
co-authored by Claude Opus 5
parent 68f789ba91
commit a534f3cc94
4 changed files with 62 additions and 23 deletions
+22 -11
View File
@@ -37,13 +37,23 @@
-- семь CamelCase-имён пришлось бы держать ровно в байтовом порядке, а сбой
-- порядка увёл бы в брак вообще всё, и молча (ADR 0005).
--
-- Метку времени разбирает не JSONExtract, а parseDateTimeBestEffortOrNull, и
-- это измеренная необходимость, а не вкус. На проводе UTCEventTime уезжает в
-- Метка времени — единственная из пяти, кого разбирает не JSONExtract, и это
-- измеренная необходимость, а не вкус. На проводе UTCEventTime уезжает в
-- ISO-8601 с суффиксом зоны — «2026-06-01T12:34:56Z» (спека генератора,
-- раздел 4), а JSONExtract с типом DateTime такую строку не берёт и отдаёт
-- NULL. Оставь его здесь — и в брак уехали бы все события до единого. Замер и
-- его подробности — ADR 0005, «Что проверено». EventDate в такой подпорке не
-- нуждается: дата уезжает как «2026-06-01», и JSONExtract её берёт.
-- NULL. Оставь его здесь — и в брак уехали бы все события до единого.
--
-- Формат назван буквально, а не отдан parseDateTimeBestEffort, и вот почему.
-- Best-effort понимает десяток записей и на непонятной не краснеет, а
-- достраивает недостающее: обрезанное «20:00:21» он превращает в первое
-- января текущего года. Такая строка прошла бы строгий приём с тихо неверным
-- временем — ровно с той порчей, ради которой класс key_field_unparsed и
-- заведён. Источник у топика один и шлёт одну запись, так что широта здесь не
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
-- «Что проверено».
--
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
-- JSONExtract её берёт.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
TO ods.event_dist
@@ -68,16 +78,17 @@ WITH
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeBestEffortOrNull(JSONExtractString(raw, 'UTCEventTime'))
IS NOT NULL AS key_fields_parsed
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed
SELECT
JSONExtract(raw, 'WatchID', 'UInt64') AS WatchID,
JSONExtract(raw, 'VisitID', 'UInt64') AS VisitID,
JSONExtract(raw, 'ClientID', 'UInt64') AS ClientID,
JSONExtract(raw, 'CounterID', 'UInt32') AS CounterID,
JSONExtract(raw, 'EventDate', 'Date') AS EventDate,
assumeNotNull(parseDateTimeBestEffortOrNull(
JSONExtractString(raw, 'UTCEventTime'))) AS UTCEventTime,
assumeNotNull(parseDateTimeOrNull(
JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ')) AS UTCEventTime,
JSONExtract(raw, 'ClientTimeZone', 'Int16') AS ClientTimeZone,
JSONExtract(raw, 'EventType', 'String') AS EventType,
JSONExtract(raw, 'Sign', 'Int8') AS Sign,
@@ -161,8 +172,8 @@ WITH
AND JSONExtract(raw, 'VisitID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'ClientID', 'Nullable(UInt64)') IS NOT NULL
AND JSONExtract(raw, 'EventDate', 'Nullable(Date)') IS NOT NULL
AND parseDateTimeBestEffortOrNull(JSONExtractString(raw, 'UTCEventTime'))
IS NOT NULL AS key_fields_parsed
AND parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'),
'%Y-%m-%dT%H:%i:%SZ') IS NOT NULL AS key_fields_parsed
SELECT
raw,
multiIf(