Anti-Join dedup (left_anti): загрузка инкремента без дублей - паттерн для batch ETL

Anti-Join dedup (left_anti): загрузка инкремента без дублей - паттерн для batch ETL

streaming

Когда данные поступают порциями (батчами), неизбежно возникает вопрос: как убедиться, что в целевую таблицу попадут только новые записи, а не всё подряд? Наивный APPEND безо всяких проверок - это рецепт катастрофы: повторный запуск пайплайна, перекрытие временных окон двух батчей, network retry при записи - и данные задваиваются.

Паттерн LEFT ANTI JOIN отвечает на этот вопрос элегантно: «из входящего батча возьми только те строки, которых ещё нет в целевой таблице». Это не тяжёлый MERGE со сканом всей истории, не Full Overwrite с полной перезаписью - это хирургически точный фильтр на этапе перед записью.


1. Архитектурная проблема: почему наивный APPEND создаёт дубли

1.1 Реальные сценарии появления дублей при инкрементальной загрузке

Batch ETL pipeline, работающий каждый час, сталкивается с дублями по нескольким причинам:

Перекрытие временных окон. Пайплайн читает события за «последние 2 часа» для надёжности (чтобы не пропустить опоздавшие события). Если он запускается каждый час, каждый батч содержит один час пересечения с предыдущим:

Батч 1: события 10:00 - 12:00
Батч 2: события 11:00 - 13:00   ← час пересечения 11:00-12:00

Без дедупликации события 11:00–12:00 записываются в таблицу дважды.

Airflow retry. Задача упала в конце и была перезапущена Airflow. Источник повторно отдаёт те же данные. Если pipeline просто делает APPEND - все строки дублируются.

CDC lag. Debezium прочитал WAL от LSN=1000, записал события в Kafka. Затем произошёл рестарт коннектора - он начинает с checkpoint LSN=800, перечитывает события 800–1000 повторно.

Late-arriving records. Внешний API прислал события с event_ts=10:00:00, когда уже запускается батч для окна 11:00–12:00. При следующем перезапуске эти события попадут в батч снова.

1.2 Почему MERGE не всегда оптимален

MERGE INTO (UPSERT) - мощный инструмент, но у него есть специфика:

  • Стоимость на Iceberg Copy-on-Write: MERGE перечитывает все data files, содержащие затронутые строки, и создаёт новые версии. Для таблицы в 10 ТБ даже вставка 1000 новых строк может требовать перечтения гигабайт.
  • Избыточность при insert-only workloads: если мы уверены, что обновлять существующие строки не нужно (только вставлять новые), MERGE избыточен - он проверяет каждую входящую строку на совпадение с существующими.
  • Не работает на plain Parquet: без Iceberg или Delta Lake MERGE недоступен вообще. Anti-join работает с любым форматом.

1.3 Суть паттерна Left Anti Join

Диаграмма показывает суть: из 100 входящих событий 85 уже присутствуют в целевой таблице (их event_id совпадает). LEFT ANTI JOIN оставляет только 15 - те, которых ещё нет. Именно они записываются через APPEND. В таблице не появляется ни одного дубля.


2. Механика LEFT ANTI JOIN в Spark

2.1 Что возвращает LEFT ANTI JOIN

LEFT ANTI JOIN возвращает строки из левой таблицы, для которых НЕ НАЙДЕНО совпадений в правой. Это дополнение к LEFT SEMI JOIN (который возвращает строки, для которых совпадение НАЙДЕНО):

  • LEFT ANTI JOIN: evt-002 и evt-004 - они в инкременте, но не в target → НОВЫЕ, нужно вставить
  • LEFT SEMI JOIN: evt-001 и evt-003 - они есть в обоих → УЖЕ ЗАГРУЖЕНЫ
  • evt-099 из target никогда не попадает в результат (он только в правой таблице)

2.2 Сравнение с альтернативами в SQL

-- LEFT ANTI JOIN (PySpark/Spark SQL)
SELECT inc.*
FROM incoming_events inc
LEFT ANTI JOIN target_events tgt
    ON inc.event_id = tgt.event_id;

-- Эквивалент через NOT EXISTS
SELECT inc.*
FROM incoming_events inc
WHERE NOT EXISTS (
    SELECT 1 FROM target_events tgt
    WHERE tgt.event_id = inc.event_id
);

-- Эквивалент через LEFT JOIN + IS NULL
SELECT inc.*
FROM incoming_events inc
LEFT JOIN target_events tgt
    ON inc.event_id = tgt.event_id
