airbyte_cdk.sources.concurrent_source.concurrent_read_processor

  1#
  2# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
  3#
  4import logging
  5import os
  6from typing import Dict, Iterable, List, Optional, Set
  7
  8from airbyte_cdk.exception_handler import generate_failed_streams_error_message
  9from airbyte_cdk.models import AirbyteMessage, AirbyteStreamStatus, FailureType, StreamDescriptor
 10from airbyte_cdk.models import Type as MessageType
 11from airbyte_cdk.sources.concurrent_source.partition_generation_completed_sentinel import (
 12    PartitionGenerationCompletedSentinel,
 13)
 14from airbyte_cdk.sources.concurrent_source.stream_thread_exception import StreamThreadException
 15from airbyte_cdk.sources.concurrent_source.thread_pool_manager import ThreadPoolManager
 16from airbyte_cdk.sources.declarative.partition_routers.cartesian_product_stream_slicer import (
 17    CartesianProductStreamSlicer,
 18)
 19from airbyte_cdk.sources.declarative.partition_routers.grouping_partition_router import (
 20    GroupingPartitionRouter,
 21)
 22from airbyte_cdk.sources.declarative.partition_routers.substream_partition_router import (
 23    SubstreamPartitionRouter,
 24)
 25from airbyte_cdk.sources.declarative.partition_routers.union_partition_router import (
 26    UnionPartitionRouter,
 27)
 28from airbyte_cdk.sources.message import MessageRepository
 29from airbyte_cdk.sources.streams.concurrent.abstract_stream import AbstractStream
 30from airbyte_cdk.sources.streams.concurrent.default_stream import DefaultStream
 31from airbyte_cdk.sources.streams.concurrent.partition_enqueuer import PartitionEnqueuer
 32from airbyte_cdk.sources.streams.concurrent.partition_reader import PartitionReader
 33from airbyte_cdk.sources.streams.concurrent.partitions.partition import Partition
 34from airbyte_cdk.sources.streams.concurrent.partitions.types import PartitionCompleteSentinel
 35from airbyte_cdk.sources.types import Record
 36from airbyte_cdk.sources.utils.record_helper import stream_data_to_airbyte_message
 37from airbyte_cdk.sources.utils.slice_logger import SliceLogger
 38from airbyte_cdk.utils import AirbyteTracedException
 39from airbyte_cdk.utils.stream_status_utils import (
 40    as_airbyte_message as stream_status_as_airbyte_message,
 41)
 42
 43
 44class ConcurrentReadProcessor:
 45    def __init__(
 46        self,
 47        stream_instances_to_read_from: List[AbstractStream],
 48        partition_enqueuer: PartitionEnqueuer,
 49        thread_pool_manager: ThreadPoolManager,
 50        logger: logging.Logger,
 51        slice_logger: SliceLogger,
 52        message_repository: MessageRepository,
 53        partition_reader: PartitionReader,
 54        max_concurrent_partition_generators: Optional[int] = None,
 55    ):
 56        """
 57        This class is responsible for handling items from a concurrent stream read process.
 58        :param stream_instances_to_read_from: List of streams to read from
 59        :param partition_enqueuer: PartitionEnqueuer instance
 60        :param thread_pool_manager: ThreadPoolManager instance
 61        :param logger: Logger instance
 62        :param slice_logger: SliceLogger instance
 63        :param message_repository: MessageRepository instance
 64        :param partition_reader: PartitionReader instance
 65        :param max_concurrent_partition_generators: Maximum number of partition generators allowed
 66            to run concurrently. None means no limit. When set, should be less than the number of
 67            workers in multi-worker mode so at least one worker slot is always available for
 68            partition reading, preventing thread pool starvation. In single-threaded mode
 69            (num_workers=1) the value may equal num_workers; ConcurrentSource.create() handles
 70            this distinction. ConcurrentSource.read() passes this value explicitly.
 71        """
 72        self._stream_name_to_instance = {s.name: s for s in stream_instances_to_read_from}
 73        self._record_counter = {}
 74        self._streams_to_running_partitions: Dict[str, Set[Partition]] = {}
 75        for stream in stream_instances_to_read_from:
 76            self._streams_to_running_partitions[stream.name] = set()
 77            self._record_counter[stream.name] = 0
 78        if (
 79            max_concurrent_partition_generators is not None
 80            and max_concurrent_partition_generators < 1
 81        ):
 82            raise ValueError(
 83                f"max_concurrent_partition_generators must be >= 1 or None, got {max_concurrent_partition_generators}"
 84            )
 85        self._thread_pool_manager = thread_pool_manager
 86        self._partition_enqueuer = partition_enqueuer
 87        self._max_concurrent_partition_generators = max_concurrent_partition_generators
 88        self._stream_instances_to_start_partition_generation = stream_instances_to_read_from
 89        self._streams_currently_generating_partitions: List[str] = []
 90        self._logger = logger
 91        self._slice_logger = slice_logger
 92        self._message_repository = message_repository
 93        self._partition_reader = partition_reader
 94        self._streams_done: Set[str] = set()
 95        self._exceptions_per_stream_name: dict[str, List[Exception]] = {}
 96
 97        # Track which streams (by name) are currently active
 98        # A stream is "active" if it's generating partitions or has partitions being read
 99        self._active_stream_names: Set[str] = set()
