From a534f3cc94cd2736486e914047b2df2cfc2d6cf7 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 7 Aug 2026 16:48:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(ods):=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=D1=83?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D1=83,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20best-effort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: 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 --- docs/adr/0005-event-ingestion.md | 33 ++++++++++++++++++++++-------- docs/architecture/storage.md | 11 +++++++++- docs/specs/2026-08-01-generator.md | 8 +++++--- sql/ddl/30-ods-views.sql | 33 ++++++++++++++++++++---------- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index 435ace1..a6733a8 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -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 на строке, которая датой не является вовсе («мусор»), и на числе, diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 1391cc1..867a293 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -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 diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index bbfcdf7..db9aa7e 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -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 года, и отправитель идёт diff --git a/sql/ddl/30-ods-views.sql b/sql/ddl/30-ods-views.sql index 1548fce..fa1ab3e 100644 --- a/sql/ddl/30-ods-views.sql +++ b/sql/ddl/30-ods-views.sql @@ -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(