Files
clickstream-ch-kafka-supers…/scripts/check_generated_history_chain.sh
T
ddadminandClaude Fable 5 f5dee26eda feat(generator): глагол next-day — следующий модельный день от слепка
- Зачем:
  - режим кормления стенда порциями: менти триггерит «следующий день»,
    видит полный цикл DWH за один шаг (задача 13, вариант 2 — генерация
    от слепка T_end).
- Что:
  - новый ограниченный режим next-day: восстановление мира из state,
    генерация ровно [T_end, T_end+24h), публикация данные -> state ->
    манифест (манифест — точка фиксации, автоотката нет).
  - операция next-day в DAG generator_control: своя предпроверка границы
    вместо clean-guard, идемпотентность через параметр expected_t_end.
  - цепочка границ — накопительное поле boundaries в манифесте, старый
    формат читается как [T0, T_end]; импорт не изменён.
  - новая проверка цепочки (make generated-history-chain-check): непарные
    счётчики и однородность по каждой границе, явный статус нулевого
    стыка, хвост за границей по всем четырём топикам, литералы в UTC
    с микросекундами.
  - документация OPERATIONS.md: глагол, предпроверка, восстановление
    после сбоя, ограничение retention; в задаче 13 — решения двух слепых
    ревью постановки и кода с аргументами отклонений.
- Проверка:
  - make test: 204 теста генератора + 31 контракт корня, зелёные.
  - make generated-history-chain-check: зелёный, 2 внутренние границы,
    непарные счётчики нулевые; учебный цикл: DM 322 -> 10026 -> 19196
    за два next-day подряд.
  - make generated-history-runtime-check (регрессия задачи 20): зелёный.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:53:48 +03:00

276 lines
11 KiB
Bash