100
101        # Store blocking group names for streams that require blocking simultaneous reads
102        # Maps stream name -> group name (empty string means no blocking)
103        self._stream_block_simultaneous_read: Dict[str, str] = {
104            stream.name: stream.block_simultaneous_read for stream in stream_instances_to_read_from
105        }
106
107        # Track which groups are currently active
108        # Maps group name -> set of stream names in that group
109        self._active_groups: Dict[str, Set[str]] = {}
110
111        for stream in stream_instances_to_read_from:
112            if stream.block_simultaneous_read:
113                self._logger.info(
114                    f"Stream '{stream.name}' is in blocking group '{stream.block_simultaneous_read}'. "
115                    f"Will defer starting this stream if another stream in the same group or its parents are active."
116                )
117
118    def on_partition_generation_completed(
119        self, sentinel: PartitionGenerationCompletedSentinel
120    ) -> Iterable[AirbyteMessage]:
121        """
122        This method is called when a partition generation is completed.
123        1. Remove the stream from the list of streams currently generating partitions
124        2. Deactivate parent streams (they were only needed for partition generation)
125        3. If the stream is done, mark it as such and return a stream status message
126        4. If there are more streams to read from, start the next partition generator
127        """
128        stream_name = sentinel.stream.name
129        self._streams_currently_generating_partitions.remove(sentinel.stream.name)
130
131        # Deactivate all parent streams now that partition generation is complete
132        # Parents were only needed to generate slices, they can now be reused
133        parent_streams = self._collect_all_parent_stream_names(stream_name)
134        for parent_stream_name in parent_streams:
135            if parent_stream_name in self._active_stream_names:
136                self._logger.debug(f"Removing '{parent_stream_name}' from active streams")
137                self._active_stream_names.discard(parent_stream_name)
138
139                # Remove from active groups
140                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
141                if parent_group:
142                    if parent_group in self._active_groups:
143                        self._active_groups[parent_group].discard(parent_stream_name)
144                        if not self._active_groups[parent_group]:
145                            del self._active_groups[parent_group]
146                    self._logger.info(
147                        f"Parent stream '{parent_stream_name}' (group '{parent_group}') deactivated after "
148                        f"partition generation completed for child '{stream_name}'. "
149                        f"Blocked streams in the queue will be retried on next start_next_partition_generator call."
150                    )
151
152        # It is possible for the stream to already be done if no partitions were generated
153        # If the partition generation process was completed and there are no partitions left to process, the stream is done
154        if (
155            self._is_stream_done(stream_name)
156            or len(self._streams_to_running_partitions[stream_name]) == 0
157        ):
158            yield from self._on_stream_is_done(stream_name)
159        if self._stream_instances_to_start_partition_generation:
160            status_message = self.start_next_partition_generator()
161            if status_message:
162                yield status_message
163
164    def on_partition(self, partition: Partition) -> None:
165        """
166        This method is called when a partition is generated.
167        1. Add the partition to the set of partitions for the stream
168        2. Log the slice if necessary
169        3. Submit the partition to the thread pool manager
170        """
171        stream_name = partition.stream_name()
172        self._streams_to_running_partitions[stream_name].add(partition)
173        cursor = self._stream_name_to_instance[stream_name].cursor
174        if self._slice_logger.should_log_slice_message(self._logger):
175            self._message_repository.emit_message(
176                self._slice_logger.create_slice_log_message(partition.to_slice())
177            )
178        self._thread_pool_manager.submit(
179            self._partition_reader.process_partition, partition, cursor
180        )
181
182    def on_partition_complete_sentinel(
183        self, sentinel: PartitionCompleteSentinel
184    ) -> Iterable[AirbyteMessage]:
185        """
186        This method is called when a partition is completed.
187        1. Close the partition
188        2. If the stream is done, mark it as such and return a stream status message
189        3. Emit messages that were added to the message repository
190        4. If there are more streams to read from, start the next partition generator
191        """
192        partition = sentinel.partition
193
194        partitions_running = self._streams_to_running_partitions[partition.stream_name()]
195        if partition in partitions_running:
196            partitions_running.remove(partition)
197            # If all partitions were generated and this was the last one, the stream is done
198            if (
199                partition.stream_name() not in self._streams_currently_generating_partitions
200                and len(partitions_running) == 0
201            ):
202                yield from self._on_stream_is_done(partition.stream_name())
203                # Try to start the next stream in the queue (may be a deferred stream)
204                if self._stream_instances_to_start_partition_generation:
205                    status_message = self.start_next_partition_generator()
206                    if status_message:
207                        yield status_message
208        yield from self._message_repository.consume_queue()
209
210    def on_record(self, record: Record) -> Iterable[AirbyteMessage]:
211        """
212        This method is called when a record is read from a partition.
213        1. Convert the record to an AirbyteMessage
214        2. If this is the first record for the stream, mark the stream as RUNNING
215        3. Increment the record counter for the stream
216        4. Ensures the cursor knows the record has been successfully emitted
217        5. Emit the message
218        6. Emit messages that were added to the message repository
219        """
220        # Do not pass a transformer or a schema
221        # AbstractStreams are expected to return data as they are expected.
222        # Any transformation on the data should be done before reaching this point
223        message = stream_data_to_airbyte_message(
224            stream_name=record.stream_name,
225            data_or_message=record.data,
226            file_reference=record.file_reference,
227        )
228        stream = self._stream_name_to_instance[record.stream_name]
229
230        if message.type == MessageType.RECORD:
231            if self._record_counter[stream.name] == 0:
232                self._logger.info(f"Marking stream {stream.name} as RUNNING")
233                yield stream_status_as_airbyte_message(
234                    stream.as_airbyte_stream(), AirbyteStreamStatus.RUNNING
235                )
236            self._record_counter[stream.name] += 1
237        yield message
238        yield from self._message_repository.consume_queue()
239
240    def on_exception(self, exception: StreamThreadException) -> Iterable[AirbyteMessage]:
241        """
242        This method is called when an exception is raised.
243        1. Stop all running streams
244        2. Raise the exception
245        """
246        self._flag_exception(exception.stream_name, exception.exception)
247        self._logger.exception(
248            f"Exception while syncing stream {exception.stream_name}", exc_info=exception.exception
249        )
250
251        stream_descriptor = StreamDescriptor(name=exception.stream_name)
252        if isinstance(exception.exception, AirbyteTracedException):
253            yield exception.exception.as_airbyte_message(stream_descriptor=stream_descriptor)
254        else:
255            yield AirbyteTracedException.from_exception(
256                exception.exception,
257                stream_descriptor=stream_descriptor,
258                message=f"An unexpected error occurred in stream {exception.stream_name}: {type(exception.exception).__name__}",
259            ).as_airbyte_message()
260
261    def _flag_exception(self, stream_name: str, exception: Exception) -> None:
262        self._exceptions_per_stream_name.setdefault(stream_name, []).append(exception)
263
264    def start_next_partition_generator(self) -> Optional[AirbyteMessage]:
265        """
266        Submits the next partition generator to the thread pool.
267
268        A stream will be deferred (moved to end of queue) if:
269        1. The stream itself has block_simultaneous_read=True AND is already active
270        2. Any parent stream has block_simultaneous_read=True AND is currently active
271
272        This prevents simultaneous reads of streams that shouldn't be accessed concurrently.
273
274        :return: A status message if a partition generator was started, otherwise None
275        """
276        if not self._stream_instances_to_start_partition_generation:
277            return None
278
279        # Enforce the concurrent generator cap so at least one worker slot is always available
280        # for partition reading. Recovery is guaranteed: on_partition_generation_completed
281        # decrements the count before calling here, so the guard always passes there.
282        if (
283            self._max_concurrent_partition_generators is not None
284            and len(self._streams_currently_generating_partitions)
285            >= self._max_concurrent_partition_generators
286        ):
287            self._logger.debug(
288                f"Concurrent partition generator cap ({self._max_concurrent_partition_generators}) reached "
289                f"({len(self._streams_currently_generating_partitions)} active). Deferring next generator start."
290            )
291            return None
292
293        # Remember initial queue size to avoid infinite loops if all streams are blocked
294        max_attempts = len(self._stream_instances_to_start_partition_generation)
295        attempts = 0
296
297        while self._stream_instances_to_start_partition_generation and attempts < max_attempts:
298            attempts += 1
299
300            # Pop the first stream from the queue
301            stream = self._stream_instances_to_start_partition_generation.pop(0)
302            stream_name = stream.name
303            stream_group = self._stream_block_simultaneous_read.get(stream_name, "")
304
305            # Check if this stream has a blocking group and is already active as parent stream
306            # (i.e. being read from during partition generation for another stream)
307            if stream_group and stream_name in self._active_stream_names:
308                # Add back to the END of the queue for retry later
309                self._stream_instances_to_start_partition_generation.append(stream)
310                self._logger.info(
311                    f"Deferring stream '{stream_name}' (group '{stream_group}') because it's already active. Trying next stream."
312                )
313                continue  # Try the next stream in the queue
314
315            # Check if this stream's group is already active (another stream in the same group is running)
316            if (
317                stream_group
318                and stream_group in self._active_groups
319                and self._active_groups[stream_group]
320            ):
321                # Add back to the END of the queue for retry later
322                self._stream_instances_to_start_partition_generation.append(stream)
323                active_streams_in_group = self._active_groups[stream_group]
324                self._logger.info(
325                    f"Deferring stream '{stream_name}' (group '{stream_group}') because other stream(s) "
326                    f"{active_streams_in_group} in the same group are active. Trying next stream."
327                )
328                continue  # Try the next stream in the queue
329
330            # Check if any parent streams have a blocking group and are currently active
331            parent_streams = self._collect_all_parent_stream_names(stream_name)
332            blocked_by_parents = [
333                p
334                for p in parent_streams
335                if self._stream_block_simultaneous_read.get(p, "")
336                and p in self._active_stream_names
337            ]
338
339            if blocked_by_parents:
340                # Add back to the END of the queue for retry later
341                self._stream_instances_to_start_partition_generation.append(stream)
342                parent_groups = {
343                    self._stream_block_simultaneous_read.get(p, "") for p in blocked_by_parents
344                }
345                self._logger.info(
346                    f"Deferring stream '{stream_name}' because parent stream(s) "
347                    f"{blocked_by_parents} (groups {parent_groups}) are active. Trying next stream."
348                )
349                continue  # Try the next stream in the queue
350
351            # No blocking - start this stream
352            # Mark stream as active before starting
353            self._active_stream_names.add(stream_name)
354            self._streams_currently_generating_partitions.append(stream_name)
355
356            # Track this stream in its group if it has one
357            if stream_group:
358                if stream_group not in self._active_groups:
359                    self._active_groups[stream_group] = set()
360                self._active_groups[stream_group].add(stream_name)
361                self._logger.debug(f"Added '{stream_name}' to active group '{stream_group}'")
362
363            # Also mark all parent streams as active (they will be read from during partition generation)
364            for parent_stream_name in parent_streams:
365                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
366                if parent_group:
367                    self._active_stream_names.add(parent_stream_name)
368                    if parent_group not in self._active_groups:
369                        self._active_groups[parent_group] = set()
370                    self._active_groups[parent_group].add(parent_stream_name)
371                    self._logger.info(
372                        f"Marking parent stream '{parent_stream_name}' (group '{parent_group}') as active "
373                        f"(will be read during partition generation for '{stream_name}')"
374                    )
375
376            self._thread_pool_manager.submit(self._partition_enqueuer.generate_partitions, stream)
377            self._logger.info(f"Marking stream {stream_name} as STARTED")
378            self._logger.info(f"Syncing stream: {stream_name}")
379            return stream_status_as_airbyte_message(
380                stream.as_airbyte_stream(),
381                AirbyteStreamStatus.STARTED,
382            )
383
384        # All streams in the queue are currently blocked
385        return None
386
387    def is_done(self) -> bool:
388        """
389        This method is called to check if the sync is done.
390        The sync is done when:
391        1. There are no more streams generating partitions
392        2. There are no more streams to read from
393        3. All partitions for all streams are closed
394        """
395        is_done = all(
396            [
397                self._is_stream_done(stream_name)
398                for stream_name in self._stream_name_to_instance.keys()
399            ]
400        )
401        if is_done and self._stream_instances_to_start_partition_generation:
402            stuck_stream_names = [
403                s.name for s in self._stream_instances_to_start_partition_generation
404            ]
405            raise AirbyteTracedException(
406                message="Partition generation queue is not empty after all streams completed.",
407                internal_message=f"Streams {stuck_stream_names} remained in the partition generation queue after all streams were marked done.",
408                failure_type=FailureType.system_error,
409            )
410        if is_done and self._active_groups:
411            raise AirbyteTracedException(
412                message="Active stream groups are not empty after all streams completed.",
413                internal_message=f"Groups {dict(self._active_groups)} still active after all streams were marked done.",
414                failure_type=FailureType.system_error,
415            )
416        if is_done and self._exceptions_per_stream_name:
417            error_message = generate_failed_streams_error_message(self._exceptions_per_stream_name)
418            self._logger.info(error_message)
419            # We still raise at least one exception when a stream raises an exception because the platform currently relies
420            # on a non-zero exit code to determine if a sync attempt has failed. We also raise the exception as a config_error
421            # type because this combined error isn't actionable, but rather the previously emitted individual errors.
422            raise AirbyteTracedException(
423                message=error_message,
424                internal_message="Concurrent read failure",
425                failure_type=FailureType.config_error,
426            )
427        return is_done
428
429    def _is_stream_done(self, stream_name: str) -> bool:
430        return stream_name in self._streams_done
431
432    def _collect_all_parent_stream_names(self, stream_name: str) -> Set[str]:
433        """Recursively collect all parent stream names for a given stream.
434
435        For example, if we have: epics -> issues -> comments
436        Then for comments, this returns {issues, epics}.
437        """
438        parent_names: Set[str] = set()
439        stream = self._stream_name_to_instance.get(stream_name)
440
441        if not stream:
442            return parent_names
443
444        partition_router = (
445            stream.get_partition_router() if isinstance(stream, DefaultStream) else None
446        )
447        routers = [partition_router] if partition_router is not None else []
448        while routers:
449            router = routers.pop()
450            if isinstance(router, GroupingPartitionRouter):
451                routers.append(router.underlying_partition_router)
452            elif isinstance(router, UnionPartitionRouter):
453                routers.extend(router.partition_routers)
454            elif isinstance(router, CartesianProductStreamSlicer):
455                routers.extend(router.stream_slicers)
456            elif isinstance(router, SubstreamPartitionRouter):
457                for parent_config in router.parent_stream_configs:
458                    parent_name = parent_config.stream.name
459                    parent_names.add(parent_name)
460                    parent_names.update(self._collect_all_parent_stream_names(parent_name))
461
462        return parent_names
463
464    def _on_stream_is_done(self, stream_name: str) -> Iterable[AirbyteMessage]:
465        self._logger.info(
466            f"Read {self._record_counter[stream_name]} records from {stream_name} stream"
467        )
468        self._logger.info(f"Marking stream {stream_name} as STOPPED")
469        stream = self._stream_name_to_instance[stream_name]
470        stream.cursor.ensure_at_least_one_state_emitted()
471        yield from self._message_repository.consume_queue()
472        self._logger.info(f"Finished syncing {stream.name}")
473        self._streams_done.add(stream_name)
474        stream_status = (
475            AirbyteStreamStatus.INCOMPLETE
476            if self._exceptions_per_stream_name.get(stream_name, [])
477            else AirbyteStreamStatus.COMPLETE
478        )
479        yield stream_status_as_airbyte_message(stream.as_airbyte_stream(), stream_status)
480
481        # Remove only this stream from active set (NOT parents)
482        if stream_name in self._active_stream_names:
483            self._active_stream_names.discard(stream_name)
484
485            # Remove from active groups
486            stream_group = self._stream_block_simultaneous_read.get(stream_name, "")
487            if stream_group:
488                if stream_group in self._active_groups:
489                    self._active_groups[stream_group].discard(stream_name)
490                    if not self._active_groups[stream_group]:
491                        del self._active_groups[stream_group]
492                self._logger.info(
493                    f"Stream '{stream_name}' (group '{stream_group}') is no longer active. "
494                    f"Blocked streams in the queue will be retried on next start_next_partition_generator call."
495                )
class ConcurrentReadProcessor:
 45class ConcurrentReadProcessor:
 46    def __init__(
 47        self,
 48        stream_instances_to_read_from: List[AbstractStream],
 49        partition_enqueuer: PartitionEnqueuer,
 50        thread_pool_manager: ThreadPoolManager,
 51        logger: logging.Logger,
 52        slice_logger: SliceLogger,
 53        message_repository: MessageRepository,
 54        partition_reader: PartitionReader,
 55        max_concurrent_partition_generators: Optional[int] = None,
 56    ):
 57        """
 58        This class is responsible for handling items from a concurrent stream read process.
 59        :param stream_instances_to_read_from: List of streams to read from
 60        :param partition_enqueuer: PartitionEnqueuer instance
 61        :param thread_pool_manager: ThreadPoolManager instance
 62        :param logger: Logger instance
 63        :param slice_logger: SliceLogger instance
 64        :param message_repository: MessageRepository instance
 65        :param partition_reader: PartitionReader instance
 66        :param max_concurrent_partition_generators: Maximum number of partition generators allowed
 67            to run concurrently. None means no limit. When set, should be less than the number of
 68            workers in multi-worker mode so at least one worker slot is always available for
 69            partition reading, preventing thread pool starvation. In single-threaded mode
 70            (num_workers=1) the value may equal num_workers; ConcurrentSource.create() handles
 71            this distinction. ConcurrentSource.read() passes this value explicitly.
 72        """
 73        self._stream_name_to_instance = {s.name: s for s in stream_instances_to_read_from}
 74        self._record_counter = {}
 75        self._streams_to_running_partitions: Dict[str, Set[Partition]] = {}
 76        for stream in stream_instances_to_read_from:
 77            self._streams_to_running_partitions[stream.name] = set()
 78            self._record_counter[stream.name] = 0
 79        if (
 80            max_concurrent_partition_generators is not None
 81            and max_concurrent_partition_generators < 1
 82        ):
 83            raise ValueError(
 84                f"max_concurrent_partition_generators must be >= 1 or None, got {max_concurrent_partition_generators}"
 85            )
 86        self._thread_pool_manager = thread_pool_manager
 87        self._partition_enqueuer = partition_enqueuer
 88        self._max_concurrent_partition_generators = max_concurrent_partition_generators
 89        self._stream_instances_to_start_partition_generation = stream_instances_to_read_from
 90        self._streams_currently_generating_partitions: List[str] = []
 91        self._logger = logger
 92        self._slice_logger = slice_logger
 93        self._message_repository = message_repository
 94        self._partition_reader = partition_reader
 95        self._streams_done: Set[str] = set()
 96        self._exceptions_per_stream_name: dict[str, List[Exception]] = {}
 97
 98        # Track which streams (by name) are currently active
 99        # A stream is "active" if it's generating partitions or has partitions being read