WHERE tgt.event_id IS NULL;

-- ПЛОХО: NOT IN с подзапросом
SELECT inc.*
FROM incoming_events inc
WHERE inc.event_id NOT IN (SELECT event_id FROM target_events);
-- Проблема: если в target есть NULL event_id - результат пустой!
-- SQL: "x NOT IN (NULL, ...)" всегда FALSE при наличии NULL в списке

Все четыре запроса возвращают одинаковый результат (при отсутствии NULL в ключах). Но Spark оптимизирует их по-разному.

2.3 Физический план: почему LEFT ANTI быстрее NOT IN

from pyspark.sql import SparkSession, functions as F
from pyspark.sql.window import Window

spark = SparkSession.builder \
    .appName("AntiJoin-Deep-Dive") \
    .config("spark.sql.extensions",
            "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
    .config("spark.sql.catalog.lakehouse", "org.apache.iceberg.spark.SparkCatalog") \
    .config("spark.sql.catalog.lakehouse.type", "hadoop") \
    .config("spark.sql.catalog.lakehouse.warehouse", "/tmp/warehouse") \
    .getOrCreate()

# Тестовые данные
incoming = spark.createDataFrame([
    ("evt-001", "user_1", "click"),
    ("evt-002", "user_2", "buy"),
    ("evt-003", "user_1", "view"),
], ["event_id", "user_id", "event_type"])

target = spark.createDataFrame([
    ("evt-001", "user_1", "click"),
    ("evt-003", "user_1", "view"),
], ["event_id", "user_id", "event_type"])

# Способ 1: left_anti (рекомендуемый)
anti_join = incoming.join(target, on="event_id", how="left_anti")
print("=== left_anti EXPLAIN ===")
anti_join.explain(mode="formatted")

# Способ 2: NOT EXISTS (эквивалент)
incoming.createOrReplaceTempView("incoming")
target.createOrReplaceTempView("target")
not_exists = spark.sql("""
    SELECT * FROM incoming i
    WHERE NOT EXISTS (SELECT 1 FROM target t WHERE t.event_id = i.event_id)
""")
print("=== NOT EXISTS EXPLAIN ===")
not_exists.explain(mode="formatted")

Физический план left_anti:

== Physical Plan ==
* BroadcastHashJoin [event_id], [event_id], LeftAnti   ← Anti семантика на уровне join
  :- * Scan: incoming
  +- BroadcastExchange                                  ← target broadcast в память
     +- * Scan: target

Spark оптимизирует left_anti как специализированный join: он строит hash-таблицу из правой части (target), затем для каждой строки из левой части (incoming) ищет совпадение в хэш-таблице. Если совпадение найдено - строка отбрасывается. Если не найдено - строка сохраняется.

Критически важно: Spark не генерирует NULL-колонки из правой таблицы (в отличие от LEFT JOIN + WHERE IS NULL). Это экономит память и CPU при больших правых таблицах.

NOT IN с подзапросом опасен при наличии NULL: в SQL x NOT IN (1, 2, NULL) всегда возвращает FALSE, потому что x != NULL неизвестно. Если в target есть хотя бы одна строка с event_id = NULL, весь запрос вернёт пустой DataFrame!


3. Пошаговая реализация: canonical anti-join ETL

3.1 Шаг 1 - Внутренняя дедупликация инкремента

Перед anti-join инкремент сам может содержать дубли (Kafka at-least-once). Если мы пропустим этот шаг, anti-join вернёт дубли из инкремента:

# Имитируем входящий батч с дублями внутри
raw_increment = spark.createDataFrame([
    ("evt-010", "order_2001", "created",  1500.0, "2026-05-23 10:00:00"),
    ("evt-010", "order_2001", "created",  1500.0, "2026-05-23 10:00:00"),  # дубль!
    ("evt-011", "order_2002", "pending",   750.0, "2026-05-23 10:05:00"),
    ("evt-012", "order_2003", "confirmed", 200.0, "2026-05-23 10:10:00"),
    # evt-001 уже есть в target - anti-join должен его отсечь
    ("evt-001", "order_1001", "confirmed",1500.0, "2026-05-23 09:00:00"),
], ["event_id", "order_id", "status", "amount", "event_ts"])

# ШАГ 1: Дедупликация самого инкремента
# Без этого evt-010 дважды попадёт в anti-join и оба пройдут фильтр
clean_increment = raw_increment.dropDuplicates(["event_id"])

print(f"Инкремент до dedup: {raw_increment.count()} строк")
print(f"Инкремент после dedup: {clean_increment.count()} строк")
# До: 5, после: 4 (убрали дубль evt-010)

Почему это важно: если инкремент содержит два evt-010 и оба отсутствуют в target, anti-join пропустит оба - в таблице появится дубль. Сначала дедуплицируем инкремент, потом делаем anti-join с target.

3.2 Шаг 2 - Чтение целевой таблицы с оптимизацией

Если target содержит данные за 3 года (100 ТБ), читать всё для anti-join слишком дорого. Используем скользящее окно - читаем только данные за разумный период:

# Создаём target таблицу (имитация существующей Silver)
spark.sql("""
    CREATE TABLE IF NOT EXISTS lakehouse.silver.order_events (
        event_id   STRING,
        order_id   STRING,
        status     STRING,
        amount     DECIMAL(10,2),
        event_ts   TIMESTAMP,
        event_date DATE
    )
    USING iceberg
    PARTITIONED BY (event_date)
""")

# Имитируем существующие данные в Silver
existing_data = spark.createDataFrame([
    ("evt-001", "order_1001", "confirmed", 1500.0, "2026-05-23 09:00:00", "2026-05-23"),
    ("evt-002", "order_1001", "created",   1500.0, "2026-05-22 08:00:00", "2026-05-22"),
], ["event_id", "order_id", "status", "amount", "event_ts", "event_date"])

existing_data \
    .withColumn("event_ts", F.to_timestamp("event_ts")) \
    .withColumn("event_date", F.to_date("event_date")) \
    .withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
    .writeTo("lakehouse.silver.order_events") \
    .append()

# ОПТИМИЗАЦИЯ: читаем target только за последние 7 дней
# Spark применяет Partition Pruning - читает только нужные партиции
LOOKBACK_DAYS = 7

target_window = (
    spark.table("lakehouse.silver.order_events")
    .filter(
        F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS)
    )
    .select("event_id")  # берём только ключ - минимальный объём данных
)

