fix(generator): сохранена фактура визита при восстановлении

- Зачем:
  - визит после восстановления не должен менять браузер и источники перехода внутри одного click_id.
- Что:
  - добавлен base_click_id в state v3 для восстановления донора фактуры.
  - исправлено восстановление timestamp offset без потери микросекунд.
  - расширены тесты и стыковая проверка browser/source и device/os/geo.
- Проверка:
  - uv run --with-requirements generator/requirements.txt pytest generator/tests -q.
  - bash -n scripts/check_generated_analytics.sh.
  - git diff --cached --check.
This commit is contained in:
2026-07-04 21:46:23 +03:00
parent 0cbfe9b32e
commit e2d06841de
13 changed files with 464 additions and 77 deletions
+184
View File
@@ -12,6 +12,8 @@ CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}"
GEN_MODEL_T0="${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}"
GEN_MODEL_T_END="${GEN_MODEL_T_END:-2026-01-01T06:00:00+00:00}"
REQUIRE_SUPERSET="${REQUIRE_SUPERSET:-1}"
CHECK_LIVE_SEAM="${CHECK_LIVE_SEAM:-auto}"
GEN_LIVE_CHECK_MINUTES="${GEN_LIVE_CHECK_MINUTES:-10}"
fail() {
echo "Ошибка: $*" >&2
@@ -46,6 +48,13 @@ pg_query() {
CH_MODEL_T0="$(clickhouse_datetime_literal "${GEN_MODEL_T0}")"
CH_MODEL_T_END="$(clickhouse_datetime_literal "${GEN_MODEL_T_END}")"
[[ "${GEN_LIVE_CHECK_MINUTES}" =~ ^[0-9]+$ ]] \
|| fail "GEN_LIVE_CHECK_MINUTES должен быть целым числом минут"
case "${CHECK_LIVE_SEAM}" in
0|1|auto) ;;
*) fail "CHECK_LIVE_SEAM должен быть 0, 1 или auto" ;;
esac
echo "=== Проверка ClickHouse: данные генерации в DM ==="
stats_query="
@@ -261,6 +270,181 @@ echo "confirmation=${contains_confirmation}"
echo "monotonic_ok=${contains_monotonic_ok}"
echo "confirmation_share=${contains_confirmation_share}"
should_check_live_seam="${CHECK_LIVE_SEAM}"
if [[ "${should_check_live_seam}" == "auto" ]]; then
live_rows_query="
WITH
toDateTime64('${CH_MODEL_T_END}', 6) AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end
SELECT count()
FROM dds.event
WHERE event_ts >= t_end AND event_ts < t_live_end
FORMAT TabSeparated"
live_rows="$(ch_query "${live_rows_query}")"
[[ "${live_rows}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать live-строки DDS после GEN_MODEL_T_END"
if (( live_rows > 0 )); then
should_check_live_seam="1"
else
should_check_live_seam="0"
fi
fi
if [[ "${should_check_live_seam}" == "1" ]]; then
echo ""
echo "=== Проверка ClickHouse: стык backfill/live в DDS ==="
seam_duplicates_query="
WITH
toDateTime64('${CH_MODEL_T0}', 6) AS t0,
toDateTime64('${CH_MODEL_T_END}', 6) AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end
SELECT
count() AS events,
uniqExact(event_id) AS unique_events,
events - unique_events AS duplicate_events
FROM dds.event
WHERE event_ts >= t0 AND event_ts < t_live_end
FORMAT TabSeparated"
seam_duplicates="$(ch_query "${seam_duplicates_query}")"
IFS=$'\t' read -r seam_events seam_unique_events seam_duplicate_events <<< "${seam_duplicates}"
[[ "${seam_events}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать события DDS на стыке"
(( seam_events > 0 )) || fail "нет событий DDS в диапазоне проверки стыка"
(( seam_duplicate_events == 0 )) || fail "на стыке найдены дубли event_id: ${seam_duplicate_events}"
echo "events=${seam_events}"
echo "unique_events=${seam_unique_events}"
echo "duplicate_events=${seam_duplicate_events}"
seam_context_query="
WITH
toDateTime64('${CH_MODEL_T_END}', 6) AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end,
crossing AS (
SELECT
e.click_id,
min(e.event_ts) AS first_ts,
max(e.event_ts) AS last_ts,
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,
groupUniqArray(coalesce(toString(c.device_type), '__NULL__')) AS device_types,
groupUniqArray(coalesce(toString(c.os_name), '__NULL__')) AS os_names,
groupUniqArray(coalesce(toString(c.geo_country), '__NULL__')) AS geo_countries
FROM dds.event AS e
LEFT JOIN dds.click AS c ON c.click_id = e.click_id
WHERE e.event_ts >= t_end - INTERVAL 30 MINUTE
AND e.event_ts < t_live_end
GROUP BY e.click_id
HAVING first_ts < t_end AND last_ts >= t_end
)
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,
countIf(
length(device_types) = 1
AND length(os_names) = 1
AND length(geo_countries) = 1
) AS click_context_homogeneous_visits
FROM crossing
FORMAT TabSeparated"
seam_context="$(ch_query "${seam_context_query}")"
IFS=$'\t' read -r crossing_visits per_event_homogeneous_visits click_context_homogeneous_visits <<< "${seam_context}"
[[ "${crossing_visits}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать переходящие визиты"
(( crossing_visits > 0 )) || fail "нет визитов, переходящих через GEN_MODEL_T_END"
(( per_event_homogeneous_visits == crossing_visits )) \
|| fail "per-event фактура меняется на стыке: ${per_event_homogeneous_visits}/${crossing_visits}"
(( click_context_homogeneous_visits == crossing_visits )) \
|| fail "device/os/geo меняются на стыке: ${click_context_homogeneous_visits}/${crossing_visits}"
echo "crossing_visits=${crossing_visits}"
echo "per_event_homogeneous_visits=${per_event_homogeneous_visits}"
echo "click_context_homogeneous_visits=${click_context_homogeneous_visits}"
ods_context_query="
WITH
toDateTime64('${CH_MODEL_T_END}', 6) AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end,
crossing AS (
SELECT
click_id
FROM dds.event
WHERE event_ts >= t_end - INTERVAL 30 MINUTE
AND event_ts < t_live_end
GROUP BY click_id
HAVING min(event_ts) < t_end AND max(event_ts) >= t_end
),
device_conflicts AS (
SELECT
d.click_id
FROM ods.device_by_click AS d
INNER JOIN crossing AS c ON c.click_id = d.click_id
WHERE d.click_id IS NOT NULL
GROUP BY d.click_id
HAVING
uniqExact(coalesce(toString(device_type), '__NULL__')) > 1
OR uniqExact(coalesce(toString(device_is_mobile), '__NULL__')) > 1
OR uniqExact(coalesce(toString(os_name), '__NULL__')) > 1
OR uniqExact(coalesce(toString(os), '__NULL__')) > 1
OR uniqExact(coalesce(toString(os_timezone), '__NULL__')) > 1
OR uniqExact(coalesce(toString(user_domain_id), '__NULL__')) > 1
),
geo_conflicts AS (
SELECT
g.click_id
FROM ods.geo_by_click AS g
INNER JOIN crossing AS c ON c.click_id = g.click_id
WHERE g.click_id IS NOT NULL
GROUP BY g.click_id
HAVING
uniqExact(coalesce(toString(geo_country), '__NULL__')) > 1
OR uniqExact(coalesce(toString(geo_timezone), '__NULL__')) > 1
OR uniqExact(coalesce(toString(geo_region_name), '__NULL__')) > 1
OR uniqExact(coalesce(toString(geo_latitude), '__NULL__')) > 1
OR uniqExact(coalesce(toString(geo_longitude), '__NULL__')) > 1
OR uniqExact(coalesce(toString(ip_address), '__NULL__')) > 1
)
SELECT
(SELECT count() FROM crossing) AS crossing_visits,
(SELECT count() FROM device_conflicts) AS ods_device_conflicts,
(SELECT count() FROM geo_conflicts) AS ods_geo_conflicts
FORMAT TabSeparated"
ods_context="$(ch_query "${ods_context_query}")"
IFS=$'\t' read -r ods_crossing_visits ods_device_conflicts ods_geo_conflicts <<< "${ods_context}"
[[ "${ods_crossing_visits}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать ODS-проверку стыка"
(( ods_crossing_visits == crossing_visits )) \
|| fail "ODS и DDS нашли разное число переходящих визитов: ${ods_crossing_visits}/${crossing_visits}"
(( ods_device_conflicts == 0 )) || fail "ODS device конфликтует на стыке: ${ods_device_conflicts}"
(( ods_geo_conflicts == 0 )) || fail "ODS geo конфликтует на стыке: ${ods_geo_conflicts}"
echo "ods_device_conflicts=${ods_device_conflicts}"
echo "ods_geo_conflicts=${ods_geo_conflicts}"
else
echo ""
echo "Проверка стыка backfill/live пропущена: CHECK_LIVE_SEAM=${CHECK_LIVE_SEAM}, live_rows=${live_rows:-0}"
fi
echo ""
echo "=== Проверка ClickHouse: основные DM-витрины не пустые ==="