Skip to content

Commit 7be739d

Browse files
kafka_consumer: cache earliest offsets across collection intervals (DataDog#24515)
* kafka_consumer: cache earliest offsets across collection intervals Log start offsets only move via the broker's log-cleaner cycle (log.retention.check.interval.ms), so refetching them on every check run is unnecessary broker load. Cache the result with a TTL derived from that broker config, clamped to a sane range. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Add changelog entry Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * kafka_consumer: trim redundant comments, add earliest-offsets cache test Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address review feedback: avoid an undocumented metric and refetch missing partitions on a fresh cache log.retention.check.interval.ms is now read from broker config data purely to derive the earliest-offsets cache TTL, without being added to the metric emission list. fetch_earliest_offsets also now refetches from the broker when a fresh cache doesn't cover every requested partition (e.g. newly added partitions), while keeping the cache's original expiration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6176ea1 commit 7be739d

3 files changed

Lines changed: 133 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cache the earliest (log-start) offset used for `kafka.topic.size` and `kafka.partition.size` across collection intervals instead of refetching it from the broker on every run.

kafka_consumer/datadog_checks/kafka_consumer/cluster_metadata.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,14 @@ def __init__(self, check, client, config, log):
6868
self.SCHEMA_COMPATIBILITY_FETCH_CACHE_MAX_SIZE = 20_000
6969
self.SCHEMA_ID_CACHE_MAX_SIZE = 20_000
7070

71+
self.EARLIEST_OFFSETS_DEFAULT_TTL = 300 # 5 minutes, matches Kafka's own broker default
72+
self.EARLIEST_OFFSETS_MIN_TTL = 60
73+
self.EARLIEST_OFFSETS_MAX_TTL = 1800
74+
self._log_retention_check_interval_s: float | None = None
75+
7176
self.BROKER_CONFIG_CACHE_KEY = 'kafka_broker_config_cache'
7277
self.BROKER_CONFIG_FETCH_CACHE_KEY = 'kafka_broker_config_fetch_cache'
78+
self.EARLIEST_OFFSETS_CACHE_KEY = 'kafka_earliest_offsets_cache'
7379
self.TOPIC_CONFIG_CACHE_KEY = 'kafka_topic_config_cache'
7480
self.TOPIC_CONFIG_FETCH_CACHE_KEY = 'kafka_topic_config_fetch_cache'
7581
self.TOPIC_HWM_SUM_CACHE_KEY = 'kafka_topic_hwm_sum_cache'
@@ -348,6 +354,21 @@ def _collect_broker_metadata(self, metadata=None):
348354
config_data[config_name],
349355
)
350356

357+
retention_check_interval_ms = config_data.get('log.retention.check.interval.ms')
358+
if retention_check_interval_ms:
359+
try:
360+
interval_s = float(retention_check_interval_ms) / 1000
361+
if interval_s > 0:
362+
self._log_retention_check_interval_s = min(
363+
interval_s, self._log_retention_check_interval_s or interval_s
364+
)
365+
except (ValueError, TypeError):
366+
self.log.debug(
367+
"Could not convert broker %s config log.retention.check.interval.ms value %r to float",
368+
broker_id_str,
369+
retention_check_interval_ms,
370+
)
371+
351372
truncated_config = self._truncate_config_for_event(config_data, max_configs=50)
352373
event_text = json.dumps(truncated_config, indent=2, sort_keys=True)
353374

@@ -386,7 +407,66 @@ def _collect_broker_metadata(self, metadata=None):
386407
"data-streams-message",
387408
)
388409

