Incident discussion · Mega crypto scale · product_health

Что случилось с плохими batch-ами crypto в Mega

Две incremental-загрузки положили в ADS уже испорченные crypto-суммы: raw amount не был поделен на 1e8. Scale берётся из currencies_dictionary.decimals; в bad rows фактически применился decimals=0 / scale 1. Затем global_transactions__ads и product_health__dm честно агрегировали завышенные значения.

checked: 2026-06-23 11:48 UTC ClickHouse: data-space-ch-1 · 25.7.6.21 brand_id: 300 · Megabet / mega cluster: premium

Коротко простыми словами

Что должно было быть
raw ÷ 1e8

Crypto хранится в raw units. Например, 4,350,000,000 raw USDT должно стать 43.5 USDT, затем примерно 37.9 EUR.

Что попало в ADS
raw ÷ 1

В плохих строках normalized_amount = amount. Поэтому 4,350,000,000 raw USDT превратилось в 4.35B USDT и примерно 3.79B EUR.

Главный вывод: product_health не является первичной причиной. Он агрегирует amount_eur, который уже был испорчен выше — в ads.premium_transactions__ads, а затем протёк в ads.global_transactions__ads.

Где хранится scale и каким сервисом он попадает в витрину

Scale физически не хранится как отдельная колонка. Он выводится из currencies_dictionary.decimals: scale = pow(10, decimals). Для crypto ожидаем decimals=8, значит scale 100000000. В плохих строках effective scale был 1, то есть как будто decimals=0.

Source MySQL

mysql_bank_premium.transactions

mysql_bank_premium.currencies_dictionary

mysql_bank_luxury.currencies_dictionary