100        self._active_stream_names: Set[str] = set()
101
102        # Store blocking group names for streams that require blocking simultaneous reads
103        # Maps stream name -> group name (empty string means no blocking)
104        self._stream_block_simultaneous_read: Dict[str, str] = {
105            stream.name: stream.block_simultaneous_read for stream in stream_instances_to_read_from
106        }
107
108        # Track which groups are currently active
109        # Maps group name -> set of stream names in that group
110        self._active_groups: Dict[str, Set[str]] = {}
111
112        for stream in stream_instances_to_read_from:
113            if stream.block_simultaneous_read:
114                self._logger.info(
115                    f"Stream '{stream.name}' is in blocking group '{stream.block_simultaneous_read}'. "
116                    f"Will defer starting this stream if another stream in the same group or its parents are active."
117                )
118
119    def on_partition_generation_completed(
120        self, sentinel: PartitionGenerationCompletedSentinel
121    ) -> Iterable[AirbyteMessage]:
122        """
123        This method is called when a partition generation is completed.
124        1. Remove the stream from the list of streams currently generating partitions
125        2. Deactivate parent streams (they were only needed for partition generation)
126        3. If the stream is done, mark it as such and return a stream status message
127        4. If there are more streams to read from, start the next partition generator
128        """
129        stream_name = sentinel.stream.name
130        self._streams_currently_generating_partitions.remove(sentinel.stream.name)
131
132        # Deactivate all parent streams now that partition generation is complete
133        # Parents were only needed to generate slices, they can now be reused
134        parent_streams = self._collect_all_parent_stream_names(stream_name)
135        for parent_stream_name in parent_streams:
136            if parent_stream_name in self._active_stream_names:
137                self._logger.debug(f"Removing '{parent_stream_name}' from active streams")
138                self._active_stream_names.discard(parent_stream_name)
139
140                # Remove from active groups
141                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
142                if parent_group:
143                    if parent_group in self._active_groups:
144                        self._active_groups[parent_group].discard(parent_stream_name)
145                        if not self._active_groups[parent_group]:
146                            del self._active_groups[parent_group]
147                    self._logger.info(
148                        f"Parent stream '{parent_stream_name}' (group '{parent_group}') deactivated after "
149                        f"partition generation completed for child '{stream_name}'. "
150                        f"Blocked streams in the queue will be retried on next start_next_partition_generator call."
151                    )
152
153        # It is possible for the stream to already be done if no partitions were generated
154        # If the partition generation process was completed and there are no partitions left to process, the stream is done
155        if (
156            self._is_stream_done(stream_name)
157            or len(self._streams_to_running_partitions[stream_name]) == 0
158        ):
159            yield from self._on_stream_is_done(stream_name)
160        if self._stream_instances_to_start_partition_generation:
161            status_message = self.start_next_partition_generator()
162            if status_message:
163                yield status_message
164
165    def on_partition(self, partition: Partition) -> None:
166        """
167        This method is called when a partition is generated.
168        1. Add the partition to the set of partitions for the stream
169        2. Log the slice if necessary
170        3. Submit the partition to the thread pool manager
171        """
172        stream_name = partition.stream_name()
173        self._streams_to_running_partitions[stream_name].add(partition)
174        cursor = self._stream_name_to_instance[stream_name].cursor
175        if self._slice_logger.should_log_slice_message(self._logger):
176            self._message_repository.emit_message(
177                self._slice_logger.create_slice_log_message(partition.to_slice())
178            )
179        self._thread_pool_manager.submit(
180            self._partition_reader.process_partition, partition, cursor
181        )
182
183    def on_partition_complete_sentinel(
184        self, sentinel: PartitionCompleteSentinel
185    ) -> Iterable[AirbyteMessage]:
186        """
187        This method is called when a partition is completed.
188        1. Close the partition
189        2. If the stream is done, mark it as such and return a stream status message
190        3. Emit messages that were added to the message repository
191        4. If there are more streams to read from, start the next partition generator
192        """
193        partition = sentinel.partition
194
195        partitions_running = self._streams_to_running_partitions[partition.stream_name()]
196        if partition in partitions_running:
197            partitions_running.remove(partition)
198            # If all partitions were generated and this was the last one, the stream is done
199            if (
200                partition.stream_name() not in self._streams_currently_generating_partitions
201                and len(partitions_running) == 0
202            ):
203                yield from self._on_stream_is_done(partition.stream_name())
204                # Try to start the next stream in the queue (may be a deferred stream)
205                if self._stream_instances_to_start_partition_generation:
206                    status_message = self.start_next_partition_generator()
207                    if status_message:
208                        yield status_message
209        yield from self._message_repository.consume_queue()
210
211    def on_record(self, record: Record) -> Iterable[AirbyteMessage]:
212        """
213        This method is called when a record is read from a partition.
214        1. Convert the record to an AirbyteMessage
215        2. If this is the first record for the stream, mark the stream as RUNNING
216        3. Increment the record counter for the stream
217        4. Ensures the cursor knows the record has been successfully emitted
218        5. Emit the message
219        6. Emit messages that were added to the message repository
220        """
221        # Do not pass a transformer or a schema
222        # AbstractStreams are expected to return data as they are expected.
223        # Any transformation on the data should be done before reaching this point
224        message = stream_data_to_airbyte_message(
225            stream_name=record.stream_name,
226            data_or_message=record.data,
227            file_reference=record.file_reference,
228        )
229        stream = self._stream_name_to_instance[record.stream_name]
230
231        if message.type == MessageType.RECORD:
232            if self._record_counter[stream.name] == 0:
233                self._logger.info(f"Marking stream {stream.name} as RUNNING")
234                yield stream_status_as_airbyte_message(
235                    stream.as_airbyte_stream(), AirbyteStreamStatus.RUNNING
236                )
237            self._record_counter[stream.name] += 1
238        yield message
239        yield from self._message_repository.consume_queue()
240
241    def on_exception(self, exception: StreamThreadException) -> Iterable[AirbyteMessage]:
242        """
243        This method is called when an exception is raised.
244        1. Stop all running streams
245        2. Raise the exception
246        """
247        self._flag_exception(exception.stream_name, exception.exception)
248        self._logger.exception(
249            f"Exception while syncing stream {exception.stream_name}", exc_info=exception.exception
250        )
251
252        stream_descriptor = StreamDescriptor(name=exception.stream_name)
253        if isinstance(exception.exception, AirbyteTracedException):
254            yield exception.exception.as_airbyte_message(stream_descriptor=stream_descriptor)
255        else:
256            yield AirbyteTracedException.from_exception(
257                exception.exception,
258                stream_descriptor=stream_descriptor,
259                message=f"An unexpected error occurred in stream {exception.stream_name}: {type(exception.exception).__name__}",
260            ).as_airbyte_message()
261
262    def _flag_exception(self, stream_name: str, exception: Exception) -> None:
263        self._exceptions_per_stream_name.setdefault(stream_name, []).append(exception)
264
265    def start_next_partition_generator(self) -> Optional[AirbyteMessage]:
266        """
267        Submits the next partition generator to the thread pool.
268
269        A stream will be deferred (moved to end of queue) if:
270        1. The stream itself has block_simultaneous_read=True AND is already active
271        2. Any parent stream has block_simultaneous_read=True AND is currently active
272
273        This prevents simultaneous reads of streams that shouldn't be accessed concurrently.
274
275        :return: A status message if a partition generator was started, otherwise None
276        """
277        if not self._stream_instances_to_start_partition_generation:
278            return None
279
280        # Enforce the concurrent generator cap so at least one worker slot is always available
281        # for partition reading. Recovery is guaranteed: on_partition_generation_completed
282        # decrements the count before calling here, so the guard always passes there.
283        if (
284            self._max_concurrent_partition_generators is not None
285            and len(self._streams_currently_generating_partitions)
286            >= self._max_concurrent_partition_generators
287        ):
288            self._logger.debug(
289                f"Concurrent partition generator cap ({self._max_concurrent_partition_generators}) reached "
290                f"({len(self._streams_currently_generating_partitions)} active). Deferring next generator start."
291            )
292            return None
293
294        # Remember initial queue size to avoid infinite loops if all streams are blocked
295        max_attempts = len(self._stream_instances_to_start_partition_generation)
296        attempts = 0
297
298        while self._stream_instances_to_start_partition_generation and attempts < max_attempts:
299            attempts += 1
300
301            # Pop the first stream from the queue
302            stream = self._stream_instances_to_start_partition_generation.pop(0)
303            stream_name = stream.name
304            stream_group = self._stream_block_simultaneous_read.get(stream_name, "")
305
306            # Check if this stream has a blocking group and is already active as parent stream
307            # (i.e. being read from during partition generation for another stream)
308            if stream_group and stream_name in self._active_stream_names:
309                # Add back to the END of the queue for retry later
310                self._stream_instances_to_start_partition_generation.append(stream)
311                self._logger.info(
312                    f"Deferring stream '{stream_name}' (group '{stream_group}') because it's already active. Trying next stream."
313                )
314                continue  # Try the next stream in the queue
315
316            # Check if this stream's group is already active (another stream in the same group is running)
317            if (
318                stream_group
319                and stream_group in self._active_groups
320                and self._active_groups[stream_group]
321            ):
322                # Add back to the END of the queue for retry later
323                self._stream_instances_to_start_partition_generation.append(stream)
324                active_streams_in_group = self._active_groups[stream_group]
325                self._logger.info(
326                    f"Deferring stream '{stream_name}' (group '{stream_group}') because other stream(s) "
327                    f"{active_streams_in_group} in the same group are active. Trying next stream."
328                )
329                continue  # Try the next stream in the queue
330
331            # Check if any parent streams have a blocking group and are currently active
332            parent_streams = self._collect_all_parent_stream_names(stream_name)
333            blocked_by_parents = [
334                p
335                for p in parent_streams
336                if self._stream_block_simultaneous_read.get(p, "")
337                and p in self._active_stream_names
338            ]
339
340            if blocked_by_parents:
341                # Add back to the END of the queue for retry later
342                self._stream_instances_to_start_partition_generation.append(stream)
343                parent_groups = {
344                    self._stream_block_simultaneous_read.get(p, "") for p in blocked_by_parents
345                }
346                self._logger.info(
347                    f"Deferring stream '{stream_name}' because parent stream(s) "
348                    f"{blocked_by_parents} (groups {parent_groups}) are active. Trying next stream."
349                )
350                continue  # Try the next stream in the queue
351
352            # No blocking - start this stream
353            # Mark stream as active before starting
354            self._active_stream_names.add(stream_name)
355            self._streams_currently_generating_partitions.append(stream_name)
356
357            # Track this stream in its group if it has one
358            if stream_group:
359                if stream_group not in self._active_groups:
360                    self._active_groups[stream_group] = set()
361                self._active_groups[stream_group].add(stream_name)
362                self._logger.debug(f"Added '{stream_name}' to active group '{stream_group}'")
363
364            # Also mark all parent streams as active (they will be read from during partition generation)
365            for parent_stream_name in parent_streams:
366                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
367                if parent_group:
368                    self._active_stream_names.add(parent_stream_name)
369                    if parent_group not in self._active_groups:
370                        self._active_groups[parent_group] = set()
371                    self._active_groups[parent_group].add(parent_stream_name)
372                    self._logger.info(
373                        f"Marking parent stream '{parent_stream_name}' (group '{parent_group}') as active "
374                        f"(will be read during partition generation for '{stream_name}')"
375                    )
376
377            self._thread_pool_manager.submit(self._partition_enqueuer.generate_partitions, stream)
378            self._logger.info(f"Marking stream {stream_name} as STARTED")
379            self._logger.info(f"Syncing stream: {stream_name}")
380            return stream_status_as_airbyte_message(
381                stream.as_airbyte_stream(),
382                AirbyteStreamStatus.STARTED,
383            )
384
385        # All streams in the queue are currently blocked
386        return None
387
388    def is_done(self) -> bool:
389        """
390        This method is called to check if the sync is done.
391        The sync is done when:
392        1. There are no more streams generating partitions
393        2. There are no more streams to read from
394        3. All partitions for all streams are closed
395        """
396        is_done = all(
397            [
398                self._is_stream_done(stream_name)
399                for stream_name in self._stream_name_to_instance.keys()
400            ]
401        )
402        if is_done and self._stream_instances_to_start_partition_generation:
403            stuck_stream_names = [
404                s.name for s in self._stream_instances_to_start_partition_generation
405            ]
406            raise AirbyteTracedException(
407                message="Partition generation queue is not empty after all streams completed.",
408                internal_message=f"Streams {stuck_stream_names} remained in the partition generation queue after all streams were marked done.",
409                failure_type=FailureType.system_error,
410            )
411        if is_done and self._active_groups:
412            raise AirbyteTracedException(
413                message="Active stream groups are not empty after all streams completed.",
414                internal_message=f"Groups {dict(self._active_groups)} still active after all streams were marked done.",
415                failure_type=FailureType.system_error,
416            )
417        if is_done and self._exceptions_per_stream_name:
418            error_message = generate_failed_streams_error_message(self._exceptions_per_stream_name)
419            self._logger.info(error_message)
420            # We still raise at least one exception when a stream raises an exception because the platform currently relies
421            # on a non-zero exit code to determine if a sync attempt has failed. We also raise the exception as a config_error
422            # type because this combined error isn't actionable, but rather the previously emitted individual errors.
423            raise AirbyteTracedException(
424                message=error_message,
425                internal_message="Concurrent read failure",
426                failure_type=FailureType.config_error,
427            )
428        return is_done
429
430    def _is_stream_done(self, stream_name: str) -> bool:
431        return stream_name in self._streams_done
432
433    def _collect_all_parent_stream_names(self, stream_name: str) -> Set[str]:
434        """Recursively collect all parent stream names for a given stream.
435
436        For example, if we have: epics -> issues -> comments
437        Then for comments, this returns {issues, epics}.
438        """
439        parent_names: Set[str] = set()
440        stream = self._stream_name_to_instance.get(stream_name)
441
442        if not stream:
443            return parent_names
444
445        partition_router = (
446            stream.get_partition_router() if isinstance(stream, DefaultStream) else None
447        )
448        routers = [partition_router] if partition_router is not None else []
449        while routers:
450            router = routers.pop()
451            if isinstance(router, GroupingPartitionRouter):
452                routers.append(router.underlying_partition_router)
453            elif isinstance(router, UnionPartitionRouter):
454                routers.extend(router.partition_routers)
455            elif isinstance(router, CartesianProductStreamSlicer):
456                routers.extend(router.stream_slicers)
457            elif isinstance(router, SubstreamPartitionRouter):
458                for parent_config in router.parent_stream_configs:
459                    parent_name = parent_config.stream.name
460                    parent_names.add(parent_name)
461                    parent_names.update(self._collect_all_parent_stream_names(parent_name))
462
463        return parent_names
464
465    def _on_stream_is_done(self, stream_name: str) -> Iterable[AirbyteMessage]:
466        self._logger.info(
467            f"Read {self._record_counter[stream_name]} records from {stream_name} stream"
468        )
469        self._logger.info(f"Marking stream {stream_name} as STOPPED")
470        stream = self._stream_name_to_instance[stream_name]
471        stream.cursor.ensure_at_least_one_state_emitted()
472        yield from self._message_repository.consume_queue()
473        self._logger.info(f"Finished syncing {stream.name}")
474        self._streams_done.add(stream_name)
475        stream_status = (
476            AirbyteStreamStatus.INCOMPLETE
477            if self._exceptions_per_stream_name.get(stream_name, [])
478            else AirbyteStreamStatus.COMPLETE
479        )
480        yield stream_status_as_airbyte_message(stream.as_airbyte_stream(), stream_status)
481
482        # Remove only this stream from active set (NOT parents)
483        if stream_name in self._active_stream_names:
484            self._active_stream_names.discard(stream_name)
485
486            # Remove from active groups
487            stream_group = self._stream_block_simultaneous_read.get(stream_name, "")
488            if stream_group:
489                if stream_group in self._active_groups:
490                    self._active_groups[stream_group].discard(stream_name)
491                    if not self._active_groups[stream_group]:
492                        del self._active_groups[stream_group]
493                self._logger.info(
494                    f"Stream '{stream_name}' (group '{stream_group}') is no longer active. "
495                    f"Blocked streams in the queue will be retried on next start_next_partition_generator call."
496                )
ConcurrentReadProcessor( stream_instances_to_read_from: List[airbyte_cdk.sources.streams.concurrent.abstract_stream.AbstractStream], partition_enqueuer: airbyte_cdk.sources.streams.concurrent.partition_enqueuer.PartitionEnqueuer, thread_pool_manager: airbyte_cdk.sources.concurrent_source.thread_pool_manager.ThreadPoolManager, logger: logging.Logger, slice_logger: airbyte_cdk.sources.utils.slice_logger.SliceLogger, message_repository: airbyte_cdk.MessageRepository, partition_reader: airbyte_cdk.sources.streams.concurrent.partition_reader.PartitionReader, max_concurrent_partition_generators: Optional[int] = None)
 46    def __init__(
 47        self,
 48        stream_instances_to_read_from: List[AbstractStream],
 49        partition_enqueuer: PartitionEnqueuer,
 50        thread_pool_manager: ThreadPoolManager,
 51        logger: logging.Logger,
 52        slice_logger: SliceLogger,
 53        message_repository: MessageRepository,
 54        partition_reader: PartitionReader,
 55        max_concurrent_partition_generators: Optional[int] = None,
 56    ):
 57        """
 58        This class is responsible for handling items from a concurrent stream read process.
 59        :param stream_instances_to_read_from: List of streams to read from
 60        :param partition_enqueuer: PartitionEnqueuer instance
 61        :param thread_pool_manager: ThreadPoolManager instance
 62        :param logger: Logger instance
 63        :param slice_logger: SliceLogger instance
 64        :param message_repository: MessageRepository instance
 65        :param partition_reader: PartitionReader instance
 66        :param max_concurrent_partition_generators: Maximum number of partition generators allowed
 67            to run concurrently. None means no limit. When set, should be less than the number of
 68            workers in multi-worker mode so at least one worker slot is always available for
 69            partition reading, preventing thread pool starvation. In single-threaded mode
 70            (num_workers=1) the value may equal num_workers; ConcurrentSource.create() handles
 71            this distinction. ConcurrentSource.read() passes this value explicitly.
 72        """
 73        self._stream_name_to_instance = {s.name: s for s in stream_instances_to_read_from}
 74        self._record_counter = {}
 75        self._streams_to_running_partitions: Dict[str, Set[Partition]] = {}
 76        for stream in stream_instances_to_read_from:
 77            self._streams_to_running_partitions[stream.name] = set()
 78            self._record_counter[stream.name] = 0
 79        if (
 80            max_concurrent_partition_generators is not None
 81            and max_concurrent_partition_generators < 1
 82        ):
 83            raise ValueError(
 84                f"max_concurrent_partition_generators must be >= 1 or None, got {max_concurrent_partition_generators}"
 85            )
 86        self._thread_pool_manager = thread_pool_manager
 87        self._partition_enqueuer = partition_enqueuer
 88        self._max_concurrent_partition_generators = max_concurrent_partition_generators
 89        self._stream_instances_to_start_partition_generation = stream_instances_to_read_from
 90        self._streams_currently_generating_partitions: List[str] = []
 91        self._logger = logger
 92        self._slice_logger = slice_logger
 93        self._message_repository = message_repository
 94        self._partition_reader = partition_reader
 95        self._streams_done: Set[str] = set()
 96        self._exceptions_per_stream_name: dict[str, List[Exception]] = {}
 97
 98        # Track which streams (by name) are currently active
 99        # A stream is "active" if it's generating partitions or has partitions being read
