Skip to content

Commit 249fe1a

Browse files
authored
dynamic configuration lookup: wait until the configs are fully read from the topic (#1036)
1 parent ae3cab7 commit 249fe1a

File tree

2 files changed

+138
-65
lines changed

2 files changed

+138
-65
lines changed

quixstreams/dataframe/joins/lookups/quix_configuration_service/lookup.py

Lines changed: 74 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
import logging
33
import threading
44
import time
5-
from collections import defaultdict
65
from typing import TYPE_CHECKING, Any, Literal, Mapping, Optional, Union
76

87
import httpx
98
import orjson
9+
from confluent_kafka import TopicPartition
1010

1111
from quixstreams.kafka import ConnectionConfig, Consumer
12+
from quixstreams.kafka.exceptions import KafkaConsumerException
1213
from quixstreams.models import HeadersMapping, Topic
1314
from quixstreams.platforms.quix.env import QUIX_ENVIRONMENT
1415

@@ -119,19 +120,17 @@ def __init__(
119120
self._version_data, maxsize=cache_size
120121
)
121122

122-
self._consumer = Consumer(
123+
self._config_consumer = Consumer(
123124
broker_address=broker_address,
124125
consumer_group=consumer_group,
125126
auto_offset_reset="earliest",
126127
auto_commit_enable=False,
127128
extra_config=consumer_extra_config,
128129
)
129130

130-
self._start()
131+
self._start_consumer_thread()
131132

132-
self._fields_by_type: dict[int, dict[str, dict[str, BaseField]]] = defaultdict(
133-
dict
134-
)
133+
self._fields_by_type: dict[int, dict[str, dict[str, BaseField]]] = {}
135134

136135
def json_field(
137136
self,
@@ -203,7 +202,7 @@ def _fetch_version_content(self, version: ConfigurationVersion) -> Optional[byte
203202
version.failed()
204203
return None
205204

206-
def _start(self) -> None:
205+
def _start_consumer_thread(self) -> None:
207206
"""
208207
Start the enrichment process in a background thread and wait for initialization to complete.
209208
"""
@@ -214,26 +213,31 @@ def _start(self) -> None:
214213

215214
def _consumer_thread(self) -> None:
216215
"""
217-
Background thread for consuming configuration events from Kafka and updating internal state.
216+
Background thread for consuming configuration events from Kafka
217+
and updating internal state.
218218
"""
219-
assigned = False
220-
221-
def on_assign(consumer: Consumer, partitions: list[tuple[str, int]]) -> None:
222-
"""
223-
Callback for partition assignment.
224-
"""
225-
nonlocal assigned
226-
assigned = True
227-
228219
try:
229-
self._consumer.subscribe(topics=[self._topic.name], on_assign=on_assign)
220+
# Assign all available partitions of the config updates topic
221+
# bypassing the consumer group protocol.
222+
tps = [
223+
TopicPartition(topic=self._topic.name, partition=i)
224+
for i in range(self._topic.broker_config.num_partitions or 0)
225+
]
226+
self._config_consumer.assign(tps)
230227

231228
while True:
232-
message = self._consumer.poll(timeout=self._consumer_poll_timeout)
229+
# Check if the consumer processed all the available config messages
230+
# and set the "started" event.
231+
if not self._started.is_set() and self._configs_ready():
232+
self._started.set()
233+
234+
message = self._config_consumer.poll(
235+
timeout=self._consumer_poll_timeout
236+
)
233237
if message is None:
234-
if assigned and not self._started.is_set():
235-
self._started.set()
236238
continue
239+
elif message.error():
240+
raise KafkaConsumerException(error=message.error())
237241

238242
value = message.value()
239243
if value is None:
@@ -267,7 +271,9 @@ def _process_config_event(self, event: Event) -> None:
267271
268272
:raises: RuntimeError: If the event type is unknown.
269273
"""
270-
logger.info(f"Processing event: {event['event']} for ID: {event['id']}")
274+
logger.info(
275+
f'Processing update for configuration ID "{event["id"]}" ({event["event"]})'
276+
)
271277
if event["event"] in {"created", "updated"}:
272278
if event["id"] not in self._configurations:
273279
logger.debug(f"Creating new configuration for ID: {event['id']}")
@@ -329,28 +335,35 @@ def _find_version(
329335
330336
:returns: The valid configuration version, or None if not found.
331337
"""
332-
logger.debug(
333-
f"Fetching data for type: {type}, on: {on}, timestamp: {timestamp}"
334-
)
338+
335339
configuration = self._configurations.get(self._config_id(type, on))
336-
if not configuration:
340+
if configuration is None:
337341
logger.debug(
338-
f"No configuration found for type: {type}, on: {on}. Trying wildcard."
342+
"No configuration found for type: %s, on: %s. Trying wildcard.",
343+
type,
344+
on,
339345
)
340346
configuration = self._configurations.get(self._config_id(type, "*"))
341-
if not configuration:
342-
logger.debug(f"No configuration found for type: {type}, on: *")
347+
if configuration is None:
348+
logger.debug("No configuration found for type: %s, on: *", type)
343349
return None
344350

345351
version = configuration.find_valid_version(timestamp)
346352
if version is None:
347353
logger.debug(
348-
f"No valid version found for type: {type}, on: {on}, timestamp: {timestamp}"
354+
"No valid configuration version found for type: %s, on: %s, timestamp: %s",
355+
type,
356+
on,
357+
timestamp,
349358
)
350359
return None
351360

352361
logger.debug(
353-
f"Found valid version '{version.version}' for type: {type}, on: {on}, timestamp: {timestamp}"
362+
"Found valid configuration version '%s' for type: %s, on: %s, timestamp: %s",
363+
version.version,
364+
type,
365+
on,
366+
timestamp,
354367
)
355368
return version
356369

@@ -416,18 +429,42 @@ def join(
416429
start = time.time()
417430
logger.debug(f"Joining message with key: {on}, timestamp: {timestamp}")
418431

419-
fields_by_type = self._fields_by_type.get(id(fields))
432+
fields_ids = id(fields)
433+
fields_by_type = self._fields_by_type.get(fields_ids)
434+
420435
if fields_by_type is None:
421-
fields_by_type = defaultdict(dict)
436+
fields_by_type = {}
422437
for key, field in fields.items():
423-
fields_by_type[field.type][key] = field
424-
self._fields_by_type[id(fields)] = fields_by_type
438+
fields_by_type.setdefault(field.type, {})[key] = field
439+
self._fields_by_type[fields_ids] = fields_by_type
440+
441+
for type_, fields in fields_by_type.items():
442+
version = self._find_version(type_, on, timestamp)
425443

426-
for type, fields in fields_by_type.items():
427-
version = self._find_version(type, on, timestamp)
428444
if version is not None and version.retry_at < start:
429445
self._version_data_cached.remove(version, fields)
430446

431447
value.update(self._version_data_cached(version, fields))
432448

433449
logger.debug("Join took %.2f ms", (time.time() - start) * 1000)
450+
451+
def _configs_ready(self) -> bool:
452+
"""
453+
Return True if the configs are loaded from the topic until the available HWM.
454+
If there are outstanding messages in the config topic or the assignment is not
455+
available yet, return False.
456+
"""
457+
458+
if not self._config_consumer.assignment():
459+
return False
460+
461+
positions = self._config_consumer.position(self._config_consumer.assignment())
462+
for position in positions:
463+
# Check if the consumer reached the end of the configuration topic
464+
# for each assigned partition.
465+
_, hwm = self._config_consumer.get_watermark_offsets(
466+
partition=position, cached=True
467+
)
468+
if hwm < 0 or (hwm > 0 and position.offset < hwm):
469+
return False
470+
return True

tests/test_quixstreams/test_dataframe/test_joins/test_lookup_quix_config.py

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
import time
3+
import uuid
34
from datetime import datetime
45
from typing import Optional
56
from unittest.mock import Mock, patch
@@ -30,7 +31,7 @@ def lookup():
3031
patch(
3132
"quixstreams.dataframe.joins.lookups.quix_configuration_service.lookup.httpx.Client"
3233
),
33-
patch.object(Lookup, "_start"),
34+
patch.object(Lookup, "_start_consumer_thread"),
3435
):
3536
lookup = Lookup(
3637
topic=mock_topic,
@@ -59,36 +60,36 @@ def create_configuration_version(
5960
)
6061

6162

63+
def create_event(
64+
id: str = "test-config",
65+
version: int = 1,
66+
valid_from: Optional[str] = None,
67+
sha256sum: str = "test-hash",
68+
content_url: str = "http://example.com/config",
69+
) -> Event:
70+
"""Helper method to create test events."""
71+
return {
72+
"id": id,
73+
"event": "created",
74+
"contentUrl": content_url,
75+
"metadata": {
76+
"type": "test-type",
77+
"target_key": "test-key",
78+
"valid_from": valid_from,
79+
"category": "test-category",
80+
"version": version,
81+
"created_at": "2025-01-01T00:00:00Z",
82+
"sha256sum": sha256sum,
83+
},
84+
}
85+
86+
6287
class TestConfiguration:
6388
"""Test suite for the Configuration class."""
6489

65-
def create_event(
66-
self,
67-
id: str = "test-config",
68-
version: int = 1,
69-
valid_from: Optional[str] = None,
70-
sha256sum: str = "test-hash",
71-
content_url: str = "http://example.com/config",
72-
) -> Event:
73-
"""Helper method to create test events."""
74-
return {
75-
"id": id,
76-
"event": "created",
77-
"contentUrl": content_url,
78-
"metadata": {
79-
"type": "test-type",
80-
"target_key": "test-key",
81-
"valid_from": valid_from,
82-
"category": "test-category",
83-
"version": version,
84-
"created_at": "2025-01-01T00:00:00Z",
85-
"sha256sum": sha256sum,
86-
},
87-
}
88-
8990
def test_from_event(self):
9091
"""Test creating a Configuration from an Event."""
91-
event = self.create_event(version=1, valid_from="2025-01-01T12:00:00Z")
92+
event = create_event(version=1, valid_from="2025-01-01T12:00:00Z")
9293
config = Configuration.from_event(event)
9394

9495
assert len(config.versions) == 1
@@ -469,10 +470,10 @@ def test_frozen_dataclass_immutability(self):
469470

470471
# Should not be able to modify frozen fields
471472
with pytest.raises((AttributeError, TypeError)):
472-
version.id = "modified" # type: ignore
473+
setattr(version, "id", "modified")
473474

474475
with pytest.raises((AttributeError, TypeError)):
475-
version.version = 2 # type: ignore
476+
setattr(version, "version", 2)
476477

477478
# But should be able to modify non-frozen fields via the methods
478479
version.failed()
@@ -1270,3 +1271,38 @@ def test_join_bytes_field_with_encoded_string(self, lookup):
12701271

12711272
# Verify new data is added
12721273
assert value["binary_data"].decode("utf-8") == test_content
1274+
1275+
1276+
class TestLookupInit:
1277+
"""
1278+
Test that Lookup loads all available configurations from the config topic
1279+
on init.
1280+
"""
1281+
1282+
def test_lookup_init_config_topic_empty(self, app_factory):
1283+
app = app_factory()
1284+
config_topic = app.topic("configs")
1285+
1286+
lookup = Lookup(
1287+
topic=config_topic,
1288+
app_config=app.config,
1289+
)
1290+
assert not lookup._configurations
1291+
1292+
def test_lookup_init_config_topic_not_empty(self, app_factory):
1293+
app = app_factory()
1294+
config_topic = app.topic("configs")
1295+
1296+
num_configs = 50
1297+
with app.get_producer() as producer:
1298+
for _ in range(num_configs):
1299+
config_event = create_event(id=str(uuid.uuid4()))
1300+
producer.produce(
1301+
topic=config_topic.name, value=orjson.dumps(config_event)
1302+
)
1303+
1304+
lookup = Lookup(
1305+
topic=config_topic,
1306+
app_config=app.config,
1307+
)
1308+
assert len(lookup._configurations) == num_configs

0 commit comments

Comments
 (0)