Зачем: после редизайна пути менти курс ссылался на старый путь generated-history-analytics/backfill и не проходился по новому стенду. Что: в README курса — единый блок подготовки и канонического сброса (make clean -> make up + ddl_init/world_init -> make superset-init), таблица уроков дополнена лабами 07–08 («в работе»); LESSON_STANDARD и уроки 0–6 ссылаются на канонический блок; урок 1 переведён на дозаливку через world_next_day (кнопкой-анонсом, цена в минутах названа); урок 4 — лесенка DAG-ов; урок 5 — словарь «база import / живой поток»; урок 6 обязателен; цифры старого мира помечены маркером «сверить-на-стенде». Проверка: grep по generated-history-analytics/backfill в docs/course/ пуст; правки только в docs/course/; git diff --check чистый. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
386 lines
29 KiB
Markdown
386 lines
29 KiB
Markdown
# Урок 3. ODS → DDS: сборка сущностей
|
||
|
||
> Формат: **практика** — будешь сам запускать команды и менять код, не только читать.
|
||
> Пререквизит: пройден урок 2 (слой ODS — данные типизированы и разложены по качеству:
|
||
> чистые строки в `ods.*`, битые — в `ods.*_errors`).
|
||
> Эталонный путь: [`sql/dds/30_ods_to_dds.sql`](../../../sql/dds/30_ods_to_dds.sql)
|
||
> и DDL целевых таблиц [`sql/ddl/dds/30_dds.sql`](../../../sql/ddl/dds/30_dds.sql).
|
||
>
|
||
> Поток данных одной строкой:
|
||
> `ods.device_by_click + ods.geo_by_click → dds.click`, `ods.browser_event + ods.location_event → dds.event`
|
||
>
|
||
> О чём урок простыми словами: в ODS один клик размазан по двум таблицам (отдельно устройство,
|
||
> отдельно гео), а событие — по двум другим. Здесь мы склеиваем эти кусочки в цельные карточки:
|
||
> карточку клика и карточку события. И встречаем первую проблему стыковки — событие, у которого
|
||
> потерялся свой клик.
|
||
|
||
---
|
||
|
||
## 1. Зачем и где в проде
|
||
|
||
В ODS у нас аккуратные, типизированные данные — но они всё ещё лежат **по кусочкам**. Про один
|
||
и тот же клик стенд знает две отдельные вещи и хранит их в двух разных таблицах: с какого
|
||
устройства был клик (`ods.device_by_click`) и из какой точки на карте (`ods.geo_by_click`). Про
|
||
событие — то же самое: что за событие и в каком браузере (`ods.browser_event`) и на какой
|
||
странице с какими UTM-метками (`ods.location_event`).
|
||
|
||
Для аналитики так работать неудобно. Стоит задать простой вопрос — «сколько кликов с мобильных
|
||
устройств пришло из России» — и аналитику приходится каждый раз вручную сшивать две-четыре
|
||
таблицы. Это долго писать и легко ошибиться.
|
||
|
||
Поэтому появляется следующий слой — **DDS**. Это сокращение от Detailed Data Store («подробное
|
||
хранилище»): слой, где разрозненные кусочки собраны в цельные карточки, готовые к анализу. Такую
|
||
цельную карточку мы дальше называем **сущностью** — это просто запись, которая описывает один
|
||
объект целиком, со всеми его признаками сразу. В нашем стенде две сущности:
|
||
|
||
- **`dds.click`** — карточка клика: один клик и сразу всё про него — устройство, операционная
|
||
система, страна, координаты, IP. Один клик — одна строка;
|
||
- **`dds.event`** — карточка события: одно событие и всё про него — тип, время, браузер,
|
||
страница, UTM-метки. Одно событие — одна строка. Внутри карточки лежит `click_id` — ссылка на
|
||
тот клик, в рамках которого событие произошло.
|
||
|
||
Идея слоя: собрать один раз — пользоваться много раз. После сборки аналитику не нужно ничего
|
||
сшивать вручную: он берёт готовую `dds.click` или `dds.event` и сразу считает.
|
||
|
||
### Откуда берётся проблема целостности
|
||
|
||
Как только мы начинаем **склеивать** таблицы, появляется вопрос, которого на прошлых слоях не
|
||
было: а что, если стыкуемые кусочки не сходятся? Событие говорит «я случилось в рамках клика
|
||
`X`», мы идём искать клик `X` в `dds.click` — а его там нет. Ни устройства, ни гео по этому
|
||
клику стенд не получил.
|
||
|
||
Такое событие — **без своего клика** — называют **сиротой** (по-английски orphan, «осиротевшее»).
|
||
В этом уроке мы вводим само понятие и учимся сирот **считать**. А ловить их жёстко — останавливать
|
||
пайплайн, если сироты появились, — будем в уроке 4. Сейчас задача проще: понять, откуда они
|
||
берутся и как их увидеть.
|
||
|
||
> **Откуда сироты берутся в проде.** Чаще всего — из-за того, что данные приходят **не
|
||
> одновременно**. Событие в браузере произошло и улетело в Kafka сразу, а контекст клика
|
||
> (устройство, гео) досчитался и доехал на секунды позже. Если собрать `dds.click` именно в этот
|
||
> момент — клика ещё нет, и событие на мгновение осиротело. Бывает и совсем потеря: контекст
|
||
> клика не доехал вообще. Поэтому целостность между событием и кликом — то, за чем на этом слое
|
||
> следят отдельно.
|
||
|
||
---
|
||
|
||
## 2. Руки: смотрим базовый прогон
|
||
|
||
Подготовь стенд по
|
||
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
|
||
После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
|
||
|
||
Ниже — форма блока **«Статистика DDS»**, который печатает `make transform`: счётчики
|
||
строк по двум нашим сущностям.
|
||
|
||
```
|
||
Статистика DDS:
|
||
┌─table─────┬─rows─┐
|
||
│ dds.click │ ... │
|
||
│ dds.event │ ... │
|
||
└───────────┴──────┘
|
||
```
|
||
|
||
Прочитаем эти две строки.
|
||
|
||
**`dds.event` — события.** Сколько событий пришло с валидным ключом, столько карточек и
|
||
собралось: одно событие — одна строка. Ровно как `ods.browser_event` из прошлого урока.
|
||
|
||
**`dds.click` обычно меньше, чем `dds.event`.** И это та же история, что мы уже разбирали
|
||
в уроке 2. Карточка клика — одна на клик, а событий на один клик может быть несколько.
|
||
Поэтому много событий ссылаются на меньшее число кликов — это нормально, так и должно быть.
|
||
|
||
Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»**
|
||
(это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку
|
||
про сирот:
|
||
|
||
```
|
||
┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐
|
||
│ 2026-06-05 │ dds │ event_without_click │ orphan_events │ 0 │
|
||
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
|
||
```
|
||
<!-- сверить-на-стенде -->
|
||
|
||
В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя
|
||
дата — не пугайся, если она не совпадёт с примером.
|
||
|
||
`orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На
|
||
чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции
|
||
4 мы сироту устроим сами — и эта строка оживёт.
|
||
<!-- сверить-на-стенде -->
|
||
|
||
Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
|
||
(пользователь `default`, пароль `123456`) и посчитай сирот напрямую:
|
||
|
||
```sql
|
||
-- Сирота = событие, у которого click_id есть, но в dds.click такого клика нет
|
||
SELECT count() AS orphans
|
||
FROM dds.event
|
||
WHERE click_id IS NOT NULL
|
||
AND click_id NOT IN (SELECT click_id FROM dds.click);
|
||
```
|
||
|
||
`NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех
|
||
click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет.
|
||
Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число.
|
||
<!-- сверить-на-стенде -->
|
||
|
||
---
|
||
|
||
## 3. Загляни внутрь
|
||
|
||
Слой, как и ODS, описан **двумя файлами** — держи оба открытыми, они про разное:
|
||
|
||
| Файл | Что задаёт |
|
||
|------|------------|
|
||
| `sql/ddl/dds/30_dds.sql` | **форму** сущностей: какие колонки, какие типы, какой движок |
|
||
| `sql/dds/30_ods_to_dds.sql` | **сборку**: как из таблиц ODS склеить эти сущности |
|
||
|
||
Дальше — четыре места, ради которых урок и затевался. Пойдём по сборке `dds.click` сверху вниз:
|
||
сначала собираем список всех кликов, потом приклеиваем к каждому устройство и гео.
|
||
|
||
### Универсум кликов: собрать все `click_id`
|
||
|
||
Сборка начинается с вопроса «а какие клики у нас вообще есть?». Источников два — `device` и
|
||
`geo`, и клик может быть в любом из них (а то и в обоих). Нам нужен полный список без повторов:
|
||
|
||
```sql
|
||
SELECT click_id FROM ods.device_by_click ...
|
||
UNION DISTINCT
|
||
SELECT click_id FROM ods.geo_by_click ...
|
||
```
|
||
|
||
`UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор
|
||
уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный
|
||
список всех кликов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы
|
||
строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал
|
||
в `device`.
|
||
|
||
> На чистой стартовой истории `device` и `geo` должны содержать один и тот же набор кликов,
|
||
> так что универсум совпадает с обоими источниками. Но код написан так, чтобы пережить случай,
|
||
> когда наборы **разойдутся**, — и это правильно: в проде они расходятся постоянно.
|
||
|
||
### `argMax`: одна строка на клик, самая свежая
|
||
|
||
Собрав список кликов, к каждому надо приклеить данные об устройстве. Тут есть тонкость из урока 2:
|
||
`ods.device_by_click` — таблица на движке `ReplacingMergeTree`, и повторы по `click_id` она
|
||
схлопывает **в фоне**, не мгновенно. Значит, прямо сейчас в ней может лежать несколько строк про
|
||
один клик. Какую брать?
|
||
|
||
Берём самую свежую — и делаем это явно, через `argMax`:
|
||
|
||
```sql
|
||
argMax(device_type, src_ingest_ts) AS device_type,
|
||
argMax(os_name, src_ingest_ts) AS os_name
|
||
...
|
||
GROUP BY click_id
|
||
```
|
||
|
||
`argMax(A, B)` читается так: «верни значение `A` из той строки, где `B` максимально». Здесь
|
||
`B` — это `src_ingest_ts`, время загрузки в ODS. То есть для каждого `click_id` берём `device_type`
|
||
из самой поздней загрузки. А `GROUP BY click_id` гарантирует, что на выходе **ровно одна строка
|
||
на клик** — прямо сейчас, не дожидаясь, пока `ReplacingMergeTree` схлопнет повторы у себя в фоне.
|
||
|
||
> **Зачем так строго.** `ReplacingMergeTree` обещает оставить одну строку на ключ, но не обещает,
|
||
> *когда* (фоновое схлопывание может ещё не случиться). Если бы мы просто прочитали таблицу, то
|
||
> могли бы поймать дубль. `argMax` + `GROUP BY` убирают эту неопределённость на чтении: одна
|
||
> свежая строка на клик, всегда.
|
||
|
||
### `LEFT JOIN`: приклеиваем устройство и гео
|
||
|
||
Теперь главная операция сборки — соединить список кликов с данными об устройстве и гео. Это
|
||
делает **`JOIN`** — операция «состыкуй строки двух таблиц по общему ключу». Ключ у нас `click_id`:
|
||
для каждого клика из универсума ищем строку с тем же `click_id` среди устройств и среди гео.
|
||
|
||
```sql
|
||
FROM ( ...универсум кликов... ) AS c
|
||
LEFT JOIN ( ...снапшот device... ) AS d ON d.click_id = c.click_id
|
||
LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id
|
||
```
|
||
|
||
Важно, что это именно `LEFT JOIN`, а не обычный `JOIN`. Разница — в том, что делать, когда пары
|
||
**не нашлось**:
|
||
|
||
- обычный (`INNER`) `JOIN` выкинул бы клик, у которого нет, скажем, гео, — нет пары, нет строки;
|
||
- **`LEFT JOIN`** оставляет **все** строки левой таблицы (нашего универсума кликов) в любом
|
||
случае. Если для клика не нашлось гео — клик всё равно в результате, просто гео-поля у него
|
||
останутся пустыми (`NULL`).
|
||
|
||
Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять
|
||
нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся
|
||
пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику
|
||
не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только
|
||
теперь не про кривое поле, а про пропавшую связь между таблицами.
|
||
|
||
> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location`
|
||
> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу
|
||
> и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать
|
||
> этот блок построчно не будем — он повторяет ту же логику.
|
||
|
||
### Сироты: событие без клика
|
||
|
||
Мы собрали `dds.click` (карточки кликов) и `dds.event` (карточки событий). Внутри каждого
|
||
события лежит `click_id` — ссылка на клик. И вот тут возникает вопрос целостности из секции 1:
|
||
**а на каждую ли ссылку есть карточка клика?**
|
||
|
||
Событие, чей `click_id` не находит себе клика в `dds.click`, — это и есть **сирота**. Считается
|
||
он ровно тем запросом, что ты уже видел в секции 2:
|
||
|
||
```sql
|
||
SELECT count() FROM dds.event
|
||
WHERE click_id IS NOT NULL
|
||
AND click_id NOT IN (SELECT click_id FROM dds.click);
|
||
```
|
||
|
||
Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой
|
||
контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события**
|
||
нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по
|
||
пустым полям внутри карточки, вторую — отдельным счётчиком. На чистой стартовой истории сирот
|
||
ноль — сейчас мы это изменим.
|
||
|
||
---
|
||
|
||
## 4. Управляемая правка: заведём сироту
|
||
|
||
Сирота на чистой стартовой истории не появится сама — данные слишком аккуратные. Поэтому
|
||
**создадим её руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого
|
||
в `dds.click` нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`.
|
||
|
||
Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие:
|
||
|
||
```sql
|
||
-- Событие со ссылкой на несуществующий клик dddd...-dddd (такого в dds.click нет)
|
||
INSERT INTO dds.event (event_id, event_ts, event_type, click_id, browser_name, dds_update_ts, ods_parse_errors)
|
||
VALUES (
|
||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- event_id нашего «события-сироты»
|
||
now64(6), 'pageview',
|
||
'dddddddd-dddd-dddd-dddd-dddddddddddd', -- click_id, которого нет ни в одной карточке клика
|
||
'DemoBrowser', now64(3), []
|
||
);
|
||
```
|
||
|
||
Теперь посчитай сирот тем же запросом, что в секции 2:
|
||
|
||
```sql
|
||
SELECT count() AS orphans
|
||
FROM dds.event
|
||
WHERE click_id IS NOT NULL
|
||
AND click_id NOT IN (SELECT click_id FROM dds.click);
|
||
```
|
||
|
||
Было `0` — стало `1`. Появилась первая сирота: событие `aaaa…` ссылается на клик `dddd…`,
|
||
которого в `dds.click` нет.
|
||
|
||
Теперь посмотри, что с этим событием делает `LEFT JOIN`. В стенде есть витрина `dm.v_events_enriched` —
|
||
это `VIEW` (готовый запрос под именем), который как раз приклеивает к каждому событию его клик
|
||
через `LEFT JOIN`. Посмотрим на нашу сироту через неё:
|
||
|
||
```sql
|
||
SELECT event_id, click_id, device_type, geo_country
|
||
FROM dm.v_events_enriched
|
||
WHERE event_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||
```
|
||
|
||
```
|
||
┌─event_id─────┬─click_id─────┬─device_type─┬─geo_country─┐
|
||
│ aaaaaaaa-... │ dddddddd-... │ ᴺᵁᴸᴸ │ ᴺᵁᴸᴸ │
|
||
└──────────────┴──────────────┴─────────────┴─────────────┘
|
||
```
|
||
|
||
Вот он, `LEFT JOIN` вживую. Событие осталось в результате (его не выкинуло), но клика-то нет —
|
||
и все поля из клика (`device_type`, `geo_country` и остальные) пришли пустыми. Будь это `INNER
|
||
JOIN`, событие просто исчезло бы из витрины, и мы бы даже не заметили, что потеряли его. `LEFT
|
||
JOIN` его сохранил — поэтому сироту вообще можно увидеть и посчитать.
|
||
|
||
> **Почему мы её только считаем, а не блокируем.** Логично было бы сказать: раз сирота — это
|
||
> разрыв целостности, давай не пустим её дальше, уроним прогон. Так и сделаем — но в уроке 4.
|
||
> Там запрос-счётчик из этого урока станет **жёстким гейтом** в Airflow: DAG покраснеет, если
|
||
> `orphan_events > 0`. Сейчас мы только научились сирот видеть; превратить взгляд в стоп-кран —
|
||
> следующий шаг.
|
||
|
||
**Верни как было.** Наша сирота лежит прямо в `dds.event`, мимо ODS. Достаточно пересобрать слой
|
||
DDS — `make transform` чистит `dds.event` (`TRUNCATE`) и наполняет заново из ODS, где никакой
|
||
сироты нет:
|
||
|
||
```bash
|
||
make transform
|
||
```
|
||
|
||
После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд
|
||
совсем «поплыл», пройди
|
||
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
|
||
<!-- сверить-на-стенде -->
|
||
|
||
---
|
||
|
||
## 5. Проверь себя
|
||
|
||
| Действие | Где смотреть | Что ожидать |
|
||
|----------|--------------|-------------|
|
||
| базовый прогон | блок «Статистика DDS» | `dds.click` и `dds.event` не пустые |
|
||
| `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` |
|
||
| почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма |
|
||
| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` |
|
||
| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` |
|
||
<!-- сверить-на-стенде -->
|
||
|
||
---
|
||
|
||
## 6. Что должно получиться
|
||
|
||
После урока у тебя на руках — видимый результат (одно на выбор):
|
||
|
||
- скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`;
|
||
- либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country`
|
||
пусты, — `LEFT JOIN` сохранил событие без клика.
|
||
<!-- сверить-на-стенде -->
|
||
|
||
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
|
||
|
||
- что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS;
|
||
- почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео;
|
||
- что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки
|
||
клика.
|
||
|
||
Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый
|
||
`click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку.
|
||
|
||
---
|
||
|
||
## Вся цепочка разом: STG → ODS → DDS
|
||
|
||
Мы прошли три слоя по отдельности — стоит собрать их в одну картину, чтобы они не остались тремя
|
||
не связанными кусками. Один и тот же клик прошёл весь путь:
|
||
|
||
- **STG** (урок 1) — приняли поток как есть: сырой JSON строкой плюс метаданные доставки из
|
||
Kafka. Ничего не разбираем, ничего не теряем;
|
||
- **ODS** (урок 2) — разобрали JSON на поля и типизировали; чистое поехало в `ods.*`, битое — в
|
||
`ods.*_errors` (это и есть DQ-split). Здесь же один клик честно лёг в две таблицы: устройство
|
||
отдельно, гео отдельно;
|
||
- **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые
|
||
спросили про целостность связей между ними (сироты).
|
||
|
||
Заметь общий принцип всех трёх слоёв — **«не теряем, а оставляем след»**. На STG не роняем приём
|
||
из-за кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в
|
||
`*_errors`. На DDS не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика
|
||
(считаем такие сироты через `orphan_events`). Один и тот же подход к качеству, проведённый через
|
||
весь пайплайн.
|
||
|
||
> **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые
|
||
> `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они
|
||
> сделаны как **`VIEW`** — это просто сохранённый под именем запрос поверх DDS, без копии данных:
|
||
> логику меняешь — данные не перегружаешь. Если тяжёлая агрегация однажды начнёт тормозить, её
|
||
> можно **материализовать** — превратить `VIEW` в обычную таблицу (как это делается — в
|
||
> `docs/ARCHITECTURE.md`, раздел «Материализация витрин»). Сами витрины в деле разберём в уроках
|
||
> 5–6, где их потребляют мониторинг и Superset.
|
||
|
||
---
|
||
|
||
## Мост к уроку 4
|
||
|
||
Сущности собраны, целостность мы умеем **видеть** — но пока только глазами: запустили запрос,
|
||
посмотрели на число сирот. В проде так не следят: проверка должна срабатывать сама на каждом
|
||
прогоне и **останавливать** пайплайн, если целостность нарушена. В уроке 4 (оркестрация в Airflow)
|
||
мы соберём всю цепочку STG → ODS → DDS → DM в один DAG с зависимостями между шагами — и превратим
|
||
наш запрос-счётчик сирот в честный **гейт**: если `orphan_events > 0`, DAG падает и дальше данные
|
||
не идут. Там же снова заведём сироту — и увидим, как на неё краснеет конкретная задача в Airflow.
|