refactor(orders): SQL приёма вынесен из DAG
- Зачем: - преобразования хранилища должны читаться рядом с целевым слоем, а DAG должен показывать оркестрацию. - Что: - запросы забора и разбора заказов разложены по каталогам STG и ODS. - общий контракт провода подключён в обе ветви штатным шаблонизатором Airflow. - SQL смонтирован во все службы Airflow, а правило раскладки записано в архитектуре. - Проверка: - make lint config-test smoke check-services. - airflow tasks render для pull_batch и parse_batch; airflow tasks test для parse_batch.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
-- Форма провода одной строки слепка: общая часть обеих вставок ODS.
|
||||
--
|
||||
-- Строгий приём: проверяется форма провода и ничего сверх неё.
|
||||
--
|
||||
-- Ключей одиннадцать, и проверяются все: десять скалярных значений прямо
|
||||
-- образуют типизированную строку заказа. Внутрь items приём не смотрит —
|
||||
-- содержимое позиций, переходы статуса и равенства сумм остаются ниже границы
|
||||
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
|
||||
--
|
||||
-- Файл включают обе вставки нарочно: годная ветвь берёт row_is_valid, брак —
|
||||
-- буквальное NOT. Разойдись условия хоть на символ — строка либо задвоится,
|
||||
-- либо исчезнет молча.
|
||||
--
|
||||
-- Отсюда два запрета на выражения предиката, и оба серьёзные. Первый: ни одно
|
||||
-- не возвращает NULL — трёхзначная логика дала бы строку, которую не берёт ни
|
||||
-- условие, ни его отрицание. Поэтому сравнения дают 0 или 1, а обнуляемый
|
||||
-- разбор заканчивается IS NOT NULL. Второй: ни одно не бросает исключений на
|
||||
-- произвольном raw — иначе одна грязная строка роняет весь переход, ради
|
||||
-- отсутствия чего таблица брака и заведена.
|
||||
|
||||
WITH
|
||||
-- Каноническое время провода: UTC, ровно три знака долей секунды.
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
|
||||
JSONType(raw) = 'Object' AS is_object,
|
||||
arraySort(JSONExtractKeys(raw)) = arraySort([
|
||||
'order_id', 'user_id', 'status', 'created_at', 'updated_at',
|
||||
'items_total', 'discount', 'delivery', 'total', 'items',
|
||||
'snapshot_date'
|
||||
]) AS keys_match,
|
||||
JSONType(raw, 'order_id') = 'String'
|
||||
AND JSONType(raw, 'status') = 'String'
|
||||
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
|
||||
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
|
||||
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
|
||||
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
|
||||
AND JSONType(raw, 'items') = 'Array'
|
||||
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
|
||||
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
|
||||
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
|
||||
-- «Что проверено».
|
||||
AND arrayAll(k ->
|
||||
JSONType(raw, k) = 'String'
|
||||
AND match(JSONExtractString(raw, k),
|
||||
'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$')
|
||||
AND parseDateTime64InJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
|
||||
['created_at', 'updated_at'])
|
||||
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
|
||||
-- форму, разбор — вместимость: у строки из двадцати девяток форма
|
||||
-- хороша, а в Decimal(18, 2) она не влезает.
|
||||
AND arrayAll(k ->
|
||||
JSONType(raw, k) = 'String'
|
||||
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
|
||||
AND toDecimal64OrNull(JSONExtractString(raw, k), 2) IS NOT NULL,
|
||||
['items_total', 'discount', 'delivery', 'total'])
|
||||
AND JSONType(raw, 'snapshot_date') = 'String'
|
||||
AND match(JSONExtractString(raw, 'snapshot_date'), '^\\d{4}-\\d{2}-\\d{2}$')
|
||||
AND parseDateTimeInJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, 'snapshot_date'), 'yyyy-MM-dd', 'UTC') IS NOT NULL
|
||||
AS fields_valid,
|
||||
is_object AND keys_match AND fields_valid AS row_is_valid
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Брак: тот же срез и буквальное отрицание того же предиката.
|
||||
--
|
||||
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
|
||||
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
|
||||
-- без объявленного порядка он попал бы то в один класс, то в другой.
|
||||
|
||||
INSERT INTO ods.order_snapshot_errors_dist
|
||||
{% include "ods/_order_wire_contract.sql" %}
|
||||
SELECT
|
||||
raw,
|
||||
multiIf(
|
||||
NOT is_object, 'not_an_object',
|
||||
NOT keys_match, 'keyset_mismatch',
|
||||
'field_invalid'
|
||||
) AS error_class,
|
||||
kafka_topic,
|
||||
kafka_partition,
|
||||
kafka_offset,
|
||||
kafka_timestamp,
|
||||
consumer_host,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM stg.orders_raw_dist
|
||||
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
||||
SETTINGS distributed_foreground_insert = 1
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
|
||||
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
|
||||
--
|
||||
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
|
||||
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
|
||||
|
||||
INSERT INTO ods.order_snapshot_dist
|
||||
{% include "ods/_order_wire_contract.sql" %}
|
||||
SELECT
|
||||
JSONExtractString(raw, 'order_id') AS order_id,
|
||||
JSONExtract(raw, 'user_id', 'UInt64') AS user_id,
|
||||
JSONExtractString(raw, 'status') AS status,
|
||||
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, 'created_at'), ts_mask, 'UTC')) AS created_at,
|
||||
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, 'updated_at'), ts_mask, 'UTC')) AS updated_at,
|
||||
assumeNotNull(toDecimal64OrNull(
|
||||
JSONExtractString(raw, 'items_total'), 2)) AS items_total,
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
|
||||
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
|
||||
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
|
||||
JSONExtractRaw(raw, 'items') AS items,
|
||||
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
|
||||
JSONExtractString(raw, 'snapshot_date'),
|
||||
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
|
||||
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
|
||||
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM stg.orders_raw_dist
|
||||
WHERE _load_id = {load_id:String} AND row_is_valid
|
||||
SETTINGS distributed_foreground_insert = 1
|
||||
Reference in New Issue
Block a user