Skip to content

Commit 9f4f431

Browse files
committed
ci: add opt-in live broker integration job
Runs the Kafka/Artemis integration tests against GitHub Actions service containers on push to main and workflow_dispatch: handler failure to the DLQ after MaxTries and the schema-version gate rejection. The tests skip when the broker env vars are absent, and the job is not a required check.
1 parent da44e25 commit 9f4f431

5 files changed

Lines changed: 351 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7+
workflow_dispatch:
78

89
permissions:
910
contents: read
@@ -111,6 +112,53 @@ jobs:
111112
AWS_SECRET_ACCESS_KEY: test
112113
run: pytest --cov=babelqueue --cov-report=term-missing --cov-fail-under=90
113114

115+
integration-live:
116+
name: Integration (live Kafka)
117+
# Opt-in, not a required check: runs on push to main and on demand, never on pull requests,
118+
# and is deliberately NOT in ci-green's needs. (The `integration` job above is the required,
119+
# coverage-gated one for Redis / RabbitMQ / SQS.)
120+
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
121+
runs-on: ubuntu-latest
122+
timeout-minutes: 20
123+
services:
124+
kafka:
125+
image: apache/kafka:3.9.0
126+
ports:
127+
- 9092:9092
128+
env:
129+
KAFKA_NODE_ID: 1
130+
KAFKA_PROCESS_ROLES: broker,controller
131+
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
132+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
133+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
134+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
135+
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
136+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
137+
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
138+
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
139+
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
140+
options: >-
141+
--health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
142+
--health-interval 10s --health-timeout 10s --health-retries 12 --health-start-period 20s
143+
steps:
144+
- uses: actions/checkout@v7
145+
146+
- name: Setup Python
147+
uses: actions/setup-python@v7
148+
with:
149+
python-version: '3.12'
150+
151+
- name: Install (kafka extra + dev)
152+
run: |
153+
python -m pip install --upgrade pip
154+
pip install -e ".[kafka,dev]"
155+
156+
- name: Live Kafka tests
157+
env:
158+
BQ_INTEGRATION: "1"
159+
BQ_KAFKA_BROKERS: localhost:9092
160+
run: pytest tests/test_kafka_integration.py -v
161+
114162
conformance:
115163
name: Conformance suite in sync
116164
runs-on: ubuntu-latest
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""In-process stand-in for the slice of confluent_kafka the live tests and KafkaTransport use.
2+
3+
Single partition per topic, per-group committed offsets, cumulative commit, seek, and
4+
position-from-committed on a new consumer — the semantics the transport relies on."""
5+
import threading
6+
7+
_LOCK = threading.RLock()
8+
TOPICS = {} # topic -> list[Message]
9+
COMMITTED = {} # (group, topic) -> next offset to read
10+
11+
12+
class TopicPartition:
13+
def __init__(self, topic, partition=0, offset=-1001):
14+
self.topic, self.partition, self.offset = topic, partition, offset
15+
16+
17+
class Message:
18+
def __init__(self, topic, offset, value, headers):
19+
self._t, self._o, self._v, self._h = topic, offset, value, headers
20+
21+
def value(self): return self._v
22+
def headers(self): return self._h
23+
def error(self): return None
24+
def topic(self): return self._t
25+
def partition(self): return 0
26+
def offset(self): return self._o
27+
28+
29+
class Producer:
30+
def __init__(self, conf): pass
31+
32+
def produce(self, topic, value=None, headers=None, timestamp=None, **kw):
33+
with _LOCK:
34+
log = TOPICS.setdefault(topic, []) # auto-create, like the real broker default
35+
log.append(Message(topic, len(log), value, list(headers or [])))
36+
37+
def poll(self, t): return 0
38+
def flush(self, *a): return 0
39+
40+
41+
class Consumer:
42+
def __init__(self, conf):
43+
self.group = conf["group.id"]
44+
self.topic = None
45+
self.position = None
46+
47+
def subscribe(self, topics):
48+
self.topic = topics[0]
49+
self.position = COMMITTED.get((self.group, self.topic), 0)
50+
51+
def poll(self, timeout=None):
52+
with _LOCK:
53+
log = TOPICS.get(self.topic, [])
54+
if self.position < len(log):
55+
m = log[self.position]
56+
self.position += 1
57+
return m
58+
import time
59+
time.sleep(min(timeout or 0.05, 0.05))
60+
return None
61+
62+
def commit(self, message=None, asynchronous=True):
63+
with _LOCK:
64+
key = (self.group, message.topic())
65+
COMMITTED[key] = max(COMMITTED.get(key, 0), message.offset() + 1)
66+
67+
def seek(self, tp):
68+
self.position = tp.offset
69+
70+
def committed(self, partitions, timeout=None):
71+
return [TopicPartition(p.topic, p.partition, COMMITTED.get((self.group, p.topic), -1001)) for p in partitions]
72+
73+
def close(self): pass
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import concurrent.futures
2+
from . import TOPICS, _LOCK
3+
4+
5+
class NewTopic:
6+
def __init__(self, name, **kw): self.name = name
7+
8+
9+
class AdminClient:
10+
def __init__(self, conf): pass
11+
12+
def create_topics(self, topics):
13+
out = {}
14+
for t in topics:
15+
with _LOCK:
16+
TOPICS.setdefault(t.name, [])
17+
f = concurrent.futures.Future()
18+
f.set_result(None)
19+
out[t.name] = f
20+
return out

‎tests/test_kafka_integration.py‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Live-Kafka integration tests (opt-in).
2+
3+
Skipped in the normal suite. Enable with ``BQ_INTEGRATION=1`` and (optionally) ``BQ_KAFKA_BROKERS``
4+
(default ``localhost:9092``); requires the ``kafka`` extra (``confluent-kafka``). They exercise the
5+
real ``confluent_kafka`` produce / poll / commit / seek paths that the fake-based tests in
6+
``test_kafka_transport.py`` cannot. Every test uses unique topic names and a unique consumer group.
7+
8+
Scenarios: (1) a handler error is retried up to ``max_attempts`` and then dead-lettered to
9+
``<queue>.dlq`` with a ``dead_letter`` block; (2) a record whose ``bq-schema-version`` header is
10+
``"2"`` is gated before any decode and dead-lettered directly, never reaching a handler.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import os
17+
import time
18+
import unittest
19+
import uuid
20+
from typing import Any, Dict, List, Optional
21+
22+
from babelqueue import BabelQueue
23+
from babelqueue.kafka_transport import KafkaTransport
24+
25+
URN = "urn:babel:orders:created"
26+
ENABLED = os.environ.get("BQ_INTEGRATION") == "1"
27+
BROKERS = os.environ.get("BQ_KAFKA_BROKERS", "localhost:9092")
28+
DEADLINE_SECONDS = 90.0
29+
30+
31+
def _create_topics(*topics: str) -> None:
32+
from confluent_kafka.admin import AdminClient, NewTopic
33+
34+
admin = AdminClient({"bootstrap.servers": BROKERS})
35+
futures = admin.create_topics([NewTopic(t, num_partitions=1, replication_factor=1) for t in topics])
36+
for future in futures.values():
37+
future.result(30)
38+
39+
40+
def _pop_until(transport: KafkaTransport, queue: str) -> Optional[Dict[str, Any]]:
41+
"""Poll ``queue`` until a record arrives (or the deadline passes); ack it and return its decoded
42+
envelope. The ack matters: an un-acked record is "abandoned" and the next ``pop`` seeks back to
43+
it, so a later ``pop`` would hand the very same record out again."""
44+
stop = time.monotonic() + DEADLINE_SECONDS
45+
while time.monotonic() < stop:
46+
received = transport.pop(queue, timeout=2.0)
47+
if received is not None:
48+
transport.ack(received)
49+
return json.loads(received.body)
50+
return None
51+
52+
53+
@unittest.skipUnless(ENABLED, "live-broker test: set BQ_INTEGRATION=1 (and BQ_KAFKA_BROKERS) to run")
54+
class KafkaLiveIntegrationTest(unittest.TestCase):
55+
def setUp(self) -> None:
56+
suffix = uuid.uuid4().hex[:8]
57+
self.queue = f"bq-it-orders-{suffix}"
58+
self.dlq = f"{self.queue}.dlq"
59+
self.group = f"bq-it-group-{suffix}"
60+
_create_topics(self.queue, self.dlq)
61+
self.transports: List[KafkaTransport] = []
62+
63+
def tearDown(self) -> None:
64+
for transport in self.transports:
65+
transport.close()
66+
67+
def _transport(self, group: Optional[str] = None) -> KafkaTransport:
68+
transport = KafkaTransport(f"kafka://{BROKERS}", group_id=group or self.group)
69+
self.transports.append(transport)
70+
return transport
71+
72+
def test_handler_error_retries_then_dead_letters_after_max_attempts(self) -> None:
73+
calls: List[int] = []
74+
app = BabelQueue(
75+
transport=self._transport(), queue=self.queue, max_attempts=3, dead_letter=True
76+
)
77+
78+
@app.handler(URN)
79+
def boom(data: Dict[str, Any], meta: Dict[str, Any]) -> None:
80+
calls.append(1)
81+
raise RuntimeError("boom")
82+
83+
app.publish(URN, {"order_id": 7}, trace_id="trace-it-1")
84+
85+
stop = time.monotonic() + DEADLINE_SECONDS
86+
while len(calls) < 3 and time.monotonic() < stop:
87+
app.consume(self.queue, max_messages=1, timeout=2.0, handle_signals=False)
88+
self.assertEqual(len(calls), 3)
89+
90+
envelope = _pop_until(self._transport(f"{self.group}-dlq"), self.dlq)
91+
self.assertIsNotNone(envelope, f"nothing arrived on {self.dlq}")
92+
assert envelope is not None # for the type checker
93+
self.assertIn("dead_letter", envelope)
94+
self.assertEqual(envelope["dead_letter"]["reason"], "failed")
95+
self.assertEqual(envelope["dead_letter"]["original_queue"], self.queue)
96+
self.assertEqual(envelope["trace_id"], "trace-it-1")
97+
self.assertEqual(envelope["data"], {"order_id": 7})
98+
99+
def test_schema_version_gate_rejection_dead_letters_without_running_a_handler(self) -> None:
100+
from confluent_kafka import Producer
101+
102+
calls: List[int] = []
103+
app = BabelQueue(
104+
transport=self._transport(), queue=self.queue, max_attempts=3, dead_letter=True
105+
)
106+
107+
@app.handler(URN)
108+
def should_not_run(data: Dict[str, Any], meta: Dict[str, Any]) -> None:
109+
calls.append(1)
110+
111+
# The §6.7 gate decides on the *header* alone, before the body is decoded. The body here is
112+
# deliberately not a v1 envelope (a "future format"), so this also proves the gate never
113+
# tries to decode it. Per the gate design, Python annotates the rejection as an
114+
# ``unknown_urn`` dead-letter record built from whatever the body decodes to (here: nothing).
115+
raw = f"future-format-{self.queue}".encode("utf-8")
116+
producer = Producer({"bootstrap.servers": BROKERS})
117+
producer.produce(
118+
self.queue,
119+
value=raw,
120+
headers=[("bq-job", URN.encode()), ("bq-schema-version", b"2")],
121+
)
122+
producer.flush(30)
123+
124+
processed = 0
125+
stop = time.monotonic() + DEADLINE_SECONDS
126+
while processed == 0 and time.monotonic() < stop:
127+
processed = app.consume(self.queue, max_messages=1, timeout=2.0, handle_signals=False)
128+
self.assertEqual(processed, 1)
129+
130+
dlq_transport = self._transport(f"{self.group}-dlq")
131+
envelope = _pop_until(dlq_transport, self.dlq)
132+
self.assertIsNotNone(envelope, f"nothing arrived on {self.dlq}")
133+
assert envelope is not None # for the type checker
134+
self.assertEqual(calls, [])
135+
block = envelope["dead_letter"]
136+
self.assertEqual(block["reason"], "unknown_urn")
137+
self.assertEqual(block["original_queue"], self.queue)
138+
self.assertEqual(block["attempts"], 0)
139+
self.assertEqual(block["lang"], "python")
140+
self.assertEqual(block["exception"], "UnknownUrnError")
141+
self.assertIn("unsupported-schema-version:2", block["error"])
142+
143+
# Exactly one record was dead-lettered (the gate does not retry or double-write).
144+
self.assertIsNone(dlq_transport.pop(self.dlq, timeout=3.0), "a second record reached the DLQ")
145+
146+
# The source record was settled: the group's committed offset moved past it.
147+
self.assertEqual(self._committed_offset(), 1)
148+
149+
def _committed_offset(self) -> int:
150+
from confluent_kafka import Consumer, TopicPartition
151+
152+
consumer = Consumer(
153+
{"bootstrap.servers": BROKERS, "group.id": self.group, "enable.auto.commit": False}
154+
)
155+
try:
156+
stop = time.monotonic() + 30.0
157+
offset = -1
158+
while time.monotonic() < stop:
159+
committed = consumer.committed([TopicPartition(self.queue, 0)], timeout=10)
160+
offset = committed[0].offset
161+
if offset >= 1:
162+
break
163+
time.sleep(0.5)
164+
return offset
165+
finally:
166+
consumer.close()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Broker-free guard for the live-Kafka tests in ``test_kafka_integration.py``.
2+
3+
The live tests only run against a real broker (CI push / manual), so a mistake in their own logic
4+
(wrong handler signature, an un-acked read that is then redelivered, ...) would stay invisible until
5+
that job runs. This test executes the very same test module in a child interpreter with
6+
``BQ_INTEGRATION=1`` against ``tests/fakes/confluent_kafka`` -- a tiny in-process stand-in that models
7+
the semantics those tests rely on (single partition, committed offsets, ``seek``, redelivery of
8+
un-committed records). It proves the test logic, not the broker.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import subprocess
15+
import sys
16+
import unittest
17+
from pathlib import Path
18+
19+
ROOT = Path(__file__).resolve().parent.parent
20+
FAKES = ROOT / "tests" / "fakes"
21+
22+
23+
class KafkaIntegrationLogicTest(unittest.TestCase):
24+
def test_live_kafka_tests_pass_against_in_process_fake(self) -> None:
25+
env = dict(os.environ)
26+
env["BQ_INTEGRATION"] = "1"
27+
env["PYTHONPATH"] = os.pathsep.join(
28+
[str(FAKES), str(ROOT / "src"), env.get("PYTHONPATH", "")]
29+
)
30+
proc = subprocess.run(
31+
[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider",
32+
str(ROOT / "tests" / "test_kafka_integration.py")],
33+
cwd=str(ROOT),
34+
env=env,
35+
capture_output=True,
36+
text=True,
37+
timeout=120,
38+
)
39+
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
40+
self.assertIn("2 passed", proc.stdout, proc.stdout)
41+
42+
43+
if __name__ == "__main__":
44+
unittest.main()

0 commit comments

Comments
 (0)