Skip to content

Commit 23a49cc

Browse files
committed
explicitly close and recreat dead kafka consumers
1 parent e799295 commit 23a49cc

17 files changed

Lines changed: 649 additions & 73 deletions

‎config.yaml‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,26 +278,30 @@ pipeline:
278278
plugins: []
279279
monitoring:
280280
kafka_consumer:
281-
# Monitoring produces several records per pipeline message. Consume them
282-
# in larger blocks so schema decoding, ClickHouse writes, and offset
283-
# commits are amortized across the batch.
284-
batch_size: 5000
281+
# Keep the uncommitted replay window bounded. ClickHouse already flushes
282+
# each table every 50 rows, so a much larger Kafka batch does not create
283+
# larger ClickHouse inserts; it only delays the Kafka offset commit.
284+
batch_size: 500
285285
# Match the ClickHouse partial-batch window. A shorter timeout forces
286286
# sparse tables into many small synchronous inserts.
287287
timeout_ms: 2000
288288
clickhouse_connector:
289289
batch_size: 50 # do not set higher
290290
batch_timeout: 2.0
291+
# Keep a sink outage well below Kafka's max-poll interval. The monitoring
292+
# agent leaves its consumer group before longer reconnect attempts.
293+
connect_timeout_seconds: 10
294+
operation_timeout_seconds: 30
291295

292296

293297
zeek:
294298
sensors:
295299
zeek:
296-
static_analysis: true
300+
static_analysis: false
297301
protocols:
298302
- dns
299303
interfaces:
300-
- eth1
304+
- wlp3s0
301305

302306
environment:
303307
kafka_brokers:
@@ -317,13 +321,21 @@ environment:
317321
# Allow long-running detector batches without Kafka forcing a group rebalance.
318322
# Default librdkafka value is 300000 ms (5 minutes)
319323
max_poll_interval_ms: 300000
324+
# A commit belongs to one consumer generation. Give transient coordinator
325+
# errors a short recovery window, then resubscribe instead of retrying a
326+
# stale generation forever.
327+
commit_retry_timeout_seconds: 30
320328
kafka_producer:
321329
# Compress JSON pipeline and monitoring batches before replication.
322330
compression_type: zstd
331+
# Bound native transactional calls so a broker/DNS outage returns control
332+
# to coordinated producer/consumer recovery.
333+
transaction_api_timeout_seconds: 30
334+
message_timeout_ms: 60000
323335
kafka_transaction_batch:
324336
# Global fallback. Exact topic settings override stage settings, which
325337
# override these defaults. Environment variables remain final overrides.
326-
size: 250
338+
size: 50
327339
timeout_ms: 50
328340
stages:
329341
# CPU-heavy inference must return to Kafka much more frequently.

‎docs/configuration.rst‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,18 @@ To entirely skip the anomaly detection phase, you can set ``inspector_module_nam
602602
* - clickhouse_connector.batch_timeout
603603
- ``2.0``
604604
- Maximum time in seconds before a partial monitoring batch is written.
605+
* - clickhouse_connector.connect_timeout_seconds
606+
- ``10``
607+
- Timeout for one ClickHouse connection attempt. Reconnection continues after the monitoring consumer has left its Kafka group.
608+
* - clickhouse_connector.operation_timeout_seconds
609+
- ``30``
610+
- Timeout for one ClickHouse HTTP request. A failed insert is replayed from uncommitted Kafka offsets after dependency recovery.
611+
* - kafka_consumer.batch_size
612+
- ``500``
613+
- Maximum monitoring Kafka records fetched in one explicit sink batch.
614+
* - kafka_consumer.timeout_ms
615+
- ``2000``
616+
- Maximum time spent collecting a partial monitoring Kafka batch.
605617

