Skip to content

Commit f67a9d9

Browse files
tolik0claude
andauthored
fix(concurrent-cdk): enforce runtime cap on concurrent partition generators to prevent deadlock (#1015)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 19a7083 commit f67a9d9

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

airbyte_cdk/sources/concurrent_source/concurrent_read_processor.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def __init__(
4545
slice_logger: SliceLogger,
4646
message_repository: MessageRepository,
4747
partition_reader: PartitionReader,
48+
max_concurrent_partition_generators: Optional[int] = None,
4849
):
4950
"""
5051
This class is responsible for handling items from a concurrent stream read process.
@@ -55,15 +56,29 @@ def __init__(
5556
:param slice_logger: SliceLogger instance
5657
:param message_repository: MessageRepository instance
5758
:param partition_reader: PartitionReader instance
59+
:param max_concurrent_partition_generators: Maximum number of partition generators allowed
60+
to run concurrently. None means no limit. When set, should be less than the number of
61+
workers in multi-worker mode so at least one worker slot is always available for
62+
partition reading, preventing thread pool starvation. In single-threaded mode
63+
(num_workers=1) the value may equal num_workers; ConcurrentSource.create() handles
64+
this distinction. ConcurrentSource.read() passes this value explicitly.
5865
"""
5966
self._stream_name_to_instance = {s.name: s for s in stream_instances_to_read_from}
6067
self._record_counter = {}
6168
self._streams_to_running_partitions: Dict[str, Set[Partition]] = {}
6269
for stream in stream_instances_to_read_from:
6370
self._streams_to_running_partitions[stream.name] = set()
6471
self._record_counter[stream.name] = 0
72+
if (
73+
max_concurrent_partition_generators is not None
74+
and max_concurrent_partition_generators < 1
75+
):
76+
raise ValueError(
77+
f"max_concurrent_partition_generators must be >= 1 or None, got {max_concurrent_partition_generators}"
78+
)
6579
self._thread_pool_manager = thread_pool_manager
6680
self._partition_enqueuer = partition_enqueuer
81+
self._max_concurrent_partition_generators = max_concurrent_partition_generators
6782
self._stream_instances_to_start_partition_generation = stream_instances_to_read_from
6883
self._streams_currently_generating_partitions: List[str] = []
6984
self._logger = logger
@@ -255,6 +270,20 @@ def start_next_partition_generator(self) -> Optional[AirbyteMessage]:
255270
if not self._stream_instances_to_start_partition_generation:
256271
return None
257272

273+
# Enforce the concurrent generator cap so at least one worker slot is always available
274+
# for partition reading. Recovery is guaranteed: on_partition_generation_completed
275+
# decrements the count before calling here, so the guard always passes there.
276+
if (
277+
self._max_concurrent_partition_generators is not None
278+
and len(self._streams_currently_generating_partitions)
279+
>= self._max_concurrent_partition_generators
280+
):
281+
self._logger.debug(
282+
f"Concurrent partition generator cap ({self._max_concurrent_partition_generators}) reached "
283+
f"({len(self._streams_currently_generating_partitions)} active). Deferring next generator start."
284+
)
285+
return None
286+
258287
# Remember initial queue size to avoid infinite loops if all streams are blocked
259288
max_attempts = len(self._stream_instances_to_start_partition_generation)
260289
attempts = 0

airbyte_cdk/sources/concurrent_source/concurrent_source.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def create(
4747
queue: Optional[Queue[QueueItem]] = None,
4848
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
4949
) -> "ConcurrentSource":
50+
if initial_number_of_partitions_to_generate < 1:
51+
raise ValueError(
52+
f"initial_number_of_partitions_to_generate must be >= 1, got {initial_number_of_partitions_to_generate}"
53+
)
5054
is_single_threaded = initial_number_of_partitions_to_generate == 1 and num_workers == 1
5155
too_many_generator = (
5256
not is_single_threaded and initial_number_of_partitions_to_generate >= num_workers
@@ -117,6 +121,7 @@ def read(
117121
self._queue,
118122
PartitionLogger(self._slice_logger, self._logger, self._message_repository),
119123
),
124+
max_concurrent_partition_generators=self._initial_number_partitions_to_generate,
120125
)
121126

122127
# Enqueue initial partition generation tasks

unit_tests/sources/streams/concurrent/test_concurrent_read_processor.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,71 @@ def test_start_next_partition_generator(self):
851851
self._partition_enqueuer.generate_partitions, self._stream
852852
)
853853

854+
def test_invalid_max_concurrent_partition_generators_raises(self):
855+
for invalid in (0, -1):
856+
with self.assertRaises(ValueError):
857+
ConcurrentReadProcessor(
858+
[self._stream],
859+
self._partition_enqueuer,
860+
self._thread_pool_manager,
861+
self._logger,
862+
self._slice_logger,
863+
self._message_repository,
864+
self._partition_reader,
865+
max_concurrent_partition_generators=invalid,
866+
)
867+
868+
def test_start_next_partition_generator_respects_concurrent_limit(self):
869+
stream_instances_to_read_from = [self._stream]
870+
handler = ConcurrentReadProcessor(
871+
stream_instances_to_read_from,
872+
self._partition_enqueuer,
873+
self._thread_pool_manager,
874+
self._logger,
875+
self._slice_logger,
876+
self._message_repository,
877+
self._partition_reader,
878+
max_concurrent_partition_generators=1,
879+
)
880+
handler._streams_currently_generating_partitions.append(_STREAM_NAME)
881+
882+
status_message = handler.start_next_partition_generator()
883+
884+
assert status_message is None
885+
assert (
886+
handler._stream_instances_to_start_partition_generation == stream_instances_to_read_from
887+
)
888+
self._thread_pool_manager.submit.assert_not_called()
889+
890+
def test_start_next_partition_generator_starts_when_below_limit(self):
891+
other_stream = Mock(spec=AbstractStream)
892+
other_stream.name = "other_stream"
893+
other_stream.block_simultaneous_read = ""
894+
other_stream.as_airbyte_stream.return_value = AirbyteStream(
895+
name="other_stream",
896+
json_schema={},
897+
supported_sync_modes=[SyncMode.full_refresh],
898+
)
899+
handler = ConcurrentReadProcessor(
900+
[other_stream],
901+
self._partition_enqueuer,
902+
self._thread_pool_manager,
903+
self._logger,
904+
self._slice_logger,
905+
self._message_repository,
906+
self._partition_reader,
907+
max_concurrent_partition_generators=2,
908+
)
909+
handler._streams_currently_generating_partitions.append(_STREAM_NAME)
910+
911+
status_message = handler.start_next_partition_generator()
912+
913+
assert status_message is not None
914+
assert "other_stream" in handler._streams_currently_generating_partitions
915+
self._thread_pool_manager.submit.assert_called_with(
916+
self._partition_enqueuer.generate_partitions, other_stream
917+
)
918+
854919

855920
class TestBlockSimultaneousRead(unittest.TestCase):
856921
"""Tests for block_simultaneous_read functionality"""

0 commit comments

Comments
 (0)