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:
@@ -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 на строке, которая датой не является вовсе («мусор»), и на числе,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user