print(f"Строк в target window ({LOOKBACK_DAYS} дней): {target_window.count()}")

Почему select("event_id"): правая часть anti-join нужна только для проверки наличия ключа. Нет смысла читать все поля таблицы - только ключ. Это уменьшает объём данных в правой стороне join-а в разы.

Выбор LOOKBACK_DAYS - компромисс:

  • Слишком мало (1 день): события старше суток пройдут anti-join как «новые» и создадут дубли при late-arriving
  • Слишком много (365 дней): читаем огромный объём данных, join медленный
  • Правило: LOOKBACK_DAYS ≥ максимально возможной задержки late-arriving events в вашей системе

3.3 Шаг 3 - Anti-join и запись результата

# ШАГ 3: Anti-join - оставляем только действительно новые события
new_events = (
    clean_increment                        # левая: дедуплицированный инкремент
    .join(
        target_window,                     # правая: только ключи из target-окна
        on="event_id",
        how="left_anti"                    # строки без совпадения в правой
    )
)

print(f"\nАнти-джойн результат:")
new_events.show()
# event_id | order_id   | status    | amount | event_ts
# evt-010  | order_2001 | created   | 1500.0 | ...
# evt-011  | order_2002 | pending   | 750.0  | ...
# evt-012  | order_2003 | confirmed | 200.0  | ...
# evt-001 отсечён: он уже есть в target

print(f"Новых событий для записи: {new_events.count()}")
# 3 из 4 (evt-001 отсечён anti-join)

# ШАГ 4: Безопасный APPEND только новых строк
(
    new_events
    .withColumn("event_ts",   F.to_timestamp("event_ts"))
    .withColumn("amount",     F.col("amount").cast("decimal(10,2)"))
    .withColumn("event_date", F.to_date("event_ts"))
    .writeTo("lakehouse.silver.order_events")
    .append()
)

print("Данные записаны в Silver через безопасный APPEND")
print(f"Итого в Silver: {spark.table('lakehouse.silver.order_events').count()} строк")
# 5 строк: 2 существующих + 3 новых

3.4 Spark SQL эквивалент

-- Эквивалентный запрос на Spark SQL (более читаемый для SQL-разработчиков)
INSERT INTO lakehouse.silver.order_events
SELECT
    inc.event_id,
    inc.order_id,
    inc.status,
    CAST(inc.amount AS DECIMAL(10,2)),
    CAST(inc.event_ts AS TIMESTAMP),
    TO_DATE(inc.event_ts)  AS event_date
