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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user