Source definitions: models/raw/platform/prod/*/plbank/sources.yml.

ODS ClickHouse

ods.premium_plbank_transactions__ods

ods.premium_plbank_currencies_dictionary__ods

ods.luxury_plbank_currencies_dictionary__ods

Dictionary columns: id, name, code, decimals, is_fiat.

ADS/DM

ads.premium_transactions__ads

ads.global_transactions__ads

dm.product_health__dm

В ADS уже persisted normalized_amount и amount_eur.

Кто грузит source → ODS

ОбъектСервис / DAGКонфигРежим
mysql_bank_premium.transactions → ods.premium_plbank_transactions__ods Airflow generated ingestion DAG ingest_premium_plbank_ods, owner data-platform dags/ingestion/sources/premium_plbank.yaml integer incremental by id, schedule 15 * * * *
mysql_bank_premium.currencies_dictionary → ods.premium_plbank_currencies_dictionary__ods Airflow generated ingestion DAG ingest_premium_plbank_ods dags/ingestion/sources/premium_plbank.yaml full_refresh, columns id, name, code, decimals, is_fiat
mysql_bank_luxury.currencies_dictionary → ods.luxury_plbank_currencies_dictionary__ods Airflow generated ingestion DAG ingest_luxury_plbank_ods dags/ingestion/sources/luxury_plbank.yaml full_refresh, columns id, name, code, decimals, is_fiat

Как YAML превращается в сервис

Airflow generator

dags/ingestion/dag_factory.py читает YAML из dags/ingestion/sources/ и создаёт PythonOperator на каждый task.

Task вызывает run_ingestion из dags/ingestion/run_ingestion.py.

ClickHouse sink

dags/ingestion/clickhouse_sink.py для full_refresh делает TRUNCATE target local table и затем INSERT ... SELECT из external MySQL database.

Connections: mysql_bank_premium / mysql_bank_luxurysys_ch_dbt_conn.

Где именно расчёт scale в dbt

ADS запускается отдельным Airflow DAG через Cosmos/dbt:

/home/ubuntu/src/data/airflow_repository/dags/premium_platform_plbank__ads.py
OWNER = 'frolov'
schedule=[PREMIUM_PLBANK_ODS]
select=['premium_transactions__ads']

Модель расчёта:

/home/ubuntu/src/data/airflow_repository/dbt_project/models/ads/platform/prod/premium/plbank/premium_transactions__ads.sql

Формула:

t.amount / pow(10, c.decimals) AS normalized_amount,
r.currency_rate * (t.amount / pow(10, c.decimals)) AS amount_eur

Критичный dependency mismatch: premium-модель берёт transactions из premium ODS, rates из premium ODS, но currency dictionary из luxury ODS:

transactions: FROM ods.premium_plbank_transactions__ods
currencies:   FROM ods.luxury_plbank_currencies_dictionary__ods
rates:        FROM ods.premium_plbank_exchange_rates_history_eur__ods

Какие batch-и были плохие

batch #1
17 Jun

_loaded_at = 2026-06-17 06:15:17

Затронуто: LTC и USDT.

batch #2
22 Jun

_loaded_at = 2026-06-22 10:15:17

Затронуто: BTC и USDT.

pattern
scale 1

У всех плохих строк effective scale был round(abs(amount / normalized_amount)) = 1, а для crypto ожидался 100000000.

Live evidence по bad rows

ДатаВалютаТипСтрокИскажение
2026-06-22USDTBONUS_ACTIVATED13.79B EUR
2026-06-22USDTREFILL12.53B EUR
2026-06-22BTCBET632.32B abs EUR
2026-06-22BTCREFILL11.97B EUR
2026-06-22BTCWIN3741M abs EUR
2026-06-17LTCBONUS_ACTIVATED1210.9M EUR
2026-06-17LTCBET1200.5M abs EUR
2026-06-17LTCREFILL141.1M EUR
2026-06-17USDTBONUS_ACTIVATED227.0M EUR

Что с ними случилось в pipeline

1 · source

Сырые premium plbank transactions содержали raw crypto amount.

2 · premium ADS

premium_transactions__ads записал строки с normalized_amount = amount.

3 · global ADS

global_transactions__ads взял эти строки как готовый upstream.

4 · product health

product_health__dm агрегировал испорченный amount_eur.

5 · dashboard

В dashboard появились миллиардные deposits / bonuses / bets.

Почему это не постоянная ошибка формулы

Текущий справочник нормальный

На момент проверки ODS dictionaries для main, luxury и premium показывают decimals=8 для BTC, LTC, USDT, USDC, SOL, TRX, TON, XRP, BCH.

Пересчёт сейчас даёт sane values

Те же transaction_id, если пересчитать сейчас через текущий dictionary, превращаются в нормальные суммы: десятки EUR, а не миллиарды.

transaction_id=1096204022 · USDT · amount=4,350,000,000
stored amount_eur     = 3,790,155,000.0000005
recomputed normalized = 43.5
recomputed amount_eur = 37.90155

transaction_id=1096203466 · BTC · amount=35,600
stored amount_eur     = 1,970,075,520
recomputed normalized = 0.000356
recomputed amount_eur = 19.7007552

Подозрительный dependency mismatch

В коде premium_transactions__ads.sql premium-модель берёт currency dictionary из luxury ODS:

FROM {{ ref('luxury_plbank_currencies_dictionary__ods') }}

Это не доказывает, что именно luxury dictionary был плохим в момент batch-а. Но это явная точка риска: premium transactions зависят от non-premium dictionary. Если словари временно расходятся или ODS отдаёт неполный/не тот snapshot, можно получить такие batch artifacts.

Timeline

2025-11-17
Создана логика premium_transactions__ads. Автор по git: Vlad / Vladislav Frolov, commit 30582c03.
2025-12-26
Создана product_health__dm. Автор по git: Vlad, commit 15ec42a2.
2026-06-17 06:15
Первый плохой batch: LTC/USDT с effective scale 1. Рядом в query_log: 06:15:46 TRUNCATE TABLE ods.luxury_plbank_currencies_dictionary__ods_local SYNC, затем 06:16:42 INSERT INTO ads.premium_transactions__ads_local. bad batch
2026-06-22 10:15
Второй плохой batch: BTC/USDT с effective scale 1. Query log: 10:15:17 INSERT INTO ods.premium_plbank_transactions__ods FROM mysql_bank_premium.transactions, затем 10:16:35 INSERT INTO ads.premium_transactions__ads. bad batch
2026-06-22 10:16
system.query_log показывает dbt incremental insert в ads.premium_transactions__ads от пользователя loader, node model.dbt_truelabel.premium_transactions__ads.
2026-06-23 11:48
Live RCA: current dictionaries нормальные; persisted bad rows и affected product_health rows подтверждены.

Что делать с плохими batch-ами

Не чинить только product_health

Если поправить только downstream, источник останется плохим. Нужно идти сверху вниз: premium ADS → global ADS → product_health.

Repair аккуратно

premium_transactions__ads_localReplicatedMergeTree, не Replacing. Просто вставить corrected duplicates опасно.

Minimum repair scope

  • _cluster='premium'
  • brand_id=300
  • dates: 2026-06-17, 2026-06-22
  • currencies: USDT, BTC, LTC
  • condition: round(abs(amount / normalized_amount)) = 1

Recommended order

  1. Identify exact bad transaction IDs.
  2. Controlled delete/rebuild in ads.premium_transactions__ads.
  3. Rebuild affected rows in ads.global_transactions__ads.
  4. Recompute affected keys/hours in dm.product_health__dm.
  5. Add DQ test for crypto effective scale and amount_eur outliers.

Detection SQL

SELECT
    toDate(created_at) AS d,
    transaction_currency_code,
    transaction_type,
    is_bonus,
    count() AS rows,
    uniqExact(user_id) AS users,
    max(_loaded_at) AS max_loaded,
    sum(abs(amount_eur)) AS sum_abs_eur,
    max(abs(amount_eur)) AS max_abs_eur
FROM ads.global_transactions__ads
WHERE brand_id = 300
  AND transaction_currency_code IN ('USDT','BTC','LTC','BNB','ETH','USDC','SOL','TRX','TON','XRP','BCH')
  AND amount != 0 AND normalized_amount != 0
  AND round(abs(amount / normalized_amount)) = 1
GROUP BY d, transaction_currency_code, transaction_type, is_bonus
ORDER BY d DESC, sum_abs_eur DESC;