100        self._active_stream_names: Set[str] = set()
101
102        # Store blocking group names for streams that require blocking simultaneous reads
103        # Maps stream name -> group name (empty string means no blocking)
104        self._stream_block_simultaneous_read: Dict[str, str] = {
105            stream.name: stream.block_simultaneous_read for stream in stream_instances_to_read_from
106        }
107
108        # Track which groups are currently active
109        # Maps group name -> set of stream names in that group
110        self._active_groups: Dict[str, Set[str]] = {}
111
112        for stream in stream_instances_to_read_from:
113            if stream.block_simultaneous_read:
114                self._logger.info(
115                    f"Stream '{stream.name}' is in blocking group '{stream.block_simultaneous_read}'. "
116                    f"Will defer starting this stream if another stream in the same group or its parents are active."
117                )

This class is responsible for handling items from a concurrent stream read process.

Parameters
  • stream_instances_to_read_from: List of streams to read from
  • partition_enqueuer: PartitionEnqueuer instance
  • thread_pool_manager: ThreadPoolManager instance
  • logger: Logger instance
  • slice_logger: SliceLogger instance
  • message_repository: MessageRepository instance
  • partition_reader: PartitionReader instance
  • max_concurrent_partition_generators: Maximum number of partition generators allowed to run concurrently. None means no limit. When set, should be less than the number of workers in multi-worker mode so at least one worker slot is always available for partition reading, preventing thread pool starvation. In single-threaded mode (num_workers=1) the value may equal num_workers; ConcurrentSource.create() handles this distinction. ConcurrentSource.read() passes this value explicitly.