FROM clean_increment_view AS inc
WHERE NOT EXISTS (
    SELECT 1
    FROM lakehouse.silver.order_events AS tgt
    WHERE tgt.event_id   = inc.event_id
      AND tgt.event_date >= CURRENT_DATE() - INTERVAL 7 DAYS
);

NOT EXISTS и left_anti дают Spark одинаковый physical plan через Catalyst Optimizer. Используйте тот синтаксис, который понятнее команде - результат идентичен.


4. Оптимизация производительности

4.1 Broadcast Anti-Join: когда одна сторона мала

По умолчанию Spark выбирает тип join-а (SortMergeJoin или BroadcastHashJoin) автоматически на основе размера таблиц. Но иногда явный hint помогает:

# Случай 2: Broadcast target-ключи (маленькая правая сторона)
# Spark автоматически broadcast-ит таблицы < spark.sql.autoBroadcastJoinThreshold (10MB)
# Но если target_window.count() < 100k строк - лучше указать явно:

new_events_broadcast = (
    clean_increment
    .join(
        F.broadcast(target_window),   # явный broadcast hint
        on="event_id",
        how="left_anti"
    )
)

new_events_broadcast.explain(mode="formatted")
# Physical Plan:
# * BroadcastHashJoin [event_id], [event_id], LeftAnti
#   :- * Scan: clean_increment  (без Shuffle!)
#   +- BroadcastExchange       (target распределяется в память каждого executor)
#      +- * Scan: target_window

# Случай 1: Broadcast инкремент (маленькая левая сторона)
# Не всегда возможен для left_anti: broadcast в left_anti оптимально
# применяется к ПРАВОЙ таблице
# Если инкремент мал - рассмотрите перестановку: target ANTI JOIN increment
small_increment_anti = (
    target_window
    .join(
        F.broadcast(clean_increment.select("event_id")),
        on="event_id",
        how="left_anti"           # НЕ то же самое! Это строки target без совпадений
    )
    # Это anti-join другой направленности - осторожно!
)

4.2 Partition Pruning: фильтр ДО join-а

Самая эффективная оптимизация - читать из target только нужные партиции:

# Неоптимально: Spark читает ВСЕ партиции target
bad_anti = (
    clean_increment
    .join(
        spark.table("lakehouse.silver.order_events"),  # вся таблица!
        on="event_id",
        how="left_anti"
    )
)

# Оптимально: Spark читает только партиции за последние 7 дней
# Static Partition Pruning: фильтр применяется ДО join-а
good_anti = (
    clean_increment
    .join(
        spark.table("lakehouse.silver.order_events")
             .filter(F.col("event_date") >= F.date_sub(F.current_date(), 7)),
        on="event_id",
        how="left_anti"
    )
)

# Spark SQL вариант с явным фильтром в подзапросе
spark.sql("""
    SELECT inc.*
    FROM clean_increment inc
    LEFT ANTI JOIN (
        SELECT event_id
        FROM lakehouse.silver.order_events
        WHERE event_date >= CURRENT_DATE() - INTERVAL 7 DAYS  -- Partition Pruning!
    ) tgt
    ON inc.event_id = tgt.event_id
""")

4.3 SortMerge Anti-Join для гигантских объёмов

Когда обе стороны join-а слишком большие для Broadcast, Spark использует SortMergeJoin. Ключевая настройка - количество shuffle-партиций:

# Для больших объёмов: настраиваем shuffle
spark.conf.set("spark.sql.shuffle.partitions", "400")  # больше партиций = меньше размер каждой
spark.conf.set("spark.sql.adaptive.enabled", "true")   # AQE автоматически подбирает оптимум

# AQE (Adaptive Query Execution) автоматически:
# 1. Уменьшает shuffle.partitions если данных меньше ожидаемого
# 2. Переключает SortMergeJoin → BroadcastHashJoin если одна сторона оказалась малой
# 3. Обрабатывает Data Skew через split-операции

large_increment = spark.table("lakehouse.bronze.orders_large")  # 100M строк
large_target    = spark.table("lakehouse.silver.orders_large")   # 1B строк

# AQE определит правильную стратегию автоматически
new_only = (
    large_increment
    .join(
        large_target
        .filter(F.col("event_date") >= F.date_sub(F.current_date(), 30))
        .select("event_id"),
        on="event_id",
        how="left_anti"
    )
)

4.4 Bloom Filter для пропуска data files

Apache Iceberg поддерживает Bloom Filter - вероятностную структуру данных, которая позволяет быстро ответить: «может ли этот event_id существовать в данном файле?» Если фильтр говорит «нет» - файл вообще не читается:

