Skip to content

Commit 236d5ab

Browse files
authored
Merge pull request #11 from iterorganization/feature/kafka-seek-most-recent-message
Implement option to only consume most recent data on the Kafka topic
2 parents c0bdb55 + df0b65e commit 236d5ab

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

src/imas_streams/kafka.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
_INITIAL_BACKOFF_TIME = 0.02 # seconds
2727
_MAXIMUM_BACKOFF_TIME = 1.0 # seconds
2828
_STREAMING_HEADER_KEY = "streaming-imas-metadata"
29+
# Kafka server will wait maximal _FETCH_WAIT_MAX_MS before sending new messages to the
30+
# consumer. Adjusted from the default (500ms) to decrease latency, especially when using
31+
# "most_recent_only" since a seek() needs to wait at least this amount of time before it
32+
# is effective (see KafkaConsumer._fast_forward()).
33+
_FETCH_WAIT_MAX_MS = 50 # milli-seconds
2934

3035

3136
class KafkaSettings(BaseModel):
@@ -151,7 +156,8 @@ def __init__(
151156
settings: KafkaSettings,
152157
stream_consumer_cls: type[StreamConsumer],
153158
*,
154-
timeout=DEFAULT_KAFKA_CONSUMER_TIMEOUT,
159+
timeout: int = DEFAULT_KAFKA_CONSUMER_TIMEOUT,
160+
most_recent_only: bool = False,
155161
**stream_consumer_kwargs,
156162
) -> None:
157163
"""Create a new KafkaConsumer.
@@ -163,13 +169,23 @@ def __init__(
163169
settings: Kafka host and topic to connect to.
164170
stream_consumer_cls: StreamConsumer type used for processing the received
165171
messages.
172+
173+
Keyword Args:
166174
timeout: Maximum time (in seconds) to wait for the topic.
175+
most_recent_only: Set to True to only receive the most recent message with
176+
every iteration of ``stream()``.
177+
stream_consumser_kwargs: any additional keyword arguments are forwarded to
178+
the constructor of ``stream_consumer_cls``.
167179
"""
168180
self._settings = settings
181+
self._most_recent_only = most_recent_only
169182
conf = {
170183
"bootstrap.servers": settings.host,
171184
"auto.offset.reset": "earliest",
172185
"group.id": str(uuid.uuid4()),
186+
# This influences the latency of receiving messages. Also impacts the seek()
187+
# in self._fast_forward()!
188+
"fetch.wait.max.ms": _FETCH_WAIT_MAX_MS,
173189
}
174190
self._consumer = confluent_kafka.Consumer(conf)
175191

@@ -220,7 +236,7 @@ def _subscribe(self, timeout) -> StreamingIMASMetadata:
220236
raise RuntimeError("Timeout reached while waiting for streaming metadata.")
221237
if msg.error() is not None:
222238
raise msg.error()
223-
headers = dict(msg.headers())
239+
headers = dict(msg.headers() or [])
224240
if _STREAMING_HEADER_KEY not in headers:
225241
raise RuntimeError(
226242
f"Topic '{topic_name}' does not contain IMAS streaming metadata."
@@ -244,6 +260,8 @@ def stream(self, *, timeout=DEFAULT_KAFKA_CONSUMER_TIMEOUT) -> Iterator[Any]:
244260
"""
245261
try:
246262
while True:
263+
if self._most_recent_only:
264+
self._fast_forward()
247265
msg = self._consumer.poll(timeout)
248266
if msg is None:
249267
logger.info(
@@ -263,3 +281,19 @@ def stream(self, *, timeout=DEFAULT_KAFKA_CONSUMER_TIMEOUT) -> Iterator[Any]:
263281
finally:
264282
self._consumer.commit()
265283
self._consumer.close()
284+
285+
def _fast_forward(self) -> None:
286+
"""Fast forward the Kafka stream, so the last available message will be returned
287+
next."""
288+
assignment = self._consumer.assignment()
289+
if len(assignment) != 1:
290+
raise RuntimeError(f"Expected a single topic assignment, got {assignment}")
291+
cur_offset = self._consumer.position(assignment)[0]
292+
_, high_watermark = self._consumer.get_watermark_offsets(assignment[0])
293+
# Check if we're not already at the end
294+
if cur_offset.offset < high_watermark - 1:
295+
cur_offset.offset = high_watermark - 1
296+
self._consumer.seek(cur_offset)
297+
# Wait for the in-flight request to return, seek takes effect afterwards
298+
# See: https://www.feldera.com/blog/seeking-in-kafka
299+
time.sleep(_FETCH_WAIT_MAX_MS / 1000)

tests/test_kafka.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,30 @@ def test_kafka_producer_consumer(kafka_host, test_magnetics):
7474
assert i == 4 # We should have received 5 messages
7575

7676

77+
def test_kafka_producer_consumer_most_recent_only(kafka_host, test_magnetics):
78+
ids_producer = StreamingIDSProducer(test_magnetics)
79+
settings = KafkaSettings(host=kafka_host, topic_name="test")
80+
kafka_producer = KafkaProducer(settings, ids_producer.metadata)
81+
82+
for i in range(5):
83+
test_magnetics.time[0] = i
84+
test_magnetics.flux_loop[0].flux.data[0] = 1 - i / 10
85+
86+
message = ids_producer.create_message(test_magnetics)
87+
kafka_producer.produce(bytes(message))
88+
89+
kafka_consumer = KafkaConsumer(
90+
settings, StreamingIDSConsumer, most_recent_only=True
91+
)
92+
result = list(kafka_consumer.stream(timeout=0.1))
93+
# We should have only the most recent message at i=4
94+
assert len(result) == 1
95+
ids = result[0]
96+
assert ids.time[0] == 4
97+
assert ids.flux_loop[0].name == "test"
98+
assert ids.flux_loop[0].flux.data[0] == 1 - 4 / 10
99+
100+
77101
def test_kafka_producer_topic_exists(kafka_host, test_magnetics):
78102
ids_producer = StreamingIDSProducer(test_magnetics)
79103
settings = KafkaSettings(host=kafka_host, topic_name="test")

0 commit comments

Comments
 (0)