119    def on_partition_generation_completed(
120        self, sentinel: PartitionGenerationCompletedSentinel
121    ) -> Iterable[AirbyteMessage]:
122        """
123        This method is called when a partition generation is completed.
124        1. Remove the stream from the list of streams currently generating partitions
125        2. Deactivate parent streams (they were only needed for partition generation)
126        3. If the stream is done, mark it as such and return a stream status message
127        4. If there are more streams to read from, start the next partition generator
128        """
129        stream_name = sentinel.stream.name
130        self._streams_currently_generating_partitions.remove(sentinel.stream.name)
131
132        # Deactivate all parent streams now that partition generation is complete
133        # Parents were only needed to generate slices, they can now be reused
134        parent_streams = self._collect_all_parent_stream_names(stream_name)
135        for parent_stream_name in parent_streams:
136            if parent_stream_name in self._active_stream_names:
137                self._logger.debug(f"Removing '{parent_stream_name}' from active streams")
138                self._active_stream_names.discard(parent_stream_name)
139
140                # Remove from active groups
141                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
142                if parent_group:
143                    if parent_group in self._active_groups:
144                        self._active_groups[parent_group].discard(parent_stream_name)
145                        if not self._active_groups[parent_group]:
146                            del self._active_groups[parent_group]
147                    self._logger.info(
148                        f"Parent stream '{parent_stream_name}' (group '{parent_group}') deactivated after "
149                        f"partition generation completed for child '{stream_name}'. "
150                        f"Blocked streams in the queue will be retried on next start_next_partition_generator call."
151                    )
152
153        # It is possible for the stream to already be done if no partitions were generated
154        # If the partition generation process was completed and there are no partitions left to process, the stream is done
155        if (
156            self._is_stream_done(stream_name)
157            or len(self._streams_to_running_partitions[stream_name]) == 0
158        ):
159            yield from self._on_stream_is_done(stream_name)
160        if self._stream_instances_to_start_partition_generation:
161            status_message = self.start_next_partition_generator()
162            if status_message:
163                yield status_message