# Включаем Bloom Filter для колонки event_id при записи
spark.sql("""
    ALTER TABLE lakehouse.silver.order_events
    SET TBLPROPERTIES (
        'write.metadata.metrics.column.event_id' = 'full',
        'write.parquet.bloom-filter-enabled.column.event_id' = 'true',
        'write.parquet.bloom-filter-false-positive-probability.column.event_id' = '0.01'
    )
""")
# Теперь при anti-join Iceberg будет пропускать data files,
# в которых точно нет искомых event_id - без чтения самих файлов

Bloom Filter работает как «ранний выход»: перед тем как читать Parquet-файл, Iceberg проверяет его Bloom Filter. Если filter говорит «этого event_id здесь нет» - файл пропускается. Ложноположительные срабатывания (false positive) возможны, ложноотрицательные (false negative) - нет. Параметр 0.01 означает 1% вероятность ложного срабатывания.


5. Anti-Join vs MERGE vs Overwrite: матрица выбора

5.1 Когда что применять

5.2 Таблица сравнения

Критерий Anti-Join + APPEND MERGE INTO Overwrite Partitions
Поддержка UPDATE ❌ ✅ ✅ (весь партишен)
Поддержка DELETE ❌ ✅ ✅ (весь партишен)
Работает без Iceberg ✅ (plain Parquet) ❌ ✅
Стоимость вычислений Низкая Средняя–Высокая Средняя
Идемпотентность ✅ Естественная ✅ С правильным ON ✅
Late-arriving data ⚠️ Требует lookback ✅ UPSERT обновит ✅ Перезапись партиции
CDC потоки ❌ (нет UPDATE) ✅ ⚠️ (только daily)
Append-only факты ✅ Идеально Избыточно Избыточно

6. Правильный выбор ключа anti-join

6.1 Типы ключей и их надёжность

Качество дедупликации полностью определяется надёжностью join-ключа. Ненадёжный ключ - ненадёжная дедупликация:

6.2 Проблема NULL-ключей в anti-join

# ЛОВУШКА: NULL-значения в join-ключе
events_with_null = spark.createDataFrame([
    ("evt-001", "order_1001"),
    (None,      "order_1002"),  # NULL event_id!
    ("evt-003", "order_1003"),
], ["event_id", "order_id"])

target_keys = spark.createDataFrame([
    ("evt-001",),
    ("evt-003",),
], ["event_id"])

# LEFT ANTI JOIN с NULL ключом в левой таблице
result = events_with_null.join(target_keys, on="event_id", how="left_anti")
result.show()
# event_id | order_id
# null     | order_1002   ← NULL-строка ПРОШЛА anti-join!
# (evt-001 и evt-003 отсечены корректно)

# В SQL: NULL != NULL → совпадение не найдено → строка попадает в ANTI результат
# Это может быть нежелательным поведением!

# РЕШЕНИЕ: фильтруем NULL-ключи перед anti-join
clean_for_anti = events_with_null.filter(F.col("event_id").isNotNull())
result_clean = clean_for_anti.join(target_keys, on="event_id", how="left_anti")
result_clean.show()
# Пустой результат - оба не-NULL ключа найдены в target

NULL != NULL в SQL - это фундаментальное правило трёхзначной логики. Строка с event_id = NULL всегда проходит anti-join, потому что NULL = NULL неизвестно → совпадение «не найдено» → строка остаётся. Это приводит к тому, что NULL-ключи всегда вставляются в target.

6.3 Составной ключ: когда одного поля недостаточно

# Некоторые системы не имеют глобального UUID - ключ составной
# Пример: события уникальны по (order_id, event_type, event_ts)

increment_comp = spark.createDataFrame([
    ("order_1001", "created",  "2026-05-23 10:00:00", 1500.0),
    ("order_1001", "paid",     "2026-05-23 10:05:00", 1500.0),
    ("order_1002", "created",  "2026-05-23 10:10:00",  750.0),
], ["order_id", "event_type", "event_ts", "amount"])

target_comp = spark.table("lakehouse.silver.order_events") \
    .filter(F.col("event_date") >= F.date_sub(F.current_date(), 7)) \
    .select("order_id", "event_type", "event_ts")

# Anti-join по составному ключу
new_comp = (
    increment_comp
    .join(
        target_comp,
        on=["order_id", "event_type", "event_ts"],  # составной ключ
        how="left_anti"
    )
)

# Spark SQL эквивалент:
spark.sql("""
    SELECT inc.*
    FROM increment_comp inc
    LEFT ANTI JOIN target_comp tgt
    ON  inc.order_id   = tgt.order_id
    AND inc.event_type = tgt.event_type
    AND inc.event_ts   = tgt.event_ts
""")