#!/usr/bin/env bash
#
# Проверяет завершённые стыки между порциями модельной истории.
set -euo pipefail
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}"
CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}"
CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}"
fail() {
echo "Ошибка: $*" >&2
exit 1
}
clickhouse_datetime_literal() {
local value="$1"
local normalized
if ! normalized="$(date --utc --date="${value}" '+%Y-%m-%d %H:%M:%S.%6N')"; then
fail "не удалось привести границу к UTC: ${value}"
fi
echo "${normalized}"
}
ch_query() {
${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
--user="${CLICKHOUSE_USER}" \
--password="${CLICKHOUSE_PASSWORD}" \
--query "$1"
}
mapfile -t boundaries < <(
${COMPOSE_BIN} run --rm --no-deps generator \
python -m clickstream_generator.startup_history_artifact kafka-boundaries
)
boundary_count="${#boundaries[@]}"
(( boundary_count >= 2 )) || fail "manifest boundaries должен содержать T0 и T_end"
manifest_topic_rows="$(${COMPOSE_BIN} run --rm --no-deps generator \
python -m clickstream_generator.startup_history_artifact kafka-topic-rows)"
IFS=$'\t' read -r expected_browser_rows expected_location_rows \
expected_device_rows expected_geo_rows <<< "${manifest_topic_rows}"
for rows in \
"${expected_browser_rows}" \
"${expected_location_rows}" \
"${expected_device_rows}" \
"${expected_geo_rows}"; do
[[ "${rows}" =~ ^[0-9]+$ ]] || fail "manifest содержит неверные счётчики топиков"
done
echo "=== Проверка порций истории ==="
for ((index = 0; index < boundary_count - 1; index++)); do
left="$(clickhouse_datetime_literal "${boundaries[index]}")"
right="$(clickhouse_datetime_literal "${boundaries[index + 1]}")"
rows="$(ch_query "
WITH
toDateTime64('${left}', 6, 'UTC') AS segment_start,
toDateTime64('${right}', 6, 'UTC') AS segment_end
SELECT count()
FROM dm.v_events_enriched
WHERE parseDateTime64BestEffortOrNull(toString(event_ts), 6, 'UTC') >= segment_start
AND parseDateTime64BestEffortOrNull(toString(event_ts), 6, 'UTC') < segment_end
FORMAT TabSeparated")"
[[ "${rows}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать порцию ${boundaries[index]}"
(( rows > 0 )) || fail "порция [${boundaries[index]}, ${boundaries[index + 1]}) пуста"
echo "segment_${index}_rows=${rows}"
done
actual_topic_rows="$(ch_query "
SELECT
(SELECT count() FROM stg.browser_raw) AS actual_topic_rows,
(SELECT count() FROM stg.location_raw) AS actual_location_rows,
(SELECT count() FROM stg.device_raw) AS actual_device_rows,
(SELECT count() FROM stg.geo_raw) AS actual_geo_rows
FORMAT TabSeparated")"
IFS=$'\t' read -r actual_browser_rows actual_location_rows \
actual_device_rows actual_geo_rows <<< "${actual_topic_rows}"
topic_names=(browser_events location_events device_events geo_events)
expected_rows=(
"${expected_browser_rows}"
"${expected_location_rows}"
"${expected_device_rows}"
"${expected_geo_rows}"
)
actual_rows=(
"${actual_browser_rows}"
"${actual_location_rows}"
"${actual_device_rows}"
"${actual_geo_rows}"
)
for ((index = 0; index < ${#topic_names[@]}; index++)); do
[[ "${actual_rows[index]}" =~ ^[0-9]+$ ]] \
|| fail "не удалось прочитать число строк ${topic_names[index]} в STG"
if (( actual_rows[index] > expected_rows[index] )); then
fail "найден хвост ${topic_names[index]} после manifest: STG=${actual_rows[index]}, manifest=${expected_rows[index]}"
fi
if (( actual_rows[index] < expected_rows[index] )); then
fail "в STG не хватает строк ${topic_names[index]}: STG=${actual_rows[index]}, manifest=${expected_rows[index]}"
fi
done
last_boundary="$(clickhouse_datetime_literal "${boundaries[boundary_count - 1]}")"
data_tail_rows="$(ch_query "
SELECT count() AS data_tail_rows
FROM stg.browser_raw
WHERE parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
) >= toDateTime64('${last_boundary}', 6, 'UTC')
FORMAT TabSeparated")"
[[ "${data_tail_rows}" =~ ^[0-9]+$ ]] \
|| fail "не удалось проверить хвост после ${boundaries[boundary_count - 1]}"
(( data_tail_rows == 0 )) \
|| fail "найден хвост данных после последней границы manifest: ${data_tail_rows}"
if (( boundary_count == 2 )); then
echo "Завершённых внутренних стыков пока нет."
exit 0
fi
echo "=== Проверка внутренних стыков boundaries ==="
for ((index = 1; index < boundary_count - 1; index++)); do
boundary="$(clickhouse_datetime_literal "${boundaries[index]}")"
pairing="$(ch_query "
WITH
toDateTime64('${boundary}', 6, 'UTC') AS boundary,
browser_rows AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
) AS event_ts
FROM stg.browser_raw
WHERE event_id IS NOT NULL AND click_id IS NOT NULL AND event_ts IS NOT NULL
GROUP BY event_id, click_id, event_ts
),
crossing AS (
SELECT click_id
FROM browser_rows
GROUP BY click_id
HAVING min(event_ts) < boundary AND max(event_ts) >= boundary
),
browser_counts AS (
SELECT click_id, count() AS browser_rows
FROM browser_rows
WHERE click_id IN (SELECT click_id FROM crossing)
GROUP BY click_id
),
location_ids AS (
SELECT toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id
FROM stg.location_raw
WHERE event_id IS NOT NULL
GROUP BY event_id
),
device_counts AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
count() AS device_rows
FROM stg.device_raw
WHERE click_id IN (SELECT click_id FROM crossing)
GROUP BY click_id
),
geo_counts AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
count() AS geo_rows
FROM stg.geo_raw
WHERE click_id IN (SELECT click_id FROM crossing)
GROUP BY click_id
)
SELECT
(SELECT count() FROM crossing) AS crossing_visits,
(
SELECT count()
FROM browser_rows AS b
LEFT JOIN location_ids AS l ON l.event_id = b.event_id
WHERE b.click_id IN (SELECT click_id FROM crossing)
AND l.event_id IS NULL
) AS unpaired_location_rows,
(
SELECT ifNull(sum(greatest(b.browser_rows - ifNull(d.device_rows, 0), 0)), 0)
FROM browser_counts AS b
LEFT JOIN device_counts AS d ON d.click_id = b.click_id
) AS unpaired_device_rows,
(
SELECT ifNull(sum(greatest(b.browser_rows - ifNull(g.geo_rows, 0), 0)), 0)
FROM browser_counts AS b
LEFT JOIN geo_counts AS g ON g.click_id = b.click_id
) AS unpaired_geo_rows
SETTINGS join_use_nulls = 1
FORMAT TabSeparated")"
IFS=$'\t' read -r crossing_visits unpaired_location_rows unpaired_device_rows unpaired_geo_rows <<< "${pairing}"
[[ "${crossing_visits}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_location_rows}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_device_rows}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_geo_rows}" =~ ^[0-9]+$ ]] \
|| fail "не удалось прочитать пары на границе ${boundaries[index]}"
if (( unpaired_location_rows > 0 || unpaired_device_rows > 0 || unpaired_geo_rows > 0 )); then
fail "непарные строки на границе ${boundaries[index]}: location=${unpaired_location_rows}, device=${unpaired_device_rows}, geo=${unpaired_geo_rows}"
fi
context="$(ch_query "
WITH
toDateTime64('${boundary}', 6, 'UTC') AS boundary,
crossing AS (
SELECT click_id
FROM
(
SELECT
click_id,
parseDateTime64BestEffortOrNull(
toString(event_ts), 6, 'UTC'
) AS model_event_ts
FROM dds.event
WHERE event_ts IS NOT NULL
)
GROUP BY click_id
HAVING min(model_event_ts) < boundary AND max(model_event_ts) >= boundary
),
facts AS (
SELECT
e.click_id,
groupUniqArray(coalesce(toString(e.browser_name), '__NULL__')) AS browser_names,
groupUniqArray(coalesce(toString(e.browser_language), '__NULL__')) AS browser_languages,
groupUniqArray(coalesce(toString(e.browser_user_agent), '__NULL__')) AS browser_user_agents,
groupUniqArray(coalesce(toString(e.referer_url), '__NULL__')) AS referer_urls,
groupUniqArray(coalesce(toString(e.referer_medium), '__NULL__')) AS referer_mediums,
groupUniqArray(coalesce(toString(e.utm_medium), '__NULL__')) AS utm_mediums,
groupUniqArray(coalesce(toString(e.utm_source), '__NULL__')) AS utm_sources,
groupUniqArray(coalesce(toString(e.utm_content), '__NULL__')) AS utm_contents,
groupUniqArray(coalesce(toString(e.utm_campaign), '__NULL__')) AS utm_campaigns
FROM dds.event AS e
INNER JOIN crossing AS c ON c.click_id = e.click_id
GROUP BY e.click_id
)
SELECT
count() AS crossing_visits,
countIf(
length(browser_names) = 1
AND length(browser_languages) = 1
AND length(browser_user_agents) = 1
AND length(referer_urls) = 1
AND length(referer_mediums) = 1
AND length(utm_mediums) = 1
AND length(utm_sources) = 1
AND length(utm_contents) = 1
AND length(utm_campaigns) = 1
) AS per_event_homogeneous_visits
FROM facts
FORMAT TabSeparated")"
IFS=$'\t' read -r context_crossing_visits per_event_homogeneous_visits <<< "${context}"
[[ "${context_crossing_visits}" =~ ^[0-9]+$ ]] \
&& [[ "${per_event_homogeneous_visits}" =~ ^[0-9]+$ ]] \
|| fail "не удалось прочитать фактуру на границе ${boundaries[index]}"
(( context_crossing_visits == crossing_visits )) \
|| fail "STG и DDS нашли разное число переходящих визитов на ${boundaries[index]}"
if (( crossing_visits == 0 )); then
echo "boundary ${index}/${boundary_count}: crossing_visits=0 — однородность неприменима"
else
(( per_event_homogeneous_visits == crossing_visits )) \
|| fail "browser/referer/utm меняются на границе ${boundaries[index]}: ${per_event_homogeneous_visits}/${crossing_visits}"
echo "boundary ${index}/${boundary_count}: crossing_visits=${crossing_visits} — однородность подтверждена"
fi
echo "boundary_${index}=${boundaries[index]}"
echo "boundary_${index}_crossing_visits=${crossing_visits}"
echo "boundary_${index}_unpaired_location_rows=${unpaired_location_rows}"
echo "boundary_${index}_unpaired_device_rows=${unpaired_device_rows}"
echo "boundary_${index}_unpaired_geo_rows=${unpaired_geo_rows}"
done