This method is called when a partition generation is completed.

  1. Remove the stream from the list of streams currently generating partitions
  2. Deactivate parent streams (they were only needed for partition generation)
  3. If the stream is done, mark it as such and return a stream status message
  4. If there are more streams to read from, start the next partition generator
def on_partition( self, partition: airbyte_cdk.sources.streams.concurrent.partitions.partition.Partition) -> None:
165    def on_partition(self, partition: Partition) -> None:
166        """
167        This method is called when a partition is generated.
168        1. Add the partition to the set of partitions for the stream
169        2. Log the slice if necessary
170        3. Submit the partition to the thread pool manager
171        """
172        stream_name = partition.stream_name()
173        self._streams_to_running_partitions[stream_name].add(partition)
174        cursor = self._stream_name_to_instance[stream_name].cursor
175        if self._slice_logger.should_log_slice_message(self._logger):
176            self._message_repository.emit_message(
177                self._slice_logger.create_slice_log_message(partition.to_slice())
178            )
179        self._thread_pool_manager.submit(
180            self._partition_reader.process_partition, partition, cursor
181        )

This method is called when a partition is generated.

  1. Add the partition to the set of partitions for the stream
  2. Log the slice if necessary
  3. Submit the partition to the thread pool manager
def on_partition_complete_sentinel( self, sentinel: airbyte_cdk.sources.streams.concurrent.partitions.types.PartitionCompleteSentinel) -> Iterable[airbyte_cdk.AirbyteMessage]:
183    def on_partition_complete_sentinel(
184        self, sentinel: PartitionCompleteSentinel
185    ) -> Iterable[AirbyteMessage]:
186        """
187        This method is called when a partition is completed.
188        1. Close the partition
189        2. If the stream is done, mark it as such and return a stream status message
190        3. Emit messages that were added to the message repository
191        4. If there are more streams to read from, start the next partition generator
192        """
193        partition = sentinel.partition
194
195        partitions_running = self._streams_to_running_partitions[partition.stream_name()]
196        if partition in partitions_running:
197            partitions_running.remove(partition)
198            # If all partitions were generated and this was the last one, the stream is done
199            if (
200                partition.stream_name() not in self._streams_currently_generating_partitions
201                and len(partitions_running) == 0
202            ):
203                yield from self._on_stream_is_done(partition.stream_name())
204                # Try to start the next stream in the queue (may be a deferred stream)
205                if self._stream_instances_to_start_partition_generation:
206                    status_message = self.start_next_partition_generator()
207                    if status_message:
208                        yield status_message
209        yield from self._message_repository.consume_queue()