При составном ключе нужно быть особенно осторожным с типами: event_ts как STRING в инкременте и TIMESTAMP в target → JOIN не найдёт совпадений → все строки пройдут как «новые». Всегда приводите типы ключей к одинаковому типу перед anti-join.


7. Идемпотентность anti-join pipeline

7.1 Почему anti-join естественно идемпотентен

Диаграмма показывает: при втором запуске Airflow с теми же входными данными anti-join обнаружит, что все 15 «новых» строк из первого запуска уже существуют в Silver. Результат anti-join - пустой DataFrame. APPEND 0 строк. Таблица не изменилась.

Это естественная идемпотентность - она возникает автоматически из-за семантики anti-join, без дополнительного кода.

7.2 Граница lookback window и late-arriving

Единственная уязвимость: если событие опоздало настолько, что его timestamp выходит за пределы LOOKBACK_DAYS:

# Пример: LOOKBACK_DAYS = 7
# evt-OLD было загружено 10 дней назад (за пределами lookback window)
# Теперь источник снова прислал evt-OLD (например, при переотправке истории)

LOOKBACK_DAYS = 7
target_window = (
    spark.table("lakehouse.silver.order_events")
    .filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
    .select("event_id")
)

# evt-OLD (10 дней назад) НЕ попадёт в target_window
# Anti-join не найдёт его в правой таблице
# evt-OLD ПРОЙДЁТ как «новое» и будет записан повторно!

# Решения:
# 1. Увеличить LOOKBACK_DAYS (но растёт стоимость join-а)
# 2. Хранить отдельную "seen events" таблицу с только ключами (экономично)
# 3. Использовать MERGE INTO вместо anti-join для таких случаев

# Решение 2: отдельная компактная таблица ключей
spark.sql("""
    CREATE TABLE IF NOT EXISTS lakehouse.silver.order_events_keys (
        event_id STRING,
        loaded_date DATE
    )
    USING iceberg
    PARTITIONED BY (loaded_date)
    TBLPROPERTIES ('write.metadata.metrics.column.event_id' = 'full')
""")

# Ключевая таблица хранит ТОЛЬКО event_id - минимальный размер
# Lookback по loaded_date, не по event_ts - всегда актуально

8. Антипаттерны anti-join deduplication

8.1 Anti-join без деduplication инкремента

# АНТИПАТТЕРН: anti-join без предварительной дедупликации инкремента
raw_with_dups = spark.createDataFrame([
    ("evt-NEW", "order_3001", "created"),
    ("evt-NEW", "order_3001", "created"),  # дубль в инкременте!
], ["event_id", "order_id", "status"])

# Anti-join не сможет помочь: оба evt-NEW отсутствуют в target
# оба пройдут фильтр и оба будут записаны!
bad_result = raw_with_dups.join(target_keys, on="event_id", how="left_anti")
print(f"Строк пройдёт: {bad_result.count()}")  # 2 - оба дубля!

# ПРАВИЛЬНО: сначала дедуплицировать инкремент
clean = raw_with_dups.dropDuplicates(["event_id"])
good_result = clean.join(target_keys, on="event_id", how="left_anti")
print(f"Строк пройдёт: {good_result.count()}")  # 1

8.2 Anti-join для mutable данных

# АНТИПАТТЕРН: использование anti-join для обновляемых сущностей

# Заказ order_1001 уже в Silver со статусом "created"
# Новый батч содержит тот же order_id со статусом "confirmed"

increment_update = spark.createDataFrame([
    ("evt-001-v2", "order_1001", "confirmed", 1500.0),  # обновлённая версия
], ["event_id", "order_id", "status", "amount"])

# Anti-join по event_id: evt-001-v2 НЕТ в target → пройдёт как «новое»
# В Silver появится ДВЕ строки для order_1001: created и confirmed
# Это ПРАВИЛЬНО если Silver хранит историю событий (event sourcing)
# Но НЕВЕРНО если Silver хранит текущее состояние заказа!

# Для mutable сущностей (current state) → MERGE INTO, не anti-join

Правило: anti-join подходит для append-only фактовых таблиц (события, транзакции, логи). Для мутабельных сущностей (текущий статус заказа, профиль клиента) - MERGE INTO.

8.3 Anti-join по ненадёжному ключу

