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
+25 -8
View File
@@ -211,14 +211,31 @@ ClickHouse 26.3.17.56. Все четыре ответили так, как жд
строке NULL — то есть все события до единого уходили бы в брак с классом
`key_field_unparsed`. Ни `DateTime64`, ни `DateTime('UTC')` суффикс тоже не
берут; без `Z` та же строка разбирается. Спека генератора обещала обратное
(«принимает ISO без плясок») — обещание было ошибочным и исправлено тем же
PR. Разбор метки времени поэтому идёт
`parseDateTimeBestEffortOrNull(JSONExtractString(raw, 'UTCEventTime'))`:
документация ClickHouse прямо относит ISO-8601 к форматам
`parseDateTimeBestEffort` (сверено через Context7 7 августа 2026 года), а
вариант `*OrNull` возвращает NULL вместо исключения и потому годится в
предикат. `EventDate` уезжает как `2026-06-01` и разбирается `JSONExtract`
без оговорок.
(«принимает ISO без плясок») — обещание было ошибочным и исправлено тем же PR.
Настройка `cast_string_to_date_time_mode = 'best_effort'` дела не меняет:
с ней суффикс берёт `CAST`, а `JSONExtract` по-прежнему отдаёт NULL — то есть
внутренний разбор `JSONExtract` её не слушает. `EventDate` уезжает как
`2026-06-01` и разбирается `JSONExtract` без оговорок.
Разбор метки времени идёт
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ')`
— по буквально названному формату, а не через `parseDateTimeBestEffort`.
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
исключения, то есть годятся в предикат. Выбран точный формат потому, что
широта здесь работает против строгого приёма: `parseDateTimeBestEffort` на
непонятной строке не краснеет, а достраивает недостающее. Измерено 7 августа
2026 года — обрезанное `20:00:21` он превращает в `2026-01-01 20:00:21`,
подставив текущий год и первое января; голая дата `2026-05-31` становится
полуночью; строка цифр читается числом эпохи. Такое сообщение прошло бы
строгий приём с тихо неверным временем — ровно с той порчей, ради которой
класс `key_field_unparsed` и заведён. Разбор по названному формату отдаёт на
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой.
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только
на порче.
Оговорка к обнуляемому разбору даты, измеренная там же: `Nullable(Date)`
даёт NULL на строке, которая датой не является вовсе («мусор»), и на числе,
+10 -1
View File
@@ -391,7 +391,7 @@ ODS. Второе: матвью приёма создаётся последне
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа) и четыре при исполнении #43
#37 (четыре 5 августа 2026 года, пятый 6 августа) и пять при исполнении #43
(7 августа). Все подтвердили то, что здесь написано.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
@@ -413,6 +413,15 @@ ODS. Второе: матвью приёма создаётся последне
залитого дважды: у каждого события метка совпала с меткой одной из двух его
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
доставок» ноль, то есть `now64()` в матвью разбора нет.
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
файлов DDL, только приходит он с другой стороны — не при первом создании, а
при замене матвью на работающем стенде. Практический вывод один: правишь
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
задержка, не измерено; наблюдение записано как наблюдение.
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
+5 -3
View File
@@ -275,9 +275,11 @@
«принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он
отдаёт NULL, и при исполнении #43 это увело бы в брак все события до
единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого
не меняется, меняется выражение разбора на стороне хранилища —
`parseDateTimeBestEffortOrNull` вместо `JSONExtract`
([ADR 0005](../adr/0005-event-ingestion.md)).
не меняется, меняется выражение разбора на стороне хранилища — `parseDateTime`
по буквально названному формату вместо `JSONExtract`
([ADR 0005](../adr/0005-event-ingestion.md)). То, что форма на проводе одна и
каноническая, здесь работает на хранилище: раз запись ровно одна, разбирать
её можно строго, не принимая заодно десяток чужих записей.
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
+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(