This method is called when a partition is completed.

  1. Close the partition
  2. If the stream is done, mark it as such and return a stream status message
  3. Emit messages that were added to the message repository
  4. If there are more streams to read from, start the next partition generator
def on_record( self, record: airbyte_cdk.Record) -> Iterable[airbyte_cdk.AirbyteMessage]:
211    def on_record(self, record: Record) -> Iterable[AirbyteMessage]:
212        """
213        This method is called when a record is read from a partition.
214        1. Convert the record to an AirbyteMessage
215        2. If this is the first record for the stream, mark the stream as RUNNING
216        3. Increment the record counter for the stream
217        4. Ensures the cursor knows the record has been successfully emitted
218        5. Emit the message
219        6. Emit messages that were added to the message repository
220        """
221        # Do not pass a transformer or a schema
222        # AbstractStreams are expected to return data as they are expected.
223        # Any transformation on the data should be done before reaching this point
224        message = stream_data_to_airbyte_message(
225            stream_name=record.stream_name,
226            data_or_message=record.data,
227            file_reference=record.file_reference,
228        )
229        stream = self._stream_name_to_instance[record.stream_name]
230
231        if message.type == MessageType.RECORD:
232            if self._record_counter[stream.name] == 0:
233                self._logger.info(f"Marking stream {stream.name} as RUNNING")
234                yield stream_status_as_airbyte_message(
235                    stream.as_airbyte_stream(), AirbyteStreamStatus.RUNNING
236                )
237            self._record_counter[stream.name] += 1
238        yield message
239        yield from self._message_repository.consume_queue()

This method is called when a record is read from a partition.

  1. Convert the record to an AirbyteMessage
  2. If this is the first record for the stream, mark the stream as RUNNING
  3. Increment the record counter for the stream
  4. Ensures the cursor knows the record has been successfully emitted
  5. Emit the message
  6. Emit messages that were added to the message repository
241    def on_exception(self, exception: StreamThreadException) -> Iterable[AirbyteMessage]:
242        """
243        This method is called when an exception is raised.
244        1. Stop all running streams
245        2. Raise the exception
246        """
247        self._flag_exception(exception.stream_name, exception.exception)
248        self._logger.exception(
249            f"Exception while syncing stream {exception.stream_name}", exc_info=exception.exception
250        )
251
252        stream_descriptor = StreamDescriptor(name=exception.stream_name)
253        if isinstance(exception.exception, AirbyteTracedException):
254            yield exception.exception.as_airbyte_message(stream_descriptor=stream_descriptor)
255        else:
256            yield AirbyteTracedException.from_exception(
257                exception.exception,
258                stream_descriptor=stream_descriptor,
259                message=f"An unexpected error occurred in stream {exception.stream_name}: {type(exception.exception).__name__}",
260            ).as_airbyte_message()