410+
def _topic_partition_pairs(self, topic_partitions):
411+
return {
412+
(topic, partition)
413+
for topic, partitions in topic_partitions.items()
414+
if topic not in KAFKA_INTERNAL_TOPICS
415+
for partition in partitions
416+
}
417+
418+
def _earliest_offsets_ttl(self) -> float:
419+
"""TTL for the earliest-offsets cache, derived from the broker's log-cleaner cycle."""
420+
ttl = self._log_retention_check_interval_s or self.EARLIEST_OFFSETS_DEFAULT_TTL
421+
return max(self.EARLIEST_OFFSETS_MIN_TTL, min(self.EARLIEST_OFFSETS_MAX_TTL, ttl))
422+
423+
def _load_earliest_offsets_cache(self) -> dict[str, Any] | None:
424+
try:
425+
cached_str = self.check.read_persistent_cache(self.EARLIEST_OFFSETS_CACHE_KEY)
426+
if not cached_str:
427+
return None
428+
data = json.loads(cached_str)
429+
return {
430+
'expire_at': data['expire_at'],
431+
'offsets': {(topic, partition): offset for topic, partition, offset in data['offsets']},
432+
}
433+
except Exception as e:
434+
self.log.debug("Could not read earliest offsets cache: %s", e)
435+
return None
436+
437+
def _save_earliest_offsets_cache(self, offsets: dict[tuple[str, int], int], expire_at: float | None = None) -> None:
438+
try:
439+
payload = {
440+
'expire_at': expire_at if expire_at is not None else time.time() + self._earliest_offsets_ttl(),
441+
'offsets': [[topic, partition, offset] for (topic, partition), offset in offsets.items()],
442+
}
443+
self.check.write_persistent_cache(self.EARLIEST_OFFSETS_CACHE_KEY, json.dumps(payload))
444+
except Exception as e:
445+
self.log.debug("Could not write earliest offsets cache: %s", e)
446+
389447
def fetch_earliest_offsets(self, topic_partitions):
448+
"""Return cached log-start offsets, refetching from the broker only once the TTL expires."""
449+
requested = self._topic_partition_pairs(topic_partitions)
450+
if not requested:
451+
return {}
452+
453+
cached = self._load_earliest_offsets_cache()
454+
if cached is not None and time.time() < cached['expire_at']:
455+
cached_offsets = {tp: offset for tp, offset in cached['offsets'].items() if tp in requested}
456+
if cached_offsets.keys() == requested:
457+
return cached_offsets
458+
459+
result = self._fetch_earliest_offsets_from_broker(topic_partitions)
460+
if result:
461+
self._save_earliest_offsets_cache(result, expire_at=cached['expire_at'])
462+
return result
463+
464+
result = self._fetch_earliest_offsets_from_broker(topic_partitions)
465+
if result:
466+
self._save_earliest_offsets_cache(result)
467+
return result
468+
469+
def _fetch_earliest_offsets_from_broker(self, topic_partitions):
390470
"""Batch-fetch log-start offsets via AdminClient.list_offsets(earliest).
391471
392472
Uses ListOffsets with the EARLIEST_TIMESTAMP sentinel, which the broker
@@ -397,9 +477,7 @@ def fetch_earliest_offsets(self, topic_partitions):
397477
"""
398478
requests = {
399479
TopicPartition(topic, partition): OffsetSpec.earliest()
400-
for topic, partitions in topic_partitions.items()
401-
if topic not in KAFKA_INTERNAL_TOPICS
402-
for partition in partitions
480+
for topic, partition in self._topic_partition_pairs(topic_partitions)
403481
}
404482
if not requests:
405483
return {}

kafka_consumer/tests/test_cluster_metadata.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,57 @@ def test_kafka_configs_refresh_interval(check, interval, expected_interval, expe
11351135
assert collector.cache.refresh_jitter == expected_jitter
11361136

11371137

1138+
def test_fetch_earliest_offsets_cached_across_calls(check):
1139+
"""fetch_earliest_offsets should hit the broker once, then serve later calls from cache."""
1140+
instance = {
1141+
'kafka_connect_str': 'localhost:9092',
1142+
'enable_cluster_monitoring': True,
1143+
}
1144+
kafka_consumer_check = check(instance)
1145+
mock_kafka_client = seed_mock_kafka_client()
1146+
kafka_consumer_check.client = mock_kafka_client
1147+
kafka_consumer_check.metadata_collector.client = mock_kafka_client
1148+
1149+
_wire_cache(kafka_consumer_check)
1150+
1151+
collector = kafka_consumer_check.metadata_collector
1152+
topic_partitions = {'test-topic': [0, 1]}
1153+
1154+
first = collector.fetch_earliest_offsets(topic_partitions)
1155+
second = collector.fetch_earliest_offsets(topic_partitions)
1156+
1157+
expected = {('test-topic', 0): 10, ('test-topic', 1): 20}
1158+
assert first == expected
1159+
assert second == expected
1160+
assert mock_kafka_client.kafka_client.list_offsets.call_count == 1
1161+
1162+
1163+
def test_fetch_earliest_offsets_refetches_when_cache_missing_partitions(check):
1164+
"""A fresh cache that doesn't cover every requested partition triggers a full refetch, keeping the same TTL."""
1165+
instance = {
1166+
'kafka_connect_str': 'localhost:9092',
1167+
'enable_cluster_monitoring': True,
1168+
}
1169+
kafka_consumer_check = check(instance)
1170+
mock_kafka_client = seed_mock_kafka_client()
1171+
kafka_consumer_check.client = mock_kafka_client
1172+
kafka_consumer_check.metadata_collector.client = mock_kafka_client
1173+
1174+
collector = kafka_consumer_check.metadata_collector
1175+
expire_at = time.time() + 300
1176+
seed_payload = json.dumps({'expire_at': expire_at, 'offsets': [['test-topic', 0, 10]]})
1177+
_wire_cache(kafka_consumer_check, seed={collector.EARLIEST_OFFSETS_CACHE_KEY: seed_payload})
1178+
1179+
topic_partitions = {'test-topic': [0, 1]}
1180+
result = collector.fetch_earliest_offsets(topic_partitions)
1181+
1182+
assert result == {('test-topic', 0): 10, ('test-topic', 1): 20}
1183+
assert mock_kafka_client.kafka_client.list_offsets.call_count == 1
1184+
1185+
saved = json.loads(kafka_consumer_check.write_persistent_cache.call_args[0][1])
1186+
assert saved['expire_at'] == expire_at
1187+
1188+
11381189
def test_schema_registry_oauth_oidc_token(check, dd_run_check, aggregator):
11391190
"""Test that OIDC OAuth token is fetched and passed as Bearer header for Schema Registry."""
11401191
instance = {

0 commit comments

Comments
 (0)