606618
``pipeline.zeek``
607619
^^^^^^^^^^^^^^^^^
@@ -649,18 +661,27 @@ The following parameters control the infrastructure of the software.
649661
* - kafka_consumer.max_poll_interval_ms
650662
- ``1800000`` fallback; ``300000`` in the supplied ``config.yaml``
651663
- Maximum time in milliseconds between Kafka consumer polls before Kafka removes the consumer from its group. Increase this for long-running detector batches and keep batch processing comfortably below the interval. See :doc:`kafka_recovery` for automatic membership recovery, rebalance handling, and delivery guarantees.
664+
* - kafka_consumer.commit_retry_timeout_seconds
665+
- ``30``
666+
- Maximum time to retry transient offset-commit failures with one consumer generation. Membership errors trigger immediate resubscription instead. ``KAFKA_CONSUMER_COMMIT_RETRY_TIMEOUT_SECONDS`` is the final environment override.
652667
* - kafka_transaction_batch.size / timeout_ms
653-
- ``250`` / ``50`` in the supplied ``config.yaml``
668+
- ``50`` / ``50`` in the supplied ``config.yaml``
654669
- Global Kafka consume/transaction batch defaults. ``size`` counts Kafka records and ``timeout_ms`` controls how long a consumer waits while collecting a batch.
655670
* - kafka_transaction_batch.stages
656671
- Detector ``size: 10``; alerter ``size: 25``
657672
- Per-consuming-stage overrides. Short names such as ``detector`` and fully-qualified names such as ``data_analysis.detector`` are supported.
658673
* - kafka_transaction_batch.topics
659-
- Domainator input topic ``size: 5``
674+
- None by default
660675
- Per-topic overrides keyed by the complete consumed topic name. Exact-topic settings override stage settings, which override the global defaults. See :doc:`kafka_recovery` for examples and environment-variable precedence.
661676
* - kafka_producer.compression_type
662677
- ``zstd``
663678
- Compression codec used by HAMSTRING's Python Kafka producers. Zeek is an external producer and must be configured separately if its image supports compression settings.
679+
* - kafka_producer.transaction_api_timeout_seconds
680+
- ``30``
681+
- Timeout for one transactional producer initialization, commit, or abort call. A timeout causes coordinated producer and consumer recovery. ``KAFKA_TRANSACTION_API_TIMEOUT_SECONDS`` is the final environment override.
682+
* - kafka_producer.message_timeout_ms
683+
- ``60000``
684+
- Maximum delivery time for a transactional Kafka output record. ``KAFKA_PRODUCER_MESSAGE_TIMEOUT_MS`` is the final environment override.
664685
* - kafka_topics.replication_factor
665686
- ``3``
666687
- Replication factor used when creating new Kafka topics. At runtime this is capped to the number of configured Kafka brokers.

‎docs/kafka_recovery.rst‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,32 @@ stages.
4141
HAMSTRING also handles librdkafka's local ``_MAX_POLL_EXCEEDED`` consumer error
4242
through the same consumer-reset path.
4343

44+
Broker and Docker-network outages
45+
---------------------------------
46+
47+
Kafka transport failures such as ``_TIMED_OUT``, ``_MSG_TIMED_OUT``,
48+
``_TRANSPORT``, and ``_RESOLVE`` use coordinated recovery. Native transaction
49+
initialization, commit, and abort calls have an explicit API timeout. When a
50+
transactional stage loses Kafka connectivity it:
51+
52+
#. aborts the transaction within the configured API timeout;
53+
#. closes the consumer, thereby leaving its current group generation;
54+
#. recreates and initializes the transactional producer;
55+
#. subscribes a fresh consumer after Kafka is reachable;
56+
#. reconsumes the uncommitted input batch.
57+
58+
The service may remain in a visible reconnect loop while Kafka or Docker DNS
59+
is unavailable, but it no longer holds stale consumer membership while doing
60+
so. ``_PURGE_QUEUE`` delivery callbacks during the abort describe records
61+
discarded from the old local producer. They do not mean that the corresponding
62+
input offsets were committed.
63+
64+
``environment.kafka_producer.transaction_api_timeout_seconds`` bounds each
65+
native transactional API call. ``message_timeout_ms`` bounds how long produced
66+
records may remain undelivered. The environment variables
67+
``KAFKA_TRANSACTION_API_TIMEOUT_SECONDS`` and
68+
``KAFKA_PRODUCER_MESSAGE_TIMEOUT_MS`` are final deployment overrides.
69+
4470
Rebalance tracking
4571
------------------
4672

@@ -94,7 +120,7 @@ the consuming pipeline stage, or by the complete consumed topic name:
94120
95121
environment:
96122
kafka_transaction_batch:
97-
size: 250
123+
size: 50
98124
timeout_ms: 50
99125
stages:
100126
detector:
@@ -120,9 +146,9 @@ corresponding short name. Exact topic keys must contain the full topic name
120146
consumed by the worker. If one consumer subscribes to multiple topics, it uses
121147
the smallest effective size and timeout across those topics.
122148

123-
The supplied configuration sets the detector stage to 10 records, the alerter
124-
stage to 25 records, and the initial Domainator topic to 5 records. Other stages
125-
inherit the global value. A caller that explicitly supplies ``max_messages`` or
149+
The supplied configuration sets the global value to 50 records, the detector
150+
stage to 10 records, and the alerter stage to 25 records. Other stages inherit
151+
the global value. A caller that explicitly supplies ``max_messages`` or
126152
``timeout_ms`` to ``consume_batch`` still overrides the resolved defaults; the
127153
collector uses this for its application-level batching configuration.
128154

@@ -133,6 +159,38 @@ Operationally, alert on:
133159
* transaction aborts while consumer lag increases;
134160
* no successful output transaction while input offsets continue to arrive.
135161

162+
Monitoring sink recovery
163+
------------------------
164+
165+
The monitoring agent is not part of a Kafka transaction: it inserts a consumed
166+
batch into ClickHouse and then commits its Kafka offsets. Its recovery guarantee
167+
is therefore **at least once**, not exactly once.
168+
169+
The supplied configuration consumes at most 500 monitoring records per Kafka
170+
batch while ClickHouse continues to flush each table every 50 rows. Increasing
171+
the Kafka value does not increase the ClickHouse insert size; it only delays the
172+
offset commit and enlarges the batch that must be replayed after a failure.
173+
174+
ClickHouse connection and request calls are bounded by
175+
``pipeline.monitoring.clickhouse_connector.connect_timeout_seconds`` and
176+
``operation_timeout_seconds``. If an insert fails, the agent closes its Kafka
177+
consumer before waiting for ClickHouse to return. It discards the local batch,
178+
reconnects ClickHouse, subscribes a fresh Kafka consumer, and replays records
179+
from committed offsets.
180+
181+
Normal offset commits retry transient coordinator errors only for
182+
``environment.kafka_consumer.commit_retry_timeout_seconds``. Membership errors
183+
such as ``UNKNOWN_MEMBER_ID`` skip that retry window and immediately cause a
184+
fresh subscription. Retrying a commit with the same expired generation can
185+
never succeed.
186+
187+
An outage can occur after ClickHouse accepted an insert but before Kafka
188+
accepted its offset commit. Replaying the Kafka record can consequently insert
189+
a duplicate monitoring row. This is preferable to committing an offset for a
190+
row that may not have reached ClickHouse. Strict duplicate prevention requires
191+
an idempotent ClickHouse schema keyed by the source Kafka topic, partition, and
192+
offset; the current raw monitoring tables do not provide that guarantee.
193+
136194
Delivery guarantees outside Kafka
137195
---------------------------------
138196

‎src/base/kafka/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
)
99
from src.base.kafka.errors import (
1010
KafkaConsumerMembershipLost,
11+
KafkaInfrastructureUnavailable,
1112
KafkaMessageFetchException,
1213
TooManyFailedAttemptsError,
1314
)
@@ -31,6 +32,7 @@
3132
"ExactlyOnceKafkaProduceHandler",
3233
"KafkaConsumeHandler",
3334
"KafkaConsumerMembershipLost",
35+
"KafkaInfrastructureUnavailable",
3436
"KafkaHandler",
3537
"KafkaMessageFetchException",
3638
"KafkaProduceHandler",

‎src/base/kafka/config.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,29 @@
1818
KAFKA_CONSUMER_MAX_POLL_INTERVAL_MS = int(
1919
KAFKA_CONSUMER_CONFIG.get("max_poll_interval_ms", 1_800_000)
2020
)
21+
KAFKA_CONSUMER_COMMIT_RETRY_TIMEOUT_SECONDS = float(
22+
os.getenv(
23+
"KAFKA_CONSUMER_COMMIT_RETRY_TIMEOUT_SECONDS",
24+
KAFKA_CONSUMER_CONFIG.get("commit_retry_timeout_seconds", 30),
25+
)
26+
)
2127
KAFKA_PRODUCER_CONFIG = CONFIG["environment"].get("kafka_producer", {})
2228
KAFKA_PRODUCER_COMPRESSION_TYPE = os.getenv(
2329
"KAFKA_PRODUCER_COMPRESSION_TYPE",
2430
KAFKA_PRODUCER_CONFIG.get("compression_type", "zstd"),
2531
)
32+
KAFKA_TRANSACTION_API_TIMEOUT_SECONDS = float(
33+
os.getenv(
34+
"KAFKA_TRANSACTION_API_TIMEOUT_SECONDS",
35+
KAFKA_PRODUCER_CONFIG.get("transaction_api_timeout_seconds", 30),
36+
)
37+
)
38+
KAFKA_PRODUCER_MESSAGE_TIMEOUT_MS = int(
39+
os.getenv(
40+
"KAFKA_PRODUCER_MESSAGE_TIMEOUT_MS",
41+
KAFKA_PRODUCER_CONFIG.get("message_timeout_ms", 60_000),
42+
)
43+
)
2644

2745
KAFKA_TRANSACTION_BATCH_CONFIG = CONFIG["environment"].get(
2846
"kafka_transaction_batch", {}

‎src/base/kafka/consumer.py‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from src.base.kafka.client import KafkaHandler
1313
from src.base.kafka.errors import (
1414
KafkaConsumerMembershipLost,
15+
KafkaInfrastructureUnavailable,
1516
KafkaMessageFetchException,
1617
TooManyFailedAttemptsError,
1718
)
@@ -28,7 +29,7 @@
2829
topic_partition_count,
2930
)
3031
from src.base.log_config import get_logger
31-
from src.base.retry import retry_forever
32+
from src.base.retry import retry_forever, retry_with_timeout
3233

3334
logger = get_logger()
3435

@@ -51,6 +52,16 @@ def __init__(self, topics: str | list[str], stage: str | None = None) -> None:
5152
self.brokers = kafka_config.bootstrap_servers()
5253
self.conf = self._build_consumer_conf()
5354
self._connect_consumer()
55+
logger.info(
56+
"Kafka consumer configured for stage %s with batch size %d, "
57+
"batch timeout %dms, max poll interval %dms, and commit retry "
58+
"timeout %.1fs.",
59+
self.stage or "unspecified",
60+
self.transaction_batch_size,
61+
self.transaction_batch_timeout_ms,
62+
kafka_config.KAFKA_CONSUMER_MAX_POLL_INTERVAL_MS,
63+
kafka_config.KAFKA_CONSUMER_COMMIT_RETRY_TIMEOUT_SECONDS,
64+
)
5465

5566
def _build_consumer_conf(self) -> dict:
5667
return {
@@ -95,6 +106,15 @@ def connect():
95106
)
96107

97108
def _reset_consumer(self) -> None:
109+
self.disconnect_for_recovery()
110+
self.reconnect_after_recovery()
111+
112+
def disconnect_for_recovery(self, reason: Exception | None = None) -> None:
113+
"""Leave the current group before waiting for a failed dependency."""
114+
if reason:
115+
logger.warning(
116+
"Closing Kafka consumer before dependency recovery: %s", reason
117+
)
98118
try:
99119
if self.consumer:
100120
self.consumer.close()
@@ -103,7 +123,14 @@ def _reset_consumer(self) -> None:
103123
"Ignoring Kafka consumer close failure during reconnect: %s",
104124
exception,
105125
)
126+
self.consumer = None
106127
self._last_consumed_message = None
128+
self._assignment_epoch += 1
129+
self._assignment_valid = False
130+
self._assigned_partitions.clear()
131+
132+
def reconnect_after_recovery(self) -> None:
133+
"""Create and subscribe a new consumer after dependencies are reachable."""
107134
self._connect_consumer()
108135

109136
def recover_group_membership(self, reason: Exception | None = None) -> None:
@@ -181,29 +208,55 @@ def commit(
181208
) -> None:
182209
"""Commit an explicit batch or the latest record from ``consume``."""
183210
if not self.consumer:
184-
return
211+
raise KafkaInfrastructureUnavailable(
212+
"Kafka offset commit requested without a connected consumer."
213+
)
214+
215+
def commit_with_current_membership(operation) -> None:
216+
def commit_once():
217+
try:
218+
return operation()
219+
except KafkaException as exception:
220+
if is_consumer_membership_error(
221+
exception.args[0] if exception.args else None
222+
):
223+
raise KafkaConsumerMembershipLost(
224+
f"Kafka offset commit used stale membership: {exception}"
225+
) from exception
226+
raise
227+
228+
try:
229+
retry_with_timeout(
230+
commit_once,
231+
"Kafka consumer batch offset commit",
232+
kafka_config.RETRY_SETTINGS,
233+
kafka_config.KAFKA_CONSUMER_COMMIT_RETRY_TIMEOUT_SECONDS,
234+
retryable=(KafkaException, RuntimeError, OSError),
235+
)
236+
except KafkaConsumerMembershipLost:
237+
raise
238+
except (KafkaException, RuntimeError, OSError) as exception:
239+
raise KafkaInfrastructureUnavailable(
240+
"Kafka offset commit did not recover before its retry "
241+
f"deadline: {exception}"
242+
) from exception
185243

186244
if consumed_messages is not None:
187245
if not consumed_messages:
188246
return
189-
retry_forever(
247+
self.ensure_batch_assignment_current(consumed_messages)
248+
commit_with_current_membership(
190249
lambda: self.consumer.commit(
191250
offsets=self.offsets_for(consumed_messages),
192251
asynchronous=False,
193-
),
194-
"Kafka consumer batch offset commit",
195-
kafka_config.RETRY_SETTINGS,
196-
retryable=(KafkaException, RuntimeError, OSError),
252+
)
197253
)
198254
self._last_consumed_message = None
199255
return
200256

201257
if self._last_consumed_message is not None:
202-
retry_forever(
203-
lambda: self.consumer.commit(self._last_consumed_message),
204-
"Kafka consumer offset commit",
205-
kafka_config.RETRY_SETTINGS,
206-
retryable=(KafkaException, RuntimeError, OSError),
258+
commit_with_current_membership(
259+
lambda: self.consumer.commit(self._last_consumed_message)
207260
)
208261
self._last_consumed_message = None
209262

‎src/base/kafka/errors.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ class KafkaMessageFetchException(Exception):
1111

1212
class KafkaConsumerMembershipLost(Exception):
1313
"""Raised when a transaction uses stale consumer-group membership."""
14+
15+
16+
class KafkaInfrastructureUnavailable(Exception):
17+
"""Raised when Kafka transport failure requires coordinated client recovery."""

0 commit comments

Comments
 (0)