This method is called when an exception is raised.

  1. Stop all running streams
  2. Raise the exception
def start_next_partition_generator(self) -> Optional[airbyte_cdk.AirbyteMessage]:
265    def start_next_partition_generator(self) -> Optional[AirbyteMessage]:
266        """
267        Submits the next partition generator to the thread pool.
268
269        A stream will be deferred (moved to end of queue) if:
270        1. The stream itself has block_simultaneous_read=True AND is already active
271        2. Any parent stream has block_simultaneous_read=True AND is currently active
272
273        This prevents simultaneous reads of streams that shouldn't be accessed concurrently.
274
275        :return: A status message if a partition generator was started, otherwise None
276        """
277        if not self._stream_instances_to_start_partition_generation:
278            return None
279
280        # Enforce the concurrent generator cap so at least one worker slot is always available
281        # for partition reading. Recovery is guaranteed: on_partition_generation_completed
282        # decrements the count before calling here, so the guard always passes there.
283        if (
284            self._max_concurrent_partition_generators is not None
285            and len(self._streams_currently_generating_partitions)
286            >= self._max_concurrent_partition_generators
287        ):
288            self._logger.debug(
289                f"Concurrent partition generator cap ({self._max_concurrent_partition_generators}) reached "
290                f"({len(self._streams_currently_generating_partitions)} active). Deferring next generator start."
291            )
292            return None
293
294        # Remember initial queue size to avoid infinite loops if all streams are blocked
295        max_attempts = len(self._stream_instances_to_start_partition_generation)
296        attempts = 0
297
298        while self._stream_instances_to_start_partition_generation and attempts < max_attempts:
299            attempts += 1
300
301            # Pop the first stream from the queue
302            stream = self._stream_instances_to_start_partition_generation.pop(0)
303            stream_name = stream.name
304            stream_group = self._stream_block_simultaneous_read.get(stream_name, "")
305
306            # Check if this stream has a blocking group and is already active as parent stream
307            # (i.e. being read from during partition generation for another stream)
308            if stream_group and stream_name in self._active_stream_names:
309                # Add back to the END of the queue for retry later
310                self._stream_instances_to_start_partition_generation.append(stream)
311                self._logger.info(
312                    f"Deferring stream '{stream_name}' (group '{stream_group}') because it's already active. Trying next stream."
313                )
314                continue  # Try the next stream in the queue
315
316            # Check if this stream's group is already active (another stream in the same group is running)
317            if (
318                stream_group
319                and stream_group in self._active_groups
320                and self._active_groups[stream_group]
321            ):
322                # Add back to the END of the queue for retry later
323                self._stream_instances_to_start_partition_generation.append(stream)
324                active_streams_in_group = self._active_groups[stream_group]
325                self._logger.info(
326                    f"Deferring stream '{stream_name}' (group '{stream_group}') because other stream(s) "
327                    f"{active_streams_in_group} in the same group are active. Trying next stream."
328                )
329                continue  # Try the next stream in the queue
330
331            # Check if any parent streams have a blocking group and are currently active
332            parent_streams = self._collect_all_parent_stream_names(stream_name)
333            blocked_by_parents = [
334                p
335                for p in parent_streams
336                if self._stream_block_simultaneous_read.get(p, "")
337                and p in self._active_stream_names
338            ]
339
340            if blocked_by_parents:
341                # Add back to the END of the queue for retry later
342                self._stream_instances_to_start_partition_generation.append(stream)
343                parent_groups = {
344                    self._stream_block_simultaneous_read.get(p, "") for p in blocked_by_parents
345                }
346                self._logger.info(
347                    f"Deferring stream '{stream_name}' because parent stream(s) "
348                    f"{blocked_by_parents} (groups {parent_groups}) are active. Trying next stream."
349                )
350                continue  # Try the next stream in the queue
351
352            # No blocking - start this stream
353            # Mark stream as active before starting
354            self._active_stream_names.add(stream_name)
355            self._streams_currently_generating_partitions.append(stream_name)
356
357            # Track this stream in its group if it has one
358            if stream_group:
359                if stream_group not in self._active_groups:
360                    self._active_groups[stream_group] = set()
361                self._active_groups[stream_group].add(stream_name)
362                self._logger.debug(f"Added '{stream_name}' to active group '{stream_group}'")
363
364            # Also mark all parent streams as active (they will be read from during partition generation)
365            for parent_stream_name in parent_streams:
366                parent_group = self._stream_block_simultaneous_read.get(parent_stream_name, "")
367                if parent_group:
368                    self._active_stream_names.add(parent_stream_name)
369                    if parent_group not in self._active_groups:
370                        self._active_groups[parent_group] = set()
371                    self._active_groups[parent_group].add(parent_stream_name)
372                    self._logger.info(
373                        f"Marking parent stream '{parent_stream_name}' (group '{parent_group}') as active "
374                        f"(will be read during partition generation for '{stream_name}')"
375                    )
376
377            self._thread_pool_manager.submit(self._partition_enqueuer.generate_partitions, stream)
378            self._logger.info(f"Marking stream {stream_name} as STARTED")
379            self._logger.info(f"Syncing stream: {stream_name}")
380            return stream_status_as_airbyte_message(
381                stream.as_airbyte_stream(),
382                AirbyteStreamStatus.STARTED,
383            )
384
385        # All streams in the queue are currently blocked
386        return None

Submits the next partition generator to the thread pool.

A stream will be deferred (moved to end of queue) if:

  1. The stream itself has block_simultaneous_read=True AND is already active
  2. Any parent stream has block_simultaneous_read=True AND is currently active

This prevents simultaneous reads of streams that shouldn't be accessed concurrently.

Returns

A status message if a partition generator was started, otherwise None

def is_done(self) -> bool:
388    def is_done(self) -> bool:
389        """
390        This method is called to check if the sync is done.
391        The sync is done when:
392        1. There are no more streams generating partitions
393        2. There are no more streams to read from
394        3. All partitions for all streams are closed
395        """
396        is_done = all(
397            [
398                self._is_stream_done(stream_name)
399                for stream_name in self._stream_name_to_instance.keys()
400            ]
401        )
402        if is_done and self._stream_instances_to_start_partition_generation:
403            stuck_stream_names = [
404                s.name for s in self._stream_instances_to_start_partition_generation
405            ]
406            raise AirbyteTracedException(
407                message="Partition generation queue is not empty after all streams completed.",
408                internal_message=f"Streams {stuck_stream_names} remained in the partition generation queue after all streams were marked done.",
409                failure_type=FailureType.system_error,
410            )
411        if is_done and self._active_groups:
412            raise AirbyteTracedException(
413                message="Active stream groups are not empty after all streams completed.",
414                internal_message=f"Groups {dict(self._active_groups)} still active after all streams were marked done.",
415                failure_type=FailureType.system_error,
416            )
417        if is_done and self._exceptions_per_stream_name:
418            error_message = generate_failed_streams_error_message(self._exceptions_per_stream_name)
419            self._logger.info(error_message)
420            # We still raise at least one exception when a stream raises an exception because the platform currently relies
421            # on a non-zero exit code to determine if a sync attempt has failed. We also raise the exception as a config_error
422            # type because this combined error isn't actionable, but rather the previously emitted individual errors.
423            raise AirbyteTracedException(
424                message=error_message,
425                internal_message="Concurrent read failure",
426                failure_type=FailureType.config_error,
427            )
428        return is_done

This method is called to check if the sync is done. The sync is done when:

  1. There are no more streams generating partitions
  2. There are no more streams to read from
  3. All partitions for all streams are closed