# АНТИПАТТЕРН: использование event_ts как ключа anti-join
# Разные источники могут присылать одно событие с разным timestamp!
source_a = ("order_1001", "created", "2026-05-23 10:00:00.000")
source_b = ("order_1001", "created", "2026-05-23 10:00:00.001")  # 1 мс разницы!

# Anti-join по (order_id, event_ts): не найдёт совпадение из-за timestamp разницы
# Оба события вставятся как «новые» - дубль по бизнес-смыслу

# ПРАВИЛЬНО: использовать стабильный бизнес-ключ (order_id + event_type)
# или UUID, если источник его предоставляет

9. End-to-End кейс: incremental batch ingestion

9.1 Сценарий

Ежечасный батч загружает события заказов из внешнего API в Silver. API может возвращать перекрывающиеся временные окна при retry. Нужен идемпотентный pipeline.

import datetime
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder \
    .appName("AntiJoin-ETL-E2E") \
    .config("spark.sql.extensions",
            "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
    .config("spark.sql.catalog.lakehouse", "org.apache.iceberg.spark.SparkCatalog") \
    .config("spark.sql.catalog.lakehouse.type", "hadoop") \
    .config("spark.sql.catalog.lakehouse.warehouse", "/tmp/warehouse") \
    .getOrCreate()

spark.conf.set("spark.sql.adaptive.enabled", "true")

# ─── ПАРАМЕТРЫ БАТЧА ─────────────────────────────────────────────────────────
BATCH_TS       = "2026-05-23 11:00:00"  # эталонное время запуска (не current_timestamp!)
LOOKBACK_DAYS  = 7                       # окно поиска в target
TARGET_TABLE   = "lakehouse.silver.order_events"

# ─── ВХОДЯЩИЙ БАТЧ ИЗ API ────────────────────────────────────────────────────
# API вернул данные за 10:00–11:00. Часть из них уже загружена предыдущим батчем!
raw_api_response = spark.createDataFrame([
    # Технический дубль: evt-001 загружен прошлым батчем
    ("evt-001", "order_1001", "created",    1500.0, "2026-05-23 10:00:00"),
    # Технический дубль внутри батча
    ("evt-010", "order_2001", "created",     500.0, "2026-05-23 10:15:00"),
    ("evt-010", "order_2001", "created",     500.0, "2026-05-23 10:15:00"),
    # Действительно новые события
    ("evt-011", "order_2002", "confirmed",  2000.0, "2026-05-23 10:30:00"),
    ("evt-012", "order_2003", "pending",     750.0, "2026-05-23 10:45:00"),
    ("evt-013", "order_2004", "created",    1200.0, "2026-05-23 10:55:00"),
], ["event_id", "order_id", "status", "amount", "event_ts"])

print(f"Событий из API: {raw_api_response.count()}")  # 6

# ─── ШАГ 1: ИНИЦИАЛИЗАЦИЯ TARGET ─────────────────────────────────────────────
spark.sql(f"""
    CREATE TABLE IF NOT EXISTS {TARGET_TABLE} (
        event_id   STRING,
        order_id   STRING,
        status     STRING,
        amount     DECIMAL(10,2),
        event_ts   TIMESTAMP,
        event_date DATE
    )
    USING iceberg
    PARTITIONED BY (event_date)
""")

# Имитируем существующие данные (загружены прошлым батчем)
existing = spark.createDataFrame([
    ("evt-001", "order_1001", "created", 1500.0, "2026-05-23 10:00:00", "2026-05-23"),
], ["event_id", "order_id", "status", "amount", "event_ts", "event_date"])

existing \
    .withColumn("event_ts", F.to_timestamp("event_ts")) \
    .withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
    .withColumn("event_date", F.to_date("event_date")) \
    .writeTo(TARGET_TABLE).append()

print(f"Существующих строк в Silver: {spark.table(TARGET_TABLE).count()}")  # 1

# ─── ШАГ 2: ДЕДУПЛИКАЦИЯ ИНКРЕМЕНТА ──────────────────────────────────────────
clean_increment = (
    raw_api_response
    .filter(F.col("event_id").isNotNull())    # убираем NULL-ключи
    .dropDuplicates(["event_id"])             # технические дубли внутри батча
)

print(f"После дедупликации инкремента: {clean_increment.count()} строк")  # 5

# ─── ШАГ 3: LOADING TARGET WINDOW ────────────────────────────────────────────
target_keys = (
    spark.table(TARGET_TABLE)
    .filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
    .select("event_id")             # только ключ - минимальный объём данных
)

print(f"Ключей в target window ({LOOKBACK_DAYS} дней): {target_keys.count()}")  # 1

# ─── ШАГ 4: ANTI-JOIN ────────────────────────────────────────────────────────
new_events = (
    clean_increment
    .join(
        F.broadcast(target_keys),   # target_keys мал - broadcast
        on="event_id",
        how="left_anti"
    )
)

print(f"Новых событий для вставки: {new_events.count()}")  # 4
new_events.select("event_id", "order_id", "status").show()
# evt-010 | order_2001 | created     (прошёл: нет в target)
# evt-011 | order_2002 | confirmed   (прошёл: нет в target)
# evt-012 | order_2003 | pending     (прошёл: нет в target)
# evt-013 | order_2004 | created     (прошёл: нет в target)
# evt-001 ОТСЕЧЁН: уже есть в target

# ─── ШАГ 5: БЕЗОПАСНЫЙ APPEND ────────────────────────────────────────────────
(
    new_events
    .withColumn("event_ts",   F.to_timestamp("event_ts"))
    .withColumn("amount",     F.col("amount").cast("decimal(10,2)"))
    .withColumn("event_date", F.to_date("event_ts"))
    .writeTo(TARGET_TABLE)
    .append()
)

print(f"\nИтого в Silver: {spark.table(TARGET_TABLE).count()} строк")  # 5
spark.table(TARGET_TABLE).orderBy("event_ts").show()

# ─── ПРОВЕРКА ИДЕМПОТЕНТНОСТИ ─────────────────────────────────────────────────
print("\n=== СИМУЛЯЦИЯ AIRFLOW RETRY (те же входные данные) ===")

# Повторяем шаги 2-5 с теми же raw_api_response
clean_increment_retry = (
    raw_api_response
    .filter(F.col("event_id").isNotNull())
    .dropDuplicates(["event_id"])
)

target_keys_retry = (
    spark.table(TARGET_TABLE)
    .filter(F.col("event_date") >= F.date_sub(F.current_date(), LOOKBACK_DAYS))
    .select("event_id")
)

new_events_retry = (
    clean_increment_retry
    .join(F.broadcast(target_keys_retry), on="event_id", how="left_anti")
)

print(f"Новых событий при retry: {new_events_retry.count()}")  # 0 - ВСЕ УЖЕ ЕСТЬ!

# APPEND 0 строк - таблица не изменилась
if new_events_retry.count() > 0:
    new_events_retry \
        .withColumn("event_ts", F.to_timestamp("event_ts")) \
        .withColumn("amount", F.col("amount").cast("decimal(10,2)")) \
        .withColumn("event_date", F.to_date("event_ts")) \
        .writeTo(TARGET_TABLE).append()

print(f"Silver после retry: {spark.table(TARGET_TABLE).count()} строк")  # всё те же 5!
print("Идемпотентность подтверждена: retry не создал дублей ✓")

9.2 Финальный результат

Событий из API: 6
Существующих строк в Silver: 1

После дедупликации инкремента: 5 строк
Ключей в target window (7 дней): 1

Новых событий для вставки: 4
event_id | order_id   | status
evt-010  | order_2001 | created
evt-011  | order_2002 | confirmed
evt-012  | order_2003 | pending
evt-013  | order_2004 | created

Итого в Silver: 5 строк

=== СИМУЛЯЦИЯ AIRFLOW RETRY ===
Новых событий при retry: 0
Silver после retry: 5 строк
Идемпотентность подтверждена: retry не создал дублей ✓

Итог: из 6 входящих событий API:

  • 1 технический дубль внутри батча (evt-010 дважды) - убран dropDuplicates
  • 1 событие уже загружено (evt-001) - отсечено anti-join
  • 4 действительно новых - записаны через APPEND
  • При Airflow retry - 0 изменений, таблица осталась консистентной

10. Финальная шпаргалка

Используйте LEFT ANTI JOIN когда:

  • Данные append-only: события, транзакции, логи (не обновляемые сущности)
  • Нужна идемпотентная вставка без overhead MERGE
  • Источник - plain Parquet или CSV (без Iceberg/Delta)
  • Инкремент мал по сравнению с target (Broadcast Anti-Join)
  • Нет требований к обновлению или удалению существующих строк

Используйте MERGE INTO когда:

  • Данные мутабельные: статус заказа, профиль клиента (CDC с UPDATE/DELETE)
  • Нужна точечная синхронизация состояния
  • Используете Iceberg или Delta Lake

Используйте Overwrite Partitions когда:

  • Детерминированные агрегаты Gold-слоя (всегда пересчитываются)
  • Нужна полная замена партиции без сложной логики ключей