Airflow DAG-и
Шість DAG-ів у dags/, ланцюг import → scorecard → test-status на assets, параметри запуску, секрети й типові пастки KubernetesPodOperator.
Уся фонова автоматизація Kumquat живе в dags/ — шість DAG-ів на Airflow
3.2.0. Жоден із них не робить роботи сам: кожен таск запускає под у неймспейсі
airflow через KubernetesPodOperator, а логіка лежить в образі CLI-пакета
(backend/packages/kumquat-*) або в SQL-скрипті. DAG-файл — це лише
розклад, залежності й параметри.
Джерело правди — самі файли dags/*.py і dags/assets.py; конвенції написання
нових DAG-ів — у скілі .claude/skills/airflow-k8s-dags/. Хто потім читає
наповнену таблицю — Аналітика креативів.
Що є в dags/
| DAG | Розклад | Ентрипойнт пода | Що робить |
|---|---|---|---|
dag_kumquat_import_creative_performance.py |
20 4,12,16 * * * |
clickhouse-client (образ clickhouse/clickhouse-server:24.8) + mysql (mysql:8.4) |
Дописує в top_creo_for_tableau дизайнера, маркетолога й дату створення з kumquat, потім дзеркалить останні 30 днів у ClickHouse creative_performance; окремою таскою дзеркалить tt_creatives_data_country у tiktok_creative_performance — чотири таски на продукт |
dag_kumquat_creative_metrics.py |
asset META_ADS_DAILY |
kumquat-creative-metrics run |
Рахує скоркард креативів і $set-ить його в creatives.metrics |
dag_kumquat_ch_sync_creatives.py |
@hourly |
kumquat-ch-sync --mode batch |
Інкрементальний догон MongoDB creatives → ClickHouse creatives |
dag_kumquat_retention_purge.py |
@daily |
kumquat-retention run |
Видаляє soft-deleted паки/креативи і ресурси, якими вони володіють (Drive, S3, Qdrant, ClickHouse) |
dag_kumquat_archive_done_tasks.py |
0 6 * * 1 |
kumquat-cli archive-done-tasks |
Щопонеділка переносить у Archived задачі з Done, які in_test або manual_launch |
dag_kumquat_sync_test_status.py |
asset CREATIVE_METRICS_READY |
kumquat-cli sync-test-status |
Тримає test_status креатива в згоді з відкруткою: in_test → paused і назад |
dag_kumquat_import_solid_funnels.py |
0 5 * * * |
web-funnel-analytics import-solid-funnels |
Збирає ClickHouse solid_funnels_kumquat за останні 5 днів (рядки покупок — за trial_type + 28 днів): спенд з solid_funnels_kumquat чотирьох продуктових баз + кроки воронки (funnel_opens, second_screens, paywalls, checkouts) з funnel_prod, ad_id → creo_name через solid_funnels_kumquat_ad_info, сума по ключу, funnel_id кожному рядку за назвою воронки з funnel_prod.sessions_data |
Образи задаються константою IMAGE у кожному файлі. creative_metrics іде на
спільному backend-образі (ml-aps/kumquat/backend:backend_latest в ECR) — CLI
скоркарда лежить у ньому як консольний скрипт; import-DAG бере публічний образ
ClickHouse і не має власного коду взагалі.
Перші три зв’язані в один ланцюг, решта дві незалежні:
creo-marketologist (зовнішній) MySQL {app}.top_creo_for_tableau
03:20 / 11:20 / 15:20 UTC {app}.tt_creatives_data_country
│
│ +1 година
▼
import_creative_performance (4 паралельні гілки)
push_dim → apply_dim → import
import_tiktok (поруч, не в ланцюжку)
│
▼
publish ──► META_ADS_DAILY
extra["max_date"]
│
▼
creative_metrics (resolve_watermark → scorecard)
│
▼
CREATIVE_METRICS_READY
│
▼
sync_test_status (in_test → paused)
Чому TikTok — у цьому ж DAG-у, а не в окремому
tt_creatives_data_country — власний звіт TikTok, і він єдиний міряє саме відео:
старти, 6-секундні перегляди й реакції. Атрибуційний фід цього не має взагалі,
тож без нього TikTok-креатив не має ні hook, ні hold.
Таска не потребує збагачення (kumquat_creative_dim до неї не стосується), тож
вона стоїть поруч із ланцюжком push_dim → apply_dim → import, а не після
нього. Але вона так само гейтить publish, і саме тому живе тут, а не в
окремому DAG-у: скоркард читає обидві таблиці одним запитом, тож «вікно
залите» мусить означати обидві — інакше він порахує hook по половині даних і
запише це в Mongo як факт.
Асет лишився один і з тією самою назвою (…/creative_performance). Перейменувати
його — означало б тихо відключити скоркард: Airflow не піднімає помилку на
неіснуючий асет, консюмер просто перестає тригеритись.
Ще дві речі, які ця таска робить і які варто знати:
creo_nameвиводиться на імпорті, а не на читанні. TikTok називає оголошення файлом (1-4023_CNI_VARAI_VID_9x16_TIK_US_EN.mp4_tops), тож ключ — цеad_nameдо першої крапки. Робиться цеsubstringIndex(…, '.', 1)навмисно замість розбору сегментів: у базі одночасно живуть дві схеми неймінгу, і регулярка під будь-яку з них тихо загубила б половину карток.- Після імпорту таска друкує діагностику, не гейт: розподіл валют (нічого не
конвертується) і відсоток
creo_name, що звівся на картку. Зміна неймінгу в кабінеті видно тут як просідання матчу — задовго до того, як хтось помітить картку без hook rate.
Чому import-DAG ще й пише в MySQL
Колонки designer, creo_marketologist і date_creative в
top_creo_for_tableau історично заповнював апстрім-пайплайн крео-маркетолога,
парсячи їх із назви креатива. Неймінг, який kumquat генерує зараз, таких
сегментів не має, тож для кожного нового креатива ці колонки порожні — і старі
дашборди Tableau, що ріжуть по дизайнеру чи маркетологу, поступово сліпнуть.
Заповнює їх тепер kumquat, двома тасками на продукт перед імпортом:
push_dim_{product}(образ ClickHouse) —INSERT INTO FUNCTION mysql(...)зcreatives_currentу MySQL-таблицюkumquat_creative_dim(її DDL —db/mysql-init.sql, по копії на кожну з чотирьох БД). Повне оновлення вимірної таблиці:ON DUPLICATE KEY UPDATEпоcreo_name.apply_dim_{product}(образmysql:8.4) —UPDATE ... JOINвимірної таблиці наtop_creo_for_tableauу тому ж 30-денному вікні, яке наступний таск переімпортовує.
Порядок push_dim → apply_dim → import навмисний: ClickHouse отримує вже
збагачені значення в тому самому рані, а не через цикл.
Три речі, які тут легко зламати:
UPDATEтреба повторювати щоразу. Апстрім видаляє й переливає те саме 30-денне вікно тричі на добу, тож збагачення не «залишається накладеним».COALESCE(NULLIF(...))— не косметика. Легасі-картки теж є в kumquat і часто безassignee; без цьогоUPDATEзатер би значення, яке апстрім розпарсив із назви коректно.creo_name, якого kumquat не знає, джойн не чіпає взагалі.- Пишемо короткі коди, не імена. Колонки
varchar(8), і легасі-рядки вже містять ініціали, тож у MySQL їдеusers.initials(черезcreatives_current.assignee_code/task_owner_code). Проставляє їхkumquat-cli backfill-user-initials; людина без коду лишає свій рядок без змін.predesignerне чіпаємо — відповідника в kumquat немає.
Записи в MySQL вимагають прав: CREATE/INSERT/UPDATE на
kumquat_creative_dim, UPDATE на top_creo_for_tableau і
GRANT WRITE ON MYSQL для користувача ClickHouse (db/init.sql).
Історію, старшу за 30 днів, вікно DAG-у не дістає — для неї є
db/backfill-creative-dim.sh.
Чому scorecard на asset-і, а не на крон-і
Import DAG видаляє й перезаливає 30-денне вікно тричі на день. Скоркард на власному кроні рано чи пізно прочитав би джерело посеред перезаливки й записав би дірку в Mongo як факт. Asset-тригер робить порядок структурним, а не питанням розкладу.
Ключове тут — таск publish, і те, що він сам є сигналом повноти: окремого
валідаційного кроку немає. Він стоїть після всіх чотирьох імпортів, тож якщо
хоч один упав — publish не виконується, asset не публікується, і скоркард
просто не стартує.
extra["max_date"] їде разом із подією, щоб консюмер не вгадував, по який день
джерело повне. Це не зручність: asset-тригерні рани в Airflow 3 не мають
logical_date, data_interval_start і data_interval_end — звернення до них
кидає KeyError/UndefinedError. Ручний тригер їх має, тому код, що їх
читає, працює всюди, крім продакшену. Через це resolve_watermark має явний
ланцюг фолбеків (params → max_date з подій → source_dag_run →
dag_run.run_after), а под отримує вже готову дату, а не шаблон.
Події накопичуються між ранами (три імпорти, один скоркард) і приходять одним
раном із багатьма подіями — тому береться max із max_date, а не перша
подія.
Обидва URI лежать у dags/assets.py, і обидві сторони мусять імпортувати
звідти. URI, написаний двічі й потім відредагований з одного боку, нічого не
ламає видимо: продюсер публікує далі, консюмер просто більше ніколи не
тригериться, і єдиний симптом — скоркард, що тихо перестав оновлюватися.
Параметри запуску
Параметри мають два DAG-и (Trigger DAG w/ config) — creative_metrics:
| Параметр | Тип | Дефолт | Що робить |
|---|---|---|---|
watermark |
null/string, format: date |
null |
YYYY-MM-DD в обхід asset-події; порожнє = резолвиться з події |
media_sources |
array of string |
[] |
Скоуп рану, напр. ["fb", "tiktok"]; порожнє = скоуп деплою |
dry_run |
boolean |
false |
Тільки extract + transform, нічого не пишеться |
watermark і dry_run йдуть у под флагами в cmds. media_sources — ні:
под резолвить скоуп виключно з оточення (CreativeMetricsSettings.media_sources,
тобто KUMQUAT_CREATIVE_METRICS__MEDIA_SOURCES), і в CLI опції для нього немає.
Тому параметр інжектиться як V1EnvVar — саме та змінна, яку читає поле.
Дві деталі, від яких залежить, чи под узагалі стартує:
| tojsonобов’язковий. Дефолтний рендер списку в Jinja — Python-repr в одинарних лапках, а поле парситься тільки як JSON.['fb', 'tiktok']падає зSettingsErrorна старті пода, ще до першого запиту.- Порожній параметр рендериться в порожній рядок, а не в
[]. pydantic-settings відкидає порожні значення (env_ignore_empty=True), тож залишається значення деплою. Жорсткий[]тут навпаки тихо розширював би скоуп назад на всі джерела при кожному ручному запуску. У Kubernetesenvмає пріоритет надenv_fromдля того самого імені, тому явно заданий параметр перекриває секрет.
Скоуп рану записується в metrics.media_sources і в creatives__metrics_runs
(див. Mongo-схема), тож із документів видно, з яким
набором джерел він рахувався.
І sync_test_status:
| Параметр | Тип | Дефолт | Що робить |
|---|---|---|---|
idle_days |
integer, minimum: 0 |
3 |
Скільки днів без spend означає, що тест закінчився |
resume_days |
integer, minimum: 0 |
1 |
Наскільки свіжим має бути spend, щоб картка повернулася в in_test |
dry_run |
boolean |
false |
Друкує, кого перевів би, і нічого не пише |
Усі три йдуть у под флагами в arguments. dry_run рендериться в
--dry-run/--no-dry-run, а не в порожній рядок: порожній елемент argv
доїхав би до typer як зайвий позиційний аргумент і поклав би под.
resume_days більший за idle_days под відхиляє — див. мертву зону нижче.
sync_test_status: два ребра, мертва зона і хто має право відкотити
test_status креатива ставиться один раз — запуском (mark_launched стемпить
in_test), — і далі його не рухає ніщо: CreativeUpdate має єдине поле
notes, повторний звіт про запуск — no-op (already_launched), а bulk-запуск
пака чіпає лише not_tested картки. Тож in_test поступово означає «колись
запускали», і разом із ним пливуть усі фільтри галереї й колонки експорту,
побудовані на ньому.
Кабінет уже каже і коли тест закінчився, і коли почався знову — креатив перестає або знову починає коштувати грошей. Звідси рівно два ребра:
| Було | Умова | Стало |
|---|---|---|
in_test |
немає spend понад idle_days (3) |
paused |
paused |
є spend у межах останніх resume_days (1) |
in_test |
not_tested і tested не чіпаються в жодному напрямку: перше — картка, яку
не запускали, друге — вердикт людини.
Дні рахуються не від now()
Джерело — 30-денне вікно, яке апстрім перезаливає, а import-DAG дзеркалить
тричі на день; date у ClickHouse відстає від сьогодні на день-два, і на
скільки саме — плаває. Від now() кожен креатив набирав би день простою
щоразу, коли імпорт застряг, а зламаний імпорт зрештою поставив би на паузу всю
бібліотеку. Тому годинник — max(date) того продукту, під яким креатив
крутився: у продукта, чий імпорт став, стає й годинник, і жодна його картка не
рухається, поки дані не підуть знову.
Тому й asset-тригер саме на CREATIVE_METRICS_READY, а не на META_ADS_DAILY
і не на власному кроні: sweep стартує лише після скоркарда, який уже пройшов
власний freshness-preflight, і ніколи — посеред перезаливки вікна, де дірка в
даних читалася б як «перестав витрачати».
Картку, яка не витратила жодного долара, sweep не чіпає: відсутність рядків у ClickHouse — це розрив синку або запуск, який ще не доїхав, а не відповідь кабінету.
Мертва зона між ребрами
resume_days навмисно менший за idle_days: проміжок між ними (2–3 дні на
дефолтах) — зона, де не спрацьовує жодне ребро. Щоб повернутися, картка мусить
крутитися зараз, а не просто мати колись spend усередині вікна паузи. Без
цього креатив із нерівним польотом фліпав би статус щопрогону — тричі на добу, —
а кожен фліп це audit-рядок, зміна кольору картки в галереї і зсув updated_at,
тобто ще й перезалив рядка в ClickHouse через kumquat-ch-sync. Мертва зона
заодно знешкоджує корекції даних: дорисований рядок за старе число нічого не
воскрешає, бо resume міряє свіжість, а не факт появи рядка.
resume_days > idle_days под відхиляє з ValueError: вище idle_days правила
перетинаються, і той самий креатив був би одночасно кандидатом на паузу і на
повернення.
Відкотити можна лише власний запис
Картка повертається в in_test тільки якщо найсвіжіший audit-рядок, який
змінював її test_status, — це рядок sweep-а (екшен
creative.test_status_swept, константа SWEEP_TEST_STATUS_ACTION). Запит —
AuditLogsRepository.last_action_touching, один агрегат на весь шорт-ліст.
Сьогодні іншого джерела паузи не існує взагалі, тож перевірка ні на що не
впливає. Вона є заради дня, коли test_status додадуть у CreativeUpdate:
ручний запис залишить рядок з іншим екшеном, і повернення для таких карток
вимкнеться саме собою — ніхто не мусить пам’ятати про це правило. Маркер на
документі (як __search_text) дав би зворотне: кожен новий writer був би
зобов’язаний його чистити.
Ідемпотентність
retries: 1 у default_args у всіх шести DAG-ах — це працює тільки тому,
що кожен таск ідемпотентний, і в кожного це зроблено по-своєму:
- import — delete + re-insert того самого 30-денного вікна, що й перезаливає
апстрім.
SETTINGS insert_deduplicate = 0тут обов’язковий: без нього повторнийINSERTідентичних даних післяDELETE(retry або незмінений source) мовчки відкидається як дублікат блока, і вікно лишається порожнім. - ch-sync — цільова таблиця
ReplicatedReplacingMergeTree, тож дублікати вікна перекриття й ретраї схлопуються самі; пропущені години не потребують нічого окремого. - creative-metrics —
$setнаcreatives.metricsплюс sweep застарілих документів заrun_id. - retention-purge — зовнішні ресурси знищуються до Mongo-документа, тож
упалий ран лишає запис soft-deleted, і наступний переробляє все з нуля. Саме
цей порядок робить
retriesбезпечним на деструктивній джобі.max_active_runs=1теж не косметика: два одночасні пурджі порахували б набір «вижилих» кожен без знання про видалення іншого. - archive-done-tasks — заархівована задача більше не підпадає під селектор,
тож повторний ран не має що робити. Кожна задача рухається тим самим
TasksService.transition_status, що й з борду, аexpected=doneозначає, що задачу, яку хтось встиг перевести самотужки, ран пропускає, а не переписує. - sync-test-status — картка, яку ран уже перевів, більше не підпадає під
те саме ребро (мертва зона не дає їй одразу підпасти під протилежне). Обидва
записи йдуть через
CreativesRepository.sweep_test_status, guard якого вимагає, щоб картка все ще була в очікуваному статусі: статус, який людина встигла поставити сама, ран звітує, а не переписує. Окремий audit-екшенcreative.test_status_sweptлишає в історії слід, що картку перевів sweep, — і він же є тим, що дозволяє ранові відкотити власну паузу.
ClickHouse: що DAG-и мусять враховувати
ClickHouse — мультиреплічний кластер ml (один шард), і Service балансує
кожне з’єднання на випадкову репліку. Для DAG-ів із цього випливають дві
речі, які видно в коді import-DAG-а:
ALTER TABLE ... DELETEіде зSETTINGS mutations_sync = 2— інакше мутація чекає лише на репліку, що обслуговує це з’єднання.DELETEі наступнийINSERTвиконуються одним викликомclickhouse-client --multiquery. Два окремі виклики — два з’єднання, і порядок операцій між репліками більше нічим не гарантований.
Повний перелік обмежень кластера (DDL ON CLUSTER 'ml',
ReplicatedMergeTree, GRANT READ ON MYSQL для mysql()) — у CLAUDE.md в
корені репозиторію.
Секрети
Усі поди беруть оточення з одного k8s-секрета через env_from:
APP_SECRETS = [
k8s.V1EnvFromSource(secret_ref=k8s.V1SecretEnvSource(name="kumquat-analytics-secrets")),
]
Сам секрет синкається External Secrets з AWS Secrets Manager
(ml/prod/kumquat/airflow/analytics-secrets) — манифест k8s/external-secrets.yaml,
застосовується як kubectl apply -n airflow -f external-secrets.yaml.
У секреті два написання тих самих значень, і це навмисно. Історично там
плоскі ключі (CH_HOST, MONGO_URI) — їх читають ch-sync і import-DAG.
kumquat-creative-metrics читає загальнодеплойний контракт із подвійним
підкреслювачем (CLICKHOUSE__HOST, MONGO__URI), бо env_nested_delimiter
мапить його на вкладену секцію налаштувань. Обидві групи вказують на ті самі
властивості в AWS.
Одна пастка, яку легко «виправити» й зламати под: CLICKHOUSE__PORT у секреті
свідомо відсутній. Ключ CH_PORT — це нативний порт (9000) для
clickhouse-client, а kumquat-creative-metrics говорить HTTP і має працювати
на дефолті 8123. Замапити CH_PORT на CLICKHOUSE__PORT — найкоротший шлях
покласти скоркард.
web-funnel-analytics import-solid-funnels (пакет web-funnel-analytics,
свої налаштування, не AppSettings) читає ClickHouse зі спільних
CLICKHOUSE__*, а MySQL — з WEB_FUNNEL_ANALYTICS__ANALYTICS_MYSQL__* (продуктові бази,
ті самі AWS-властивості, що й плоскі MYSQL_*) і
WEB_FUNNEL_ANALYTICS__FUNNEL_MYSQL__* (сервер funnel_prod, свої хост і юзер;
властивості FUNNEL_MYSQL_HOST/PORT/USER/PASSWORD в AWS). Властивість, якої немає в AWS,
валить синк усього секрета, тож спершу AWS, потім kubectl apply.
Конвенції подів
Кожен KubernetesPodOperator у цих DAG-ах ставить однаковий набір:
in_cluster=True, namespace="airflow", startup_timeout_seconds=600,
get_logs=True, on_finish_action="delete_succeeded_pod".
delete_succeeded_pod, а неdelete_podчиkeep_pod: успішні поди прибираються, упалі лишаються для дебагу. Прибрати їх —make clean-failed-pods.image_pull_policyзалежить від тегу.creative_metricsіде зAlways, бо тегbackend_latestмутабельний (CI перепубліковує його) і закешований шар тихо виконував би вчорашні визначення метрик. Там, де образ зовнішній і версійований (clickhouse-server:24.8), достатньоIfNotPresent.start_date— фіксованийpendulum.datetime(..., tz="UTC"),catchup=False,max_active_runs=1усюди.- DAG-файл не робить нічого важкого на рівні модуля: планувальник перечитує його постійно.
Дебаг
python dags/dag_*.pyвиконуєdag.test()— таски виконуються в процесі, а не в подах. Це перевіряє структуру DAG-а й Python-логіку тасків, і не перевіряє ні образи, ні секрети, ні неймспейс.- Под у
Pending→startup_timeout_seconds, місткість нод, або pull з ECR. - Таск упав без логів → скоріш за все OOM;
kubectl describe pod -n airflow(упалі поди лишаються). - Змінної немає в контейнері → перевір, чи вона взагалі є в
kumquat-analytics-secrets, і чи не переплутане одне підкреслювання з двома. - Скоркард тихо не оновлюється → asset-подія. Порівняй URI в
dags/assets.pyз тим, що видно в UI на вкладці Assets.
Відомі межі
- Тестів на DAG-и немає. Airflow не входить у локальне середовище
(
backend/.venvйого не має), тож ні імпорт файлів, ні рендер шаблонів у CI не перевіряються. Помилка в Jinja-виразі вилазить у продакшені як упалий под. - Як файли DAG-ів попадають у кластер — поза цим репозиторієм, як і збірка
образів: воркфлоу живе в спільних
plantin-org/ml-gha-workflows. Через це константиIMAGEрозписані непослідовно —creative_metricsтримає повний ECR-шлях, аch-syncіretentionдосі мають заглушку:latest # set by CI, яку підставляє деплой. container_resourcesне задані ніде. Поки поди легкі, це прийнятно; важкий таск без реквестів планувальник не зможе нормально розкласти й може бути OOM-killed без слідів.media_sourcesє лише в скоркарді. Для import-DAG-а скоуп зашитий у константуAPPS; додати продукт — це зміна коду.