refactor(airflow): пробник Kafka разрезан на запись и чтение

Зачем: пробник Kafka — второй и последний пример DAG в стенде, и после #20
единственный, который не показывает, как здесь пишут код. Одним красным
квадратом он к тому же не отвечал на вопрос, какая половина круга отказала:
брокер не принял маркер или не отдал его обратно.

Что:
- вместо одной задачи check_round_trip две: write_marker пишет маркер и
  возвращает адрес записи, read_marker читает по этому адресу и сверяет;
- адрес и маркер едут между задачами XCom — именованными полями словаря:
  XCom проходит через JSON, и кортеж вернулся бы списком;
- внутри записи адрес собирается NamedTuple RecordAddress — два соседних
  целых в сигнатуре переставляются молча;
- каждая задача заводит своего клиента и закрывает его сама, поэтому часовые
  = None и finally с проверками на None ушли; у продюсера закрыть за собой —
  это flush(): своего close() у него нет, и он же возвращает число
  недоставленных;
- настройки клиентов и все сроки ожидания стали именованными модульными
  константами; безымянных чисел в телах задач не осталось;
- слитное условие доставки разобрано на шесть утверждений, каждое со своим
  именем и своим текстом ошибки;
- успех больше не возвращается из середины цикла: чтение выходит из цикла по
  сообщению или по крайнему сроку, а сверка идёт после;
- шапка файла приведена к форме «Тест проверяет: ...» с абзацем о том, чем
  тест не является; она же уходит в doc_md;
- комментарии стоят ровно в шести местах, где незнакома модель Kafka.

Из настроек консьюмера убран session.timeout.ms: он про членство в группе и
удары сердца координатору, а пробник назначает себе адрес и в группу не
входит — почему его нет, объясняет шапка файла. Поведение не меняется.
Сверено по документации confluent-kafka-python через Context7:
session.timeout.ms описан как срок сессии группы, flush() возвращает число
оставшихся в очереди сообщений.

KafkaProbeTests держался за flush_timeouts == [10, 1], то есть за устройство
finally, которого больше нет. На его место встали две проверки свойств:
продюсер закрыт даже тогда, когда отказала запись, и чтение назначается ровно
на тот адрес, который вернул брокер.

Тело issue #22 поправлено тем же изменением: там было записано «задача
остаётся одна» — это расхождение с тем, о чём договаривались в гриллинге.

Проверка: make config-test зелен; make smoke — оба пробника зелены, красной
осталась только проверка памяти стенда по причине из #21.

Closes #22
This commit is contained in:
2026-07-31 20:10:00 +03:00
parent f7de99f10e
commit dd491ebe33
2 changed files with 271 additions and 96 deletions
+104 -34
View File
@@ -1,4 +1,4 @@
"""Малые проверки логики пробника ClickHouse без запуска Airflow."""
"""Малые проверки логики пробников ClickHouse и Kafka без запуска Airflow."""
from __future__ import annotations
@@ -55,7 +55,7 @@ def load_clickhouse_dag_tasks():
return module, captured_tasks
def load_kafka_dag_task():
def load_kafka_dag_tasks():
airflow_module = types.ModuleType("airflow")
sdk_module = types.ModuleType("airflow.sdk")
captured_tasks = {}
@@ -87,7 +87,7 @@ def load_kafka_dag_task():
raise RuntimeError("не удалось загрузить модуль пробника Kafka")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return captured_tasks["check_round_trip"]
return captured_tasks
class QueryResult:
@@ -180,43 +180,113 @@ class ClickHouseProbeTests(unittest.TestCase):
self.assertTrue(client.closed)
DELIVERED_PARTITION = 3
DELIVERED_OFFSET = 42
class StubMessage:
"""Сообщение Kafka в том объёме, в каком его читает пробник."""
def __init__(self, value: bytes) -> None:
self._value = value
def partition(self) -> int:
return DELIVERED_PARTITION
def offset(self) -> int:
return DELIVERED_OFFSET
def error(self):
return None
def value(self) -> bytes:
return self._value
class StubTopicPartition:
def __init__(self, topic: str, partition: int, offset: int) -> None:
self.topic = topic
self.partition = partition
self.offset = offset
def install_kafka_stub(produce_error: str | None = None):
"""Ставит заглушку `confluent_kafka` и возвращает журналы её клиентов.
Продюсер подтверждает доставку сразу и по известному адресу, консьюмер
отдаёт записанное с первого опроса. Если задан `produce_error`, запись
падает — так проверяется, что продюсер закрывается и на пути отказа.
"""
producers = []
consumers = []
class Producer:
def __init__(self, _config) -> None:
self.written = b""
self.flushed = False
producers.append(self)
def produce(self, _topic, key=None, value=None, on_delivery=None) -> None:
if produce_error is not None:
raise RuntimeError(produce_error)
self.written = value
on_delivery(None, StubMessage(value))
def flush(self, _timeout) -> int:
self.flushed = True
return 0
class Consumer:
def __init__(self, _config) -> None:
self.assigned = []
self.closed = False
consumers.append(self)
def assign(self, partitions) -> None:
self.assigned = partitions
def poll(self, _timeout):
return StubMessage(producers[-1].written)
def close(self) -> None:
self.closed = True
kafka_module = types.ModuleType("confluent_kafka")
kafka_module.Consumer = Consumer
kafka_module.Producer = Producer
kafka_module.TopicPartition = StubTopicPartition
sys.modules["confluent_kafka"] = kafka_module
return producers, consumers
class KafkaProbeTests(unittest.TestCase):
def test_producer_flushes_when_consumer_creation_fails(self) -> None:
kafka_module = types.ModuleType("confluent_kafka")
flush_timeouts = []
def test_producer_is_closed_when_write_fails(self) -> None:
producers, _ = install_kafka_stub(produce_error="брокер недоступен")
tasks = load_kafka_dag_tasks()
class Message:
def partition(self) -> int:
return 0
with self.assertRaisesRegex(RuntimeError, "брокер недоступен"):
tasks["write_marker"]()
def offset(self) -> int:
return 1
self.assertEqual(len(producers), 1)
self.assertTrue(producers[0].flushed)
class Producer:
def __init__(self, _config) -> None:
pass
def test_read_goes_to_the_address_broker_returned(self) -> None:
_, consumers = install_kafka_stub()
tasks = load_kafka_dag_tasks()
def produce(self, _topic, **kwargs) -> None:
kwargs["on_delivery"](None, Message())
written = tasks["write_marker"]()
tasks["read_marker"](written)
def flush(self, timeout: int) -> int:
flush_timeouts.append(timeout)
return 0
class Consumer:
def __init__(self, _config) -> None:
raise RuntimeError("чтение недоступно")
kafka_module.Consumer = Consumer
kafka_module.Producer = Producer
kafka_module.TopicPartition = object
sys.modules["confluent_kafka"] = kafka_module
check_round_trip = load_kafka_dag_task()
with self.assertRaisesRegex(RuntimeError, "чтение недоступно"):
check_round_trip()
self.assertEqual(flush_timeouts, [10, 1])
self.assertEqual(
(written["partition"], written["offset"]),
(DELIVERED_PARTITION, DELIVERED_OFFSET),
)
self.assertEqual(len(consumers), 1)
self.assertEqual(len(consumers[0].assigned), 1)
assigned = consumers[0].assigned[0]
self.assertEqual(assigned.partition, DELIVERED_PARTITION)
self.assertEqual(assigned.offset, DELIVERED_OFFSET)
self.assertTrue(consumers[0].closed)
def run_tests() -> int: