Skip to content

Commit 5166440

Browse files
[bugfix] Kafka: batch + retry offsets_for_times to survive broker timeouts (#547)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0254966 commit 5166440

3 files changed

Lines changed: 219 additions & 10 deletions

File tree

docs/source/feature/data.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,13 @@ data_config {
106106
- `kafka://broker:9092/topic?group.id=consumer_group&auto.offset.reset=earliest`
107107
- 需以`&`分隔符来分隔kafka的参数,`group.id`是必选参数,其余参数参考[Kafka配置文档](https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md)
108108
- `enable.auto.commit`默认设置为`false`,KafkaDataset使用自身的checkpoint机制管理消费位点,不依赖Kafka broker端的offset提交。如需启用自动提交,可在URI中显式设置`enable.auto.commit=true`
109-
- 支持`start.timestamp.ms`参数,指定从某个时间戳(毫秒)开始消费,消费者会从各分区中时间戳 >= 该值的最早offset开始读取。当同时存在checkpoint时,checkpoint优先级更高。示例:
110-
- `kafka://broker:9092/topic?group.id=consumer_group&auto.offset.reset=earliest&start.timestamp.ms=1711929600000`
109+
- 支持`start.timestamp.ms`参数,指定从某个时间戳(毫秒)开始消费,消费者会从各分区中时间戳 >= 该值的最早offset开始读取。当同时存在checkpoint时,checkpoint优先级更高
110+
- 示例:
111+
- `kafka://broker:9092/topic?group.id=consumer_group&auto.offset.reset=earliest&start.timestamp.ms=1711929600000`
112+
- 使用`start.timestamp.ms`时,时间戳到offset的解析(`offsets_for_times`)会分批请求并带重试,可通过如下环境变量调优,避免分区较多时单次请求超时:
113+
- `TZREC_KAFKA_OFFSETS_FOR_TIMES_BATCH_SIZE`: 每次`offsets_for_times`请求解析的分区数,默认`32`
114+
- `TZREC_KAFKA_OFFSETS_FOR_TIMES_TIMEOUT`: 每次`offsets_for_times`请求的超时时间(秒),默认`30.0`
115+
- `TZREC_KAFKA_OFFSETS_FOR_TIMES_RETRIES`: 每批请求失败后的重试次数,默认`2`
111116
112117
- 注意:
113118

tzrec/datasets/kafka_dataset.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@
1414
import os
1515
import threading
1616
import time
17-
from typing import Any, Dict, Iterator, List, Optional, Tuple
17+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
1818
from urllib.parse import parse_qsl, urlparse
1919

2020
import pyarrow as pa
21-
from confluent_kafka import OFFSET_INVALID, Consumer, TopicPartition
21+
from confluent_kafka import (
22+
OFFSET_INVALID,
23+
Consumer,
24+
KafkaException,
25+
TopicPartition,
26+
)
2227

2328
from tzrec.datasets.dataset import BaseDataset, BaseReader
2429
from tzrec.datasets.utils import (
@@ -91,6 +96,72 @@ def _parse_kafka_uri(uri: str) -> Tuple[str, Dict[str, Any], Optional[int]]:
9196
return topic, params, start_timestamp_ms
9297

9398

99+
def _offsets_for_times_batched(
100+
consumer: Consumer,
101+
ts_partitions: List[TopicPartition],
102+
start_timestamp_ms: int,
103+
batch_size: int,
104+
timeout: float,
105+
max_retries: int,
106+
sleep_fn: Callable[[float], None] = time.sleep,
107+
) -> Dict[Tuple[str, int], int]:
108+
"""Resolve a start timestamp to offsets in chunks, with retry.
109+
110+
``consumer.offsets_for_times`` is split into chunks of ``batch_size``
111+
partitions to keep each broker request small, and every chunk is retried
112+
up to ``max_retries`` times on ``KafkaException`` (e.g. request timeout).
113+
A persistent failure re-raises rather than silently falling back, because
114+
this reader is shared with training, where falling back to
115+
``auto.offset.reset`` would replay the whole topic.
116+
117+
Args:
118+
consumer (Consumer): the kafka consumer.
119+
ts_partitions (list): partitions to resolve.
120+
start_timestamp_ms (int): target timestamp in milliseconds.
121+
batch_size (int): number of partitions per ``offsets_for_times`` call.
122+
timeout (float): per-call request timeout in seconds.
123+
max_retries (int): retries per chunk after the first attempt.
124+
sleep_fn (callable): sleep between retries (injectable for testing).
125+
126+
Returns:
127+
dict: map of (topic, partition) to resolved offset, including only
128+
partitions the broker resolved to a real (>= 0) offset.
129+
130+
Raises:
131+
KafkaException: if a chunk still fails after exhausting retries.
132+
"""
133+
resolved_map: Dict[Tuple[str, int], int] = {}
134+
for i in range(0, len(ts_partitions), batch_size):
135+
chunk = ts_partitions[i : i + batch_size]
136+
# offsets_for_times reads the target timestamp from the offset field.
137+
for tp in chunk:
138+
tp.offset = start_timestamp_ms
139+
retry_cnt = 0
140+
while True:
141+
try:
142+
resolved = consumer.offsets_for_times(chunk, timeout=timeout)
143+
except KafkaException as e:
144+
if retry_cnt >= max_retries:
145+
logger.error(
146+
f"offsets_for_times failed after {max_retries} retries "
147+
f"for {len(chunk)} partition(s) (timeout={timeout}s); "
148+
f"tune TZREC_KAFKA_OFFSETS_FOR_TIMES_* env vars"
149+
)
150+
raise
151+
retry_cnt += 1
152+
logger.warning(
153+
f"offsets_for_times attempt {retry_cnt}/{max_retries} failed "
154+
f"for {len(chunk)} partition(s), retrying: {e}"
155+
)
156+
sleep_fn(min(2.0 * retry_cnt, 10.0))
157+
continue
158+
break
159+
for r in resolved:
160+
if r.offset is not None and r.offset >= 0:
161+
resolved_map[(r.topic, r.partition)] = r.offset
162+
return resolved_map
163+
164+
94165
class KafkaDataset(BaseDataset):
95166
"""Dataset for reading data from Kafka (each message is a serialized Arrow batch).
96167
@@ -260,6 +331,14 @@ def _reader(
260331
"""
261332
topic, config, start_timestamp_ms = _parse_kafka_uri(self._input_path)
262333
max_poll_interval_ms = int(config.get("max.poll.interval.ms", "300000"))
334+
# offsets_for_times tunables (chunk size / per-call timeout / retries).
335+
offt_batch_size = max(
336+
1, int(os.environ.get("TZREC_KAFKA_OFFSETS_FOR_TIMES_BATCH_SIZE", "32"))
337+
)
338+
offt_timeout = float(
339+
os.environ.get("TZREC_KAFKA_OFFSETS_FOR_TIMES_TIMEOUT", "30.0")
340+
)
341+
offt_retries = int(os.environ.get("TZREC_KAFKA_OFFSETS_FOR_TIMES_RETRIES", "2"))
263342
consumer = Consumer(config)
264343

265344
stop_event = threading.Event()
@@ -283,10 +362,14 @@ def on_assign(consumer: Consumer, partitions: List[TopicPartition]) -> None:
283362
tp.offset = OFFSET_INVALID
284363

285364
if ts_partitions:
286-
for tp in ts_partitions:
287-
tp.offset = start_timestamp_ms
288-
resolved = consumer.offsets_for_times(ts_partitions, timeout=30.0)
289-
resolved_map = {(r.topic, r.partition): r.offset for r in resolved}
365+
resolved_map = _offsets_for_times_batched(
366+
consumer,
367+
ts_partitions,
368+
start_timestamp_ms,
369+
offt_batch_size,
370+
offt_timeout,
371+
offt_retries,
372+
)
290373
for tp in ts_partitions:
291374
res_offset = resolved_map.get((tp.topic, tp.partition))
292375
if res_offset is not None and res_offset >= 0:

tzrec/datasets/kafka_dataset_test.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@
1414
import os
1515
import time
1616
import unittest
17+
from unittest import mock
1718

1819
import pyarrow as pa
1920
from alibabacloud_alikafka20190916 import models as alikafka_models
2021
from alibabacloud_alikafka20190916.client import Client as AliKafkaClient
2122
from alibabacloud_credentials.client import Client as CredClient
2223
from alibabacloud_tea_openapi import models as openapi_models
2324
from alibabacloud_tea_util import models as util_models
24-
from confluent_kafka import Producer
25+
from confluent_kafka import KafkaException, Producer, TopicPartition
2526
from parameterized import parameterized
2627
from torch.utils.data import DataLoader
2728

28-
from tzrec.datasets.kafka_dataset import KafkaDataset, _parse_kafka_uri
29+
from tzrec.datasets.kafka_dataset import (
30+
KafkaDataset,
31+
_offsets_for_times_batched,
32+
_parse_kafka_uri,
33+
)
2934
from tzrec.features.feature import FgMode, create_features
3035
from tzrec.protos import data_pb2, feature_pb2
3136
from tzrec.utils.checkpoint_util import update_dataloder_state
@@ -69,6 +74,122 @@ def test_parse_with_non_integer_timestamp_raises(self):
6974
_parse_kafka_uri(uri)
7075

7176

77+
def _resolved(partition, offset, topic="t"):
78+
"""Build a TopicPartition as returned by offsets_for_times."""
79+
tp = TopicPartition(topic, partition)
80+
tp.offset = offset
81+
return tp
82+
83+
84+
class OffsetsForTimesBatchedTest(unittest.TestCase):
85+
"""Unit tests for _offsets_for_times_batched (broker-free, mocked)."""
86+
87+
START_TS = 1711929600000
88+
89+
def test_empty_partitions_returns_empty(self):
90+
consumer = mock.MagicMock()
91+
result = _offsets_for_times_batched(
92+
consumer, [], self.START_TS, 30, 30.0, 3, sleep_fn=mock.MagicMock()
93+
)
94+
self.assertEqual(result, {})
95+
consumer.offsets_for_times.assert_not_called()
96+
97+
def test_single_chunk_all_resolved(self):
98+
seen_offsets = []
99+
100+
def _fake(chunk, timeout):
101+
# offsets_for_times receives the target timestamp in tp.offset.
102+
seen_offsets.extend(tp.offset for tp in chunk)
103+
return [_resolved(tp.partition, tp.partition + 100) for tp in chunk]
104+
105+
consumer = mock.MagicMock()
106+
consumer.offsets_for_times.side_effect = _fake
107+
parts = [TopicPartition("t", p) for p in range(5)]
108+
result = _offsets_for_times_batched(
109+
consumer, parts, self.START_TS, 30, 30.0, 3, sleep_fn=mock.MagicMock()
110+
)
111+
self.assertEqual(consumer.offsets_for_times.call_count, 1)
112+
self.assertEqual(result, {("t", p): p + 100 for p in range(5)})
113+
# target timestamp was set on every partition before the call.
114+
self.assertEqual(seen_offsets, [self.START_TS] * 5)
115+
116+
def test_chunking_boundary(self):
117+
consumer = mock.MagicMock()
118+
consumer.offsets_for_times.side_effect = lambda chunk, timeout: [
119+
_resolved(tp.partition, tp.partition + 100) for tp in chunk
120+
]
121+
parts = [TopicPartition("t", p) for p in range(65)]
122+
result = _offsets_for_times_batched(
123+
consumer, parts, self.START_TS, 30, 30.0, 3, sleep_fn=mock.MagicMock()
124+
)
125+
chunk_sizes = [
126+
len(call.args[0]) for call in consumer.offsets_for_times.call_args_list
127+
]
128+
self.assertEqual(chunk_sizes, [30, 30, 5])
129+
self.assertEqual(len(result), 65)
130+
131+
def test_partial_resolution_filters_negative(self):
132+
# broker returns -1 when the timestamp is past the last message.
133+
consumer = mock.MagicMock()
134+
consumer.offsets_for_times.return_value = [
135+
_resolved(0, 100),
136+
_resolved(1, -1),
137+
_resolved(2, 102),
138+
]
139+
parts = [TopicPartition("t", p) for p in range(3)]
140+
result = _offsets_for_times_batched(
141+
consumer, parts, self.START_TS, 30, 30.0, 3, sleep_fn=mock.MagicMock()
142+
)
143+
self.assertEqual(result, {("t", 0): 100, ("t", 2): 102})
144+
self.assertNotIn(("t", 1), result)
145+
146+
def test_retry_then_success(self):
147+
sleep_fn = mock.MagicMock()
148+
consumer = mock.MagicMock()
149+
consumer.offsets_for_times.side_effect = [
150+
KafkaException("transient timeout"),
151+
[_resolved(0, 100)],
152+
]
153+
parts = [TopicPartition("t", 0)]
154+
result = _offsets_for_times_batched(
155+
consumer, parts, self.START_TS, 30, 30.0, 3, sleep_fn=sleep_fn
156+
)
157+
self.assertEqual(result, {("t", 0): 100})
158+
self.assertEqual(consumer.offsets_for_times.call_count, 2)
159+
self.assertEqual(sleep_fn.call_count, 1)
160+
161+
def test_retry_exhausted_raises(self):
162+
sleep_fn = mock.MagicMock()
163+
consumer = mock.MagicMock()
164+
consumer.offsets_for_times.side_effect = KafkaException("persistent timeout")
165+
parts = [TopicPartition("t", 0)]
166+
with self.assertRaises(KafkaException):
167+
_offsets_for_times_batched(
168+
consumer, parts, self.START_TS, 30, 30.0, 3, sleep_fn=sleep_fn
169+
)
170+
# 1 initial attempt + 3 retries = 4 calls; sleep before each retry = 3.
171+
self.assertEqual(consumer.offsets_for_times.call_count, 4)
172+
self.assertEqual(sleep_fn.call_count, 3)
173+
174+
def test_retry_isolated_per_chunk(self):
175+
sleep_fn = mock.MagicMock()
176+
# chunk 0 fails once then succeeds; chunk 1 succeeds first try.
177+
outcomes = [
178+
KafkaException("blip"),
179+
[_resolved(0, 100)],
180+
[_resolved(1, 101)],
181+
]
182+
consumer = mock.MagicMock()
183+
consumer.offsets_for_times.side_effect = outcomes
184+
parts = [TopicPartition("t", 0), TopicPartition("t", 1)]
185+
result = _offsets_for_times_batched(
186+
consumer, parts, self.START_TS, 1, 30.0, 3, sleep_fn=sleep_fn
187+
)
188+
self.assertEqual(result, {("t", 0): 100, ("t", 1): 101})
189+
self.assertEqual(consumer.offsets_for_times.call_count, 3)
190+
self.assertEqual(sleep_fn.call_count, 1)
191+
192+
72193
class KafkaDatasetTest(unittest.TestCase):
73194
def setUp(self):
74195
credential = CredClient()

0 commit comments

Comments
 (0)