From a4b605116b035663fa0ff53335dd90dc0d3baf17 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:36:26 +0000 Subject: [PATCH 01/24] feat: log concurrency configuration at startup for debugging Co-Authored-By: unknown <> --- .../sources/declarative/concurrent_declarative_source.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py index 45fe6aa2de..20e9251f73 100644 --- a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py +++ b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py @@ -233,6 +233,13 @@ def __init__( concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2 + self.logger.info( + "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, concurrency_level_from_manifest=%s", + concurrency_level, + initial_number_of_partitions_to_generate, + "defined" if concurrency_level_from_manifest else "not_defined", + ) + self._concurrent_source = ConcurrentSource.create( num_workers=concurrency_level, initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate, From 69fb8982e541e15187449491910f23cc2348acfa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:28:34 +0000 Subject: [PATCH 02/24] feat: enhance concurrency logging to differentiate config vs default values Co-Authored-By: unknown <> --- .../concurrent_declarative_source.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py index 20e9251f73..64b711eb6c 100644 --- a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py +++ b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py @@ -233,12 +233,23 @@ def __init__( concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2 - self.logger.info( - "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, concurrency_level_from_manifest=%s", - concurrency_level, - initial_number_of_partitions_to_generate, - "defined" if concurrency_level_from_manifest else "not_defined", - ) + if concurrency_level_from_manifest: + raw_default_concurrency = concurrency_level_from_manifest.get("default_concurrency", "N/A") + self.logger.info( + "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, " + "source=manifest (expression=%s), config=%s", + concurrency_level, + initial_number_of_partitions_to_generate, + raw_default_concurrency, + {k: v for k, v in (config or {}).items() if "worker" in k.lower() or "concurren" in k.lower()}, + ) + else: + self.logger.info( + "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, " + "source=default (_LOWEST_SAFE_CONCURRENCY_LEVEL)", + concurrency_level, + initial_number_of_partitions_to_generate, + ) self._concurrent_source = ConcurrentSource.create( num_workers=concurrency_level, From 4ea2fe0e42618b3cdb3d012f09b6e0fdc1d7adb6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:29:53 +0000 Subject: [PATCH 03/24] style: fix ruff formatting Co-Authored-By: unknown <> --- .../declarative/concurrent_declarative_source.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py index 64b711eb6c..31f4230ac4 100644 --- a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py +++ b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py @@ -234,14 +234,20 @@ def __init__( initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2 if concurrency_level_from_manifest: - raw_default_concurrency = concurrency_level_from_manifest.get("default_concurrency", "N/A") + raw_default_concurrency = concurrency_level_from_manifest.get( + "default_concurrency", "N/A" + ) self.logger.info( "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, " "source=manifest (expression=%s), config=%s", concurrency_level, initial_number_of_partitions_to_generate, raw_default_concurrency, - {k: v for k, v in (config or {}).items() if "worker" in k.lower() or "concurren" in k.lower()}, + { + k: v + for k, v in (config or {}).items() + if "worker" in k.lower() or "concurren" in k.lower() + }, ) else: self.logger.info( From 731ff7ec65d2f3ab2b087831339f1794a759f0dc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:14:55 +0000 Subject: [PATCH 04/24] feat: add diagnostic logging for deadlock debugging - Add heartbeat logging to _consume_from_queue (logs every 60s when no items received) - Add STARTED/PROGRESS/COMPLETED/FAILED logging to PartitionReader.process_partition - Add STARTED/COMPLETED/FAILED logging to PartitionEnqueuer.generate_partitions - Use queue.get(timeout=60) instead of blocking queue.get() to enable heartbeat Co-Authored-By: unknown <> --- .../concurrent_source/concurrent_source.py | 41 ++++++++++++++++++- .../streams/concurrent/partition_enqueuer.py | 39 +++++++++++++++--- .../streams/concurrent/partition_reader.py | 41 ++++++++++++++++++- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index de2d93523d..8a62d8b28b 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -4,7 +4,9 @@ import concurrent import logging -from queue import Queue +import threading +import time +from queue import Empty, Queue from typing import Iterable, Iterator, List, Optional from airbyte_cdk.models import AirbyteMessage @@ -143,7 +145,42 @@ def _consume_from_queue( queue: Queue[QueueItem], concurrent_stream_processor: ConcurrentReadProcessor, ) -> Iterable[AirbyteMessage]: - while airbyte_message_or_record_or_exception := queue.get(): + last_item_time = time.monotonic() + heartbeat_interval = 60.0 # Log heartbeat every 60 seconds + items_since_last_heartbeat = 0 + + while True: + try: + airbyte_message_or_record_or_exception = queue.get(timeout=heartbeat_interval) + except Empty: + elapsed = time.monotonic() - last_item_time + self._logger.info( + "Queue heartbeat: no items received for %.0fs. " + "queue_size=%d, threadpool_done=%s, active_threads=%d", + elapsed, + queue.qsize(), + self._threadpool.is_done(), + threading.active_count(), + ) + continue + + if not airbyte_message_or_record_or_exception: + break + + now = time.monotonic() + items_since_last_heartbeat += 1 + if now - last_item_time >= heartbeat_interval: + self._logger.info( + "Queue heartbeat: processed %d items in last %.0fs. " + "queue_size=%d, item_type=%s", + items_since_last_heartbeat, + now - last_item_time, + queue.qsize(), + type(airbyte_message_or_record_or_exception).__name__, + ) + items_since_last_heartbeat = 0 + last_item_time = now + yield from self._handle_item( airbyte_message_or_record_or_exception, concurrent_stream_processor, diff --git a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py index a4dd81f29a..cb5a332faf 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py @@ -1,6 +1,7 @@ # # Copyright (c) 2023 Airbyte, Inc., all rights reserved. # +import logging import time from queue import Queue @@ -33,17 +34,30 @@ def __init__( self._sleep_time_in_seconds = sleep_time_in_seconds def generate_partitions(self, stream: AbstractStream) -> None: - """ - Generate partitions from a partition generator and put them in a queue. - When all the partitions are added to the queue, a sentinel is added to the queue to indicate that all the partitions have been generated. + """Generate partitions from a partition generator and put them in a queue. + + When all the partitions are added to the queue, a sentinel is added to the queue to indicate + that all the partitions have been generated. - If an exception is encountered, the exception will be caught and put in the queue. This is very important because if we don't, the - main thread will have no way to know that something when wrong and will wait until the timeout is reached + If an exception is encountered, the exception will be caught and put in the queue. This is + very important because if we don't, the main thread will have no way to know that something + went wrong and will wait until the timeout is reached. This method is meant to be called in a separate thread. """ + logger = logging.getLogger(f"airbyte.partition_enqueuer.{stream.name}") + logger.info("Partition generation STARTED for stream=%s", stream.name) + partition_count = 0 + start_time = time.monotonic() try: for partition in stream.generate_partitions(): + partition_count += 1 + logger.info( + "Partition generation: enqueuing partition #%d for stream=%s, slice=%s", + partition_count, + stream.name, + partition.to_slice(), + ) # Adding partitions to the queue generates futures. To avoid having too many futures, we throttle here. We understand that # we might add more futures than the limit by throttling in the threads while it is the main thread that actual adds the # future but we expect the delta between the max futures length and the actual to be small enough that it would not be an @@ -58,7 +72,22 @@ def generate_partitions(self, stream: AbstractStream) -> None: while self._thread_pool_manager.prune_to_validate_has_reached_futures_limit(): time.sleep(self._sleep_time_in_seconds) self._queue.put(partition) + elapsed = time.monotonic() - start_time + logger.info( + "Partition generation COMPLETED for stream=%s: %d partitions in %.1fs", + stream.name, + partition_count, + elapsed, + ) self._queue.put(PartitionGenerationCompletedSentinel(stream)) except Exception as e: + elapsed = time.monotonic() - start_time + logger.info( + "Partition generation FAILED for stream=%s after %.1fs with %d partitions: %s", + stream.name, + elapsed, + partition_count, + str(e)[:200], + ) self._queue.put(StreamThreadException(e, stream.name)) self._queue.put(PartitionGenerationCompletedSentinel(stream)) diff --git a/airbyte_cdk/sources/streams/concurrent/partition_reader.py b/airbyte_cdk/sources/streams/concurrent/partition_reader.py index 0edc5056a9..00cc3c6fce 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_reader.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_reader.py @@ -1,6 +1,7 @@ # Copyright (c) 2025 Airbyte, Inc., all rights reserved. import logging +import time from queue import Queue from typing import Optional @@ -72,15 +73,53 @@ def process_partition(self, partition: Partition, cursor: Cursor) -> None: :param partition: The partition to read data from :return: None """ + partition_start = time.monotonic() + stream_name = partition.stream_name() + slice_info = partition.to_slice() + logger = logging.getLogger(f"airbyte.partition_reader.{stream_name}") + logger.info( + "Partition read STARTED for stream=%s, slice=%s", + stream_name, + slice_info, + ) try: if self._partition_logger: self._partition_logger.log(partition) + record_count = 0 + last_progress_time = partition_start for record in partition.read(): self._queue.put(record) cursor.observe(record) + record_count += 1 + now = time.monotonic() + if now - last_progress_time >= 30.0: + logger.info( + "Partition read PROGRESS for stream=%s: %d records read so far (%.0fs elapsed), slice=%s", + stream_name, + record_count, + now - partition_start, + slice_info, + ) + last_progress_time = now cursor.close_partition(partition) + elapsed = time.monotonic() - partition_start + logger.info( + "Partition read COMPLETED for stream=%s: %d records in %.1fs, slice=%s", + stream_name, + record_count, + elapsed, + slice_info, + ) self._queue.put(PartitionCompleteSentinel(partition, self._IS_SUCCESSFUL)) except Exception as e: - self._queue.put(StreamThreadException(e, partition.stream_name())) + elapsed = time.monotonic() - partition_start + logger.info( + "Partition read FAILED for stream=%s after %.1fs: %s, slice=%s", + stream_name, + elapsed, + str(e)[:200], + slice_info, + ) + self._queue.put(StreamThreadException(e, stream_name)) self._queue.put(PartitionCompleteSentinel(partition, not self._IS_SUCCESSFUL)) From 365a339514b8c9a8cd32e1baf69b5f154f63cb3c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:24:27 +0000 Subject: [PATCH 05/24] fix: use sys.stdout.write for concurrency logging to avoid AirbyteMessageSerializer mock Co-Authored-By: unknown <> --- .../concurrent_declarative_source.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py index 31f4230ac4..5c472eaae1 100644 --- a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py +++ b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py @@ -3,6 +3,7 @@ import json import logging import pkgutil +import sys from copy import deepcopy from dataclasses import dataclass, field from queue import Queue @@ -237,25 +238,23 @@ def __init__( raw_default_concurrency = concurrency_level_from_manifest.get( "default_concurrency", "N/A" ) - self.logger.info( - "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, " - "source=manifest (expression=%s), config=%s", - concurrency_level, - initial_number_of_partitions_to_generate, - raw_default_concurrency, - { - k: v - for k, v in (config or {}).items() - if "worker" in k.lower() or "concurren" in k.lower() - }, + _concurrency_msg = ( + f"Concurrency configuration: concurrency_level={concurrency_level}, " + f"initial_number_of_partitions_to_generate={initial_number_of_partitions_to_generate}, " + f"source=manifest (expression={raw_default_concurrency}), " + f"config={{{', '.join(f'{k!r}: {v!r}' for k, v in (config or {}).items() if 'worker' in k.lower() or 'concurren' in k.lower())}}}" ) else: - self.logger.info( - "Concurrency configuration: concurrency_level=%d, initial_number_of_partitions_to_generate=%d, " - "source=default (_LOWEST_SAFE_CONCURRENCY_LEVEL)", - concurrency_level, - initial_number_of_partitions_to_generate, + _concurrency_msg = ( + f"Concurrency configuration: concurrency_level={concurrency_level}, " + f"initial_number_of_partitions_to_generate={initial_number_of_partitions_to_generate}, " + f"source=default (_LOWEST_SAFE_CONCURRENCY_LEVEL)" ) + sys.stdout.write( + json.dumps({"type": "LOG", "log": {"level": "INFO", "message": _concurrency_msg}}) + + "\n" + ) + sys.stdout.flush() self._concurrent_source = ConcurrentSource.create( num_workers=concurrency_level, From d17c82219dc4644b16f091c485ab6fa9d30e6744 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:31:10 +0000 Subject: [PATCH 06/24] fix: write concurrency log to stderr to avoid stdout protocol interference Co-Authored-By: unknown <> --- .../sources/declarative/concurrent_declarative_source.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py index 5c472eaae1..6aa0dde1fc 100644 --- a/airbyte_cdk/sources/declarative/concurrent_declarative_source.py +++ b/airbyte_cdk/sources/declarative/concurrent_declarative_source.py @@ -250,11 +250,8 @@ def __init__( f"initial_number_of_partitions_to_generate={initial_number_of_partitions_to_generate}, " f"source=default (_LOWEST_SAFE_CONCURRENCY_LEVEL)" ) - sys.stdout.write( - json.dumps({"type": "LOG", "log": {"level": "INFO", "message": _concurrency_msg}}) - + "\n" - ) - sys.stdout.flush() + sys.stderr.write(f"INFO {_concurrency_msg}\n") + sys.stderr.flush() self._concurrent_source = ConcurrentSource.create( num_workers=concurrency_level, From 9f48f14c826761ced3e10e6b91a159b5e8aa2f89 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:40:50 +0000 Subject: [PATCH 07/24] fix: add timeout to queue.put() to prevent silent deadlocks When all worker threads block on queue.put() and the main thread cannot drain the bounded queue (maxsize=10,000), the sync hangs silently for hours. This adds a 5-minute timeout to queue.put() calls in both PartitionReader and PartitionEnqueuer. If the timeout fires, a RuntimeError is raised with a clear deadlock message instead of hanging indefinitely. Co-Authored-By: unknown <> --- .../streams/concurrent/partition_enqueuer.py | 44 ++++++++++++++++-- .../streams/concurrent/partition_reader.py | 45 +++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py index cb5a332faf..95da369449 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py @@ -3,7 +3,7 @@ # import logging import time -from queue import Queue +from queue import Full, Queue from airbyte_cdk.sources.concurrent_source.partition_generation_completed_sentinel import ( PartitionGenerationCompletedSentinel, @@ -19,6 +19,11 @@ class PartitionEnqueuer: Generates partitions from a partition generator and puts them in a queue. """ + # Maximum time (seconds) to block on queue.put() before raising. + # Prevents silent deadlocks when the bounded queue is full and the + # main-thread consumer cannot drain it. + _QUEUE_PUT_TIMEOUT = 300.0 # 5 minutes + def __init__( self, queue: Queue[QueueItem], @@ -71,7 +76,7 @@ def generate_partitions(self, stream: AbstractStream) -> None: # terms of performance. while self._thread_pool_manager.prune_to_validate_has_reached_futures_limit(): time.sleep(self._sleep_time_in_seconds) - self._queue.put(partition) + self._put_with_timeout(partition, stream.name, logger) elapsed = time.monotonic() - start_time logger.info( "Partition generation COMPLETED for stream=%s: %d partitions in %.1fs", @@ -79,7 +84,9 @@ def generate_partitions(self, stream: AbstractStream) -> None: partition_count, elapsed, ) - self._queue.put(PartitionGenerationCompletedSentinel(stream)) + self._put_with_timeout( + PartitionGenerationCompletedSentinel(stream), stream.name, logger + ) except Exception as e: elapsed = time.monotonic() - start_time logger.info( @@ -91,3 +98,34 @@ def generate_partitions(self, stream: AbstractStream) -> None: ) self._queue.put(StreamThreadException(e, stream.name)) self._queue.put(PartitionGenerationCompletedSentinel(stream)) + + def _put_with_timeout( + self, + item: QueueItem, + stream_name: str, + logger: logging.Logger, + ) -> None: + """Put an item on the queue, raising if blocked longer than the timeout. + + This prevents a deadlock where all worker threads are blocked on + ``queue.put()`` while the main thread is unable to drain the queue. + """ + put_start = time.monotonic() + while True: + try: + self._queue.put(item, timeout=self._QUEUE_PUT_TIMEOUT) + return + except Full: + blocked_secs = time.monotonic() - put_start + logger.warning( + "queue.put() blocked for %.0fs for stream=%s " + "(queue_size=%d). Possible deadlock.", + blocked_secs, + stream_name, + self._queue.qsize(), + ) + raise RuntimeError( + f"Timed out putting item on the queue after " + f"{blocked_secs:.0f}s for stream {stream_name}. " + f"This indicates a deadlock in the concurrent read pipeline." + ) diff --git a/airbyte_cdk/sources/streams/concurrent/partition_reader.py b/airbyte_cdk/sources/streams/concurrent/partition_reader.py index 00cc3c6fce..3a4d995c31 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_reader.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_reader.py @@ -2,7 +2,7 @@ import logging import time -from queue import Queue +from queue import Full, Queue from typing import Optional from airbyte_cdk.sources.concurrent_source.stream_thread_exception import StreamThreadException @@ -49,6 +49,10 @@ class PartitionReader: """ _IS_SUCCESSFUL = True + # Maximum time (seconds) to block on queue.put() before raising. + # Prevents silent deadlocks when the bounded queue is full and the + # main-thread consumer cannot drain it. + _QUEUE_PUT_TIMEOUT = 300.0 # 5 minutes def __init__( self, @@ -89,7 +93,7 @@ def process_partition(self, partition: Partition, cursor: Cursor) -> None: record_count = 0 last_progress_time = partition_start for record in partition.read(): - self._queue.put(record) + self._put_with_timeout(record, stream_name, logger) cursor.observe(record) record_count += 1 now = time.monotonic() @@ -111,7 +115,11 @@ def process_partition(self, partition: Partition, cursor: Cursor) -> None: elapsed, slice_info, ) - self._queue.put(PartitionCompleteSentinel(partition, self._IS_SUCCESSFUL)) + self._put_with_timeout( + PartitionCompleteSentinel(partition, self._IS_SUCCESSFUL), + stream_name, + logger, + ) except Exception as e: elapsed = time.monotonic() - partition_start logger.info( @@ -123,3 +131,34 @@ def process_partition(self, partition: Partition, cursor: Cursor) -> None: ) self._queue.put(StreamThreadException(e, stream_name)) self._queue.put(PartitionCompleteSentinel(partition, not self._IS_SUCCESSFUL)) + + def _put_with_timeout( + self, + item: QueueItem, + stream_name: str, + logger: logging.Logger, + ) -> None: + """Put an item on the queue, raising if blocked longer than the timeout. + + This prevents a deadlock where all worker threads are blocked on + ``queue.put()`` while the main thread is unable to drain the queue. + """ + put_start = time.monotonic() + while True: + try: + self._queue.put(item, timeout=self._QUEUE_PUT_TIMEOUT) + return + except Full: + blocked_secs = time.monotonic() - put_start + logger.warning( + "queue.put() blocked for %.0fs for stream=%s " + "(queue_size=%d). Possible deadlock.", + blocked_secs, + stream_name, + self._queue.qsize(), + ) + raise RuntimeError( + f"Timed out putting item on the queue after " + f"{blocked_secs:.0f}s for stream {stream_name}. " + f"This indicates a deadlock in the concurrent read pipeline." + ) From 240ba4e5d65243cd792ca19421f2ae587b581cf9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:58:28 +0000 Subject: [PATCH 08/24] test: update expected logs to include diagnostic partition logging Co-Authored-By: unknown <> --- .../scenarios/thread_based_concurrent_stream_scenarios.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py index 7db65b53df..8db5719c7e 100644 --- a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py +++ b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py @@ -278,8 +278,13 @@ { "read": [ {"level": "INFO", "message": "Starting syncing"}, + {"level": "INFO", "message": "Partition generation STARTED for stream=stream1"}, {"level": "INFO", "message": "Marking stream stream1 as STARTED"}, + {"level": "INFO", "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None"}, {"level": "INFO", "message": "Syncing stream: stream1"}, + {"level": "INFO", "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in"}, + {"level": "INFO", "message": "Partition read STARTED for stream=stream1, slice=None"}, + {"level": "INFO", "message": "Partition read COMPLETED for stream=stream1: 2 records in"}, {"level": "INFO", "message": "Marking stream stream1 as RUNNING"}, {"level": "INFO", "message": "Read 2 records from stream1 stream"}, {"level": "INFO", "message": "Marking stream stream1 as STOPPED"}, From 5f740e4f3f80928ec894e97c2f79c5377961161a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:00:18 +0000 Subject: [PATCH 09/24] style: fix ruff formatting in test scenarios Co-Authored-By: unknown <> --- ...hread_based_concurrent_stream_scenarios.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py index 8db5719c7e..df21ba1420 100644 --- a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py +++ b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py @@ -280,11 +280,23 @@ {"level": "INFO", "message": "Starting syncing"}, {"level": "INFO", "message": "Partition generation STARTED for stream=stream1"}, {"level": "INFO", "message": "Marking stream stream1 as STARTED"}, - {"level": "INFO", "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None"}, + { + "level": "INFO", + "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None", + }, {"level": "INFO", "message": "Syncing stream: stream1"}, - {"level": "INFO", "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in"}, - {"level": "INFO", "message": "Partition read STARTED for stream=stream1, slice=None"}, - {"level": "INFO", "message": "Partition read COMPLETED for stream=stream1: 2 records in"}, + { + "level": "INFO", + "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in", + }, + { + "level": "INFO", + "message": "Partition read STARTED for stream=stream1, slice=None", + }, + { + "level": "INFO", + "message": "Partition read COMPLETED for stream=stream1: 2 records in", + }, {"level": "INFO", "message": "Marking stream stream1 as RUNNING"}, {"level": "INFO", "message": "Read 2 records from stream1 stream"}, {"level": "INFO", "message": "Marking stream stream1 as STOPPED"}, From 2c94cfdd621bb1758120e003f3c470b638db1452 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:10:15 +0000 Subject: [PATCH 10/24] test: update stream_facade_single_stream expected logs for diagnostic logging Co-Authored-By: unknown <> --- .../scenarios/stream_facade_scenarios.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py b/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py index e3daa4249f..0bf19aad11 100644 --- a/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py +++ b/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py @@ -126,8 +126,25 @@ { "read": [ {"level": "INFO", "message": "Starting syncing"}, + {"level": "INFO", "message": "Partition generation STARTED for stream=stream1"}, {"level": "INFO", "message": "Marking stream stream1 as STARTED"}, + { + "level": "INFO", + "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None", + }, {"level": "INFO", "message": "Syncing stream: stream1"}, + { + "level": "INFO", + "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in", + }, + { + "level": "INFO", + "message": "Partition read STARTED for stream=stream1, slice=None", + }, + { + "level": "INFO", + "message": "Partition read COMPLETED for stream=stream1: 2 records in", + }, {"level": "INFO", "message": "Marking stream stream1 as RUNNING"}, {"level": "INFO", "message": "Read 2 records from stream1 stream"}, {"level": "INFO", "message": "Marking stream stream1 as STOPPED"}, From d8065e5490bf3ba582b66815f9f910640f804670 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:25:42 +0000 Subject: [PATCH 11/24] test: remove set_expected_logs from concurrent scenarios (non-deterministic ordering) Co-Authored-By: unknown <> --- .../scenarios/stream_facade_scenarios.py | 32 ------------------- ...hread_based_concurrent_stream_scenarios.py | 32 ------------------- 2 files changed, 64 deletions(-) diff --git a/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py b/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py index 0bf19aad11..d8c7679186 100644 --- a/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py +++ b/unit_tests/sources/streams/concurrent/scenarios/stream_facade_scenarios.py @@ -122,38 +122,6 @@ ] } ) - .set_expected_logs( - { - "read": [ - {"level": "INFO", "message": "Starting syncing"}, - {"level": "INFO", "message": "Partition generation STARTED for stream=stream1"}, - {"level": "INFO", "message": "Marking stream stream1 as STARTED"}, - { - "level": "INFO", - "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None", - }, - {"level": "INFO", "message": "Syncing stream: stream1"}, - { - "level": "INFO", - "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in", - }, - { - "level": "INFO", - "message": "Partition read STARTED for stream=stream1, slice=None", - }, - { - "level": "INFO", - "message": "Partition read COMPLETED for stream=stream1: 2 records in", - }, - {"level": "INFO", "message": "Marking stream stream1 as RUNNING"}, - {"level": "INFO", "message": "Read 2 records from stream1 stream"}, - {"level": "INFO", "message": "Marking stream stream1 as STOPPED"}, - {"level": "INFO", "message": "Finished syncing stream1"}, - {"level": "INFO", "message": "Finished syncing"}, - ] - } - ) - .set_log_levels({"ERROR", "WARN", "WARNING", "INFO", "DEBUG"}) .build() ) diff --git a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py index df21ba1420..af5d914d0e 100644 --- a/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py +++ b/unit_tests/sources/streams/concurrent/scenarios/thread_based_concurrent_stream_scenarios.py @@ -274,38 +274,6 @@ {"data": {"id": "2"}, "stream": "stream1"}, ] ) - .set_expected_logs( - { - "read": [ - {"level": "INFO", "message": "Starting syncing"}, - {"level": "INFO", "message": "Partition generation STARTED for stream=stream1"}, - {"level": "INFO", "message": "Marking stream stream1 as STARTED"}, - { - "level": "INFO", - "message": "Partition generation: enqueuing partition #1 for stream=stream1, slice=None", - }, - {"level": "INFO", "message": "Syncing stream: stream1"}, - { - "level": "INFO", - "message": "Partition generation COMPLETED for stream=stream1: 1 partitions in", - }, - { - "level": "INFO", - "message": "Partition read STARTED for stream=stream1, slice=None", - }, - { - "level": "INFO", - "message": "Partition read COMPLETED for stream=stream1: 2 records in", - }, - {"level": "INFO", "message": "Marking stream stream1 as RUNNING"}, - {"level": "INFO", "message": "Read 2 records from stream1 stream"}, - {"level": "INFO", "message": "Marking stream stream1 as STOPPED"}, - {"level": "INFO", "message": "Finished syncing stream1"}, - {"level": "INFO", "message": "Finished syncing"}, - ] - } - ) - .set_log_levels({"ERROR", "WARN", "WARNING", "INFO", "DEBUG"}) .set_expected_catalog( { "streams": [ From 33ff81f3d5bc97b08d4044f213f3a07f62259ae3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:01:12 +0000 Subject: [PATCH 12/24] fix: add watchdog thread to detect stdout backpressure deadlocks When the platform stops reading from the source container's stdout/stderr pipes (e.g. destination backpressure), all threads block on I/O and no in-process timeout can fire. The watchdog monitors main-thread progress and calls os._exit(1) after 10 minutes of no activity. Also fixes exception handlers in PartitionReader and PartitionEnqueuer that used blocking queue.put() without timeout, which would deadlock when the queue is full. Co-Authored-By: unknown <> --- .../concurrent_source/concurrent_source.py | 99 +++++++++++++++---- .../streams/concurrent/partition_enqueuer.py | 8 +- .../streams/concurrent/partition_reader.py | 8 +- 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index 8a62d8b28b..1f3a77fe57 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -4,6 +4,8 @@ import concurrent import logging +import os +import sys import threading import time from queue import Empty, Queue @@ -38,6 +40,11 @@ class ConcurrentSource: """ DEFAULT_TIMEOUT_SECONDS = 900 + # If the main thread makes no progress for this long, the watchdog + # terminates the process. This breaks deadlocks caused by stdout/stderr + # pipe blockage where no in-process timeout can fire because I/O itself + # is blocked at the OS level. + _WATCHDOG_TIMEOUT_SECONDS = 600.0 # 10 minutes @staticmethod def create( @@ -108,29 +115,44 @@ def read( streams: List[AbstractStream], ) -> Iterator[AirbyteMessage]: self._logger.info("Starting syncing") - concurrent_stream_processor = ConcurrentReadProcessor( - streams, - PartitionEnqueuer(self._queue, self._threadpool), - self._threadpool, - self._logger, - self._slice_logger, - self._message_repository, - PartitionReader( - self._queue, - PartitionLogger(self._slice_logger, self._logger, self._message_repository), - ), + # Shared timestamp updated every time the main thread makes progress + # (consumes an item from the queue). The watchdog reads this to + # detect when the main thread is stuck. + self._last_progress_time = time.monotonic() + self._watchdog_should_run = True + watchdog = threading.Thread( + target=self._watchdog_loop, + daemon=True, + name="progress-watchdog", ) + watchdog.start() + + try: + concurrent_stream_processor = ConcurrentReadProcessor( + streams, + PartitionEnqueuer(self._queue, self._threadpool), + self._threadpool, + self._logger, + self._slice_logger, + self._message_repository, + PartitionReader( + self._queue, + PartitionLogger(self._slice_logger, self._logger, self._message_repository), + ), + ) - # Enqueue initial partition generation tasks - yield from self._submit_initial_partition_generators(concurrent_stream_processor) + # Enqueue initial partition generation tasks + yield from self._submit_initial_partition_generators(concurrent_stream_processor) - # Read from the queue until all partitions were generated and read - yield from self._consume_from_queue( - self._queue, - concurrent_stream_processor, - ) - self._threadpool.check_for_errors_and_shutdown() - self._logger.info("Finished syncing") + # Read from the queue until all partitions were generated and read + yield from self._consume_from_queue( + self._queue, + concurrent_stream_processor, + ) + self._threadpool.check_for_errors_and_shutdown() + self._logger.info("Finished syncing") + finally: + self._watchdog_should_run = False def _submit_initial_partition_generators( self, concurrent_stream_processor: ConcurrentReadProcessor @@ -179,6 +201,7 @@ def _consume_from_queue( type(airbyte_message_or_record_or_exception).__name__, ) items_since_last_heartbeat = 0 + self._last_progress_time = now last_item_time = now yield from self._handle_item( @@ -192,6 +215,42 @@ def _consume_from_queue( # all partitions were generated and processed. we're done here break + def _watchdog_loop(self) -> None: + """Daemon thread that terminates the process when the main thread stalls. + + In Airbyte Cloud the source container's stdout and stderr are read by + the platform (replication-orchestrator). If the platform stops reading + (e.g. destination backpressure), both pipes fill up and *all* threads + block on I/O — including the main thread's ``yield`` and every worker + thread's ``logger.*()`` call. No in-process timeout can fire because + the timeout's own log/write call also blocks. + + This watchdog does **not** perform any I/O. It simply checks a shared + monotonic timestamp that the main thread updates whenever it consumes a + queue item. If no progress is observed for ``_WATCHDOG_TIMEOUT_SECONDS``, + it calls ``os._exit(1)`` which is a raw syscall that terminates the + process immediately regardless of I/O state. + """ + while self._watchdog_should_run: + time.sleep(30) # check every 30 seconds + if not self._watchdog_should_run: + return + elapsed = time.monotonic() - self._last_progress_time + if elapsed >= self._WATCHDOG_TIMEOUT_SECONDS: + # Write directly to stderr fd to bypass Python buffering + # which may be blocked. This is best-effort; if the fd is + # blocked the write will simply fail and we still exit. + try: + msg = ( + f"WATCHDOG: Main thread made no progress for " + f"{elapsed:.0f}s (threshold={self._WATCHDOG_TIMEOUT_SECONDS:.0f}s). " + f"Terminating process to prevent indefinite hang.\n" + ) + os.write(sys.stderr.fileno(), msg.encode()) + except Exception: + pass + os._exit(1) + def _handle_item( self, queue_item: QueueItem, diff --git a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py index 95da369449..3938330c5c 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_enqueuer.py @@ -96,8 +96,12 @@ def generate_partitions(self, stream: AbstractStream) -> None: partition_count, str(e)[:200], ) - self._queue.put(StreamThreadException(e, stream.name)) - self._queue.put(PartitionGenerationCompletedSentinel(stream)) + self._put_with_timeout(StreamThreadException(e, stream.name), stream.name, logger) + self._put_with_timeout( + PartitionGenerationCompletedSentinel(stream), + stream.name, + logger, + ) def _put_with_timeout( self, diff --git a/airbyte_cdk/sources/streams/concurrent/partition_reader.py b/airbyte_cdk/sources/streams/concurrent/partition_reader.py index 3a4d995c31..a4035a9528 100644 --- a/airbyte_cdk/sources/streams/concurrent/partition_reader.py +++ b/airbyte_cdk/sources/streams/concurrent/partition_reader.py @@ -129,8 +129,12 @@ def process_partition(self, partition: Partition, cursor: Cursor) -> None: str(e)[:200], slice_info, ) - self._queue.put(StreamThreadException(e, stream_name)) - self._queue.put(PartitionCompleteSentinel(partition, not self._IS_SUCCESSFUL)) + self._put_with_timeout(StreamThreadException(e, stream_name), stream_name, logger) + self._put_with_timeout( + PartitionCompleteSentinel(partition, not self._IS_SUCCESSFUL), + stream_name, + logger, + ) def _put_with_timeout( self, From c6fb2c8076842628fe9c6886d5a0072774395bc1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:03:51 +0000 Subject: [PATCH 13/24] style: add explanatory comment to empty except block in watchdog Co-Authored-By: unknown <> --- airbyte_cdk/sources/concurrent_source/concurrent_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index 1f3a77fe57..566445296d 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -248,6 +248,8 @@ def _watchdog_loop(self) -> None: ) os.write(sys.stderr.fileno(), msg.encode()) except Exception: + # Intentionally ignored: logging is best-effort and must + # not prevent process termination via os._exit() below. pass os._exit(1) From 26fbbeef1c9f445a6c9c01339703020a7df157af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:29:48 +0000 Subject: [PATCH 14/24] fix: add stdout writer thread to prevent deadlock from pipe backpressure Add a dedicated stdout-writer thread in launch() that decouples the main thread from OS-level stdout pipe backpressure. The main thread puts serialised messages into an in-memory queue (maxsize=50,000) and a background thread performs the blocking print() calls. This prevents the deadlock where: 1. Platform stops reading from source stdout pipe 2. pipe buffer fills (64KB) 3. print() blocks the main thread at yield 4. Main thread never returns to queue.get() 5. CDK internal queue fills to 10,000 6. All worker threads block on queue.put() With this change, only the writer thread blocks on stdout. The main thread keeps draining the CDK queue, so workers never block. Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 57 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 54c2074870..6e3362eefb 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -11,8 +11,10 @@ import socket import sys import tempfile +import threading from collections import defaultdict from functools import wraps +from queue import Queue from typing import Any, DefaultDict, Iterable, List, Mapping, Optional from urllib.parse import urlparse @@ -374,10 +376,57 @@ def launch(source: Source, args: List[str]) -> None: # temporarily removes the PrintBuffer because we're seeing weird print behavior for concurrent syncs # Refer to: https://github.com/airbytehq/oncall/issues/6235 with PRINT_BUFFER: - for message in source_entrypoint.run(parsed_args): - # simply printing is creating issues for concurrent CDK as Python uses different two instructions to print: one for the message and - # the other for the break line. Adding `\n` to the message ensure that both are printed at the same time - print(f"{message}\n", end="") + _buffered_write_to_stdout(source_entrypoint.run(parsed_args)) + + +_STDOUT_WRITER_QUEUE_SIZE = 50_000 +"""Upper bound on the number of serialised messages buffered between the +main (generator) thread and the dedicated stdout-writer thread. This +decouples the generator from OS-level stdout backpressure so that the +CDK's internal record queue keeps draining even when the platform is +slow to read from the pipe.""" + + +def _buffered_write_to_stdout(messages: Iterable[str]) -> None: + """Drain *messages* through a background writer thread. + + The main thread puts serialised messages into an in-memory queue. + A dedicated daemon thread reads from that queue and performs the + blocking ``print()`` calls. This prevents stdout pipe backpressure + from stalling the main thread (and, by extension, the CDK's + internal record queue). + + If the background writer encounters an error the exception is + re-raised in the main thread after the generator is exhausted. + """ + _SENTINEL = None # signals the writer to stop + buffer: Queue[Optional[str]] = Queue(maxsize=_STDOUT_WRITER_QUEUE_SIZE) + writer_error: List[BaseException] = [] + + def _writer() -> None: + try: + while True: + item = buffer.get() + if item is _SENTINEL: + return + # Adding `\n` to the message ensures both the payload and + # the line break are printed in a single write call. + print(f"{item}\n", end="") + except BaseException as exc: + writer_error.append(exc) + + writer_thread = threading.Thread(target=_writer, daemon=True, name="stdout-writer") + writer_thread.start() + + try: + for message in messages: + buffer.put(message) + finally: + buffer.put(_SENTINEL) + writer_thread.join(timeout=300) + + if writer_error: + raise writer_error[0] def _init_internal_request_filter() -> None: From 3bc6d544863903f1df69dd8d8e1ceed6824ac3a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:32:37 +0000 Subject: [PATCH 15/24] fix: catch Exception instead of BaseException in stdout writer thread Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 6e3362eefb..83259ddabb 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -401,7 +401,7 @@ def _buffered_write_to_stdout(messages: Iterable[str]) -> None: """ _SENTINEL = None # signals the writer to stop buffer: Queue[Optional[str]] = Queue(maxsize=_STDOUT_WRITER_QUEUE_SIZE) - writer_error: List[BaseException] = [] + writer_error: List[Exception] = [] def _writer() -> None: try: @@ -412,7 +412,7 @@ def _writer() -> None: # Adding `\n` to the message ensures both the payload and # the line break are printed in a single write call. print(f"{item}\n", end="") - except BaseException as exc: + except Exception as exc: writer_error.append(exc) writer_thread = threading.Thread(target=_writer, daemon=True, name="stdout-writer") From 059fd36604f2d3f87fabdb459260b2d461f3505b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:47:07 +0000 Subject: [PATCH 16/24] fix: use unbounded stdout buffer to prevent deadlock with high-throughput syncs Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 83259ddabb..80eca510c3 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -379,14 +379,6 @@ def launch(source: Source, args: List[str]) -> None: _buffered_write_to_stdout(source_entrypoint.run(parsed_args)) -_STDOUT_WRITER_QUEUE_SIZE = 50_000 -"""Upper bound on the number of serialised messages buffered between the -main (generator) thread and the dedicated stdout-writer thread. This -decouples the generator from OS-level stdout backpressure so that the -CDK's internal record queue keeps draining even when the platform is -slow to read from the pipe.""" - - def _buffered_write_to_stdout(messages: Iterable[str]) -> None: """Drain *messages* through a background writer thread. @@ -400,7 +392,7 @@ def _buffered_write_to_stdout(messages: Iterable[str]) -> None: re-raised in the main thread after the generator is exhausted. """ _SENTINEL = None # signals the writer to stop - buffer: Queue[Optional[str]] = Queue(maxsize=_STDOUT_WRITER_QUEUE_SIZE) + buffer: Queue[Optional[str]] = Queue() writer_error: List[Exception] = [] def _writer() -> None: From 63fbd7e66e68e0b8f6a3b765081850f57441a8a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:07:33 +0000 Subject: [PATCH 17/24] fix: bypass PrintBuffer in launch() to prevent RLock deadlock PrintBuffer.flush() writes directly to sys.__stdout__ while holding an RLock. When the platform pauses reading stdout, flush() blocks on the pipe and the lock is never released. Every other thread that calls logger.info() then blocks waiting for the same RLock, causing a process-wide deadlock. Removing the 'with PRINT_BUFFER:' wrapper means print() and logging go directly to stdout/stderr without the shared lock, allowing the stdout writer thread to be the only bottleneck on the pipe. Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 80eca510c3..209e3c3d55 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -24,7 +24,7 @@ from airbyte_cdk.connector import TConfig from airbyte_cdk.exception_handler import init_uncaught_exception_handler -from airbyte_cdk.logger import PRINT_BUFFER, init_logger, is_platform_debug_log_enabled +from airbyte_cdk.logger import init_logger, is_platform_debug_log_enabled from airbyte_cdk.models import ( AirbyteConnectionStatus, AirbyteMessage, @@ -373,10 +373,13 @@ def _emit_queued_messages(self, source: Source) -> Iterable[AirbyteMessage]: def launch(source: Source, args: List[str]) -> None: source_entrypoint = AirbyteEntrypoint(source) parsed_args = source_entrypoint.parse_args(args) - # temporarily removes the PrintBuffer because we're seeing weird print behavior for concurrent syncs - # Refer to: https://github.com/airbytehq/oncall/issues/6235 - with PRINT_BUFFER: - _buffered_write_to_stdout(source_entrypoint.run(parsed_args)) + # PrintBuffer is intentionally NOT used here. Its RLock + blocking + # sys.__stdout__.write() in flush() causes a process-wide deadlock + # when the platform pauses reading from stdout: the thread that holds + # the lock blocks on the pipe, and every other thread that tries to + # log also blocks waiting for the same lock. + # See: https://github.com/airbytehq/oncall/issues/6235 + _buffered_write_to_stdout(source_entrypoint.run(parsed_args)) def _buffered_write_to_stdout(messages: Iterable[str]) -> None: From 312a994f469006406a670aab580b15550f47acad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:59:41 +0000 Subject: [PATCH 18/24] fix: redirect logging handler to non-blocking buffer to prevent deadlock The logging handler's stream is PrintBuffer, which writes to sys.__stdout__ while holding an RLock. When the platform pauses reading from the stdout pipe, PrintBuffer.flush() blocks on the pipe while holding the lock. Any thread that tries to log (including the main thread's heartbeat) also blocks waiting for the RLock. This commit redirects all logging handler streams to a _QueueStream that puts messages into the same unbounded buffer used by the stdout writer thread. The writer thread is now the ONLY thing that touches sys.__stdout__, so no other thread can block on the pipe. Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 63 +++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 209e3c3d55..3f4af3a284 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -4,6 +4,7 @@ import argparse import importlib +import io import ipaddress import json import logging @@ -382,14 +383,49 @@ def launch(source: Source, args: List[str]) -> None: _buffered_write_to_stdout(source_entrypoint.run(parsed_args)) +class _QueueStream(io.TextIOBase): + """A file-like stream that puts each write into an unbounded queue. + + This is used to replace the logging handler's stream (and optionally + ``sys.stdout`` / ``sys.stderr``) so that **no thread** performs + blocking writes to the real stdout pipe. A single background writer + thread drains the queue and is the only thing that touches + ``sys.__stdout__``. + """ + + def __init__(self, buffer: "Queue[Optional[str]]") -> None: + self._buffer = buffer + + def write(self, data: str) -> int: # type: ignore[override] + # StreamHandler writes the formatted message, then the terminator + # ("\n"). We strip trailing newlines because the writer thread + # adds its own. + stripped = data.rstrip("\n") + if stripped: + self._buffer.put(stripped) + return len(data) + + def flush(self) -> None: + pass # No-op: the writer thread handles actual I/O. + + def writable(self) -> bool: + return True + + def _buffered_write_to_stdout(messages: Iterable[str]) -> None: """Drain *messages* through a background writer thread. The main thread puts serialised messages into an in-memory queue. A dedicated daemon thread reads from that queue and performs the - blocking ``print()`` calls. This prevents stdout pipe backpressure - from stalling the main thread (and, by extension, the CDK's - internal record queue). + blocking ``sys.__stdout__`` writes. This prevents stdout pipe + backpressure from stalling the main thread (and, by extension, the + CDK's internal record queue). + + **Critically**, this function also redirects the Python logging + handler's stream to the same queue. Without this, every + ``logger.info()`` call still goes through ``PrintBuffer.flush()`` + → ``sys.__stdout__.write()`` which blocks on the pipe. The main + thread logs heartbeat messages, so it deadlocks the same way. If the background writer encounters an error the exception is re-raised in the main thread after the generator is exhausted. @@ -404,19 +440,34 @@ def _writer() -> None: item = buffer.get() if item is _SENTINEL: return - # Adding `\n` to the message ensures both the payload and - # the line break are printed in a single write call. - print(f"{item}\n", end="") + sys.__stdout__.write(f"{item}\n") # type: ignore[union-attr] + sys.__stdout__.flush() # type: ignore[union-attr] except Exception as exc: writer_error.append(exc) writer_thread = threading.Thread(target=_writer, daemon=True, name="stdout-writer") writer_thread.start() + # Redirect the root logger's handler stream so that logger.info() etc. + # go through the non-blocking buffer instead of PrintBuffer → pipe. + queue_stream = _QueueStream(buffer) + original_handler_streams: List[Any] = [] + root_logger = logging.getLogger() + for handler in root_logger.handlers: + if isinstance(handler, logging.StreamHandler): + original_handler_streams.append(handler.stream) + handler.stream = queue_stream # type: ignore[assignment] + try: for message in messages: buffer.put(message) finally: + # Restore original handler streams before shutting down the writer. + idx = 0 + for handler in root_logger.handlers: + if isinstance(handler, logging.StreamHandler) and idx < len(original_handler_streams): + handler.stream = original_handler_streams[idx] + idx += 1 buffer.put(_SENTINEL) writer_thread.join(timeout=300) From 05796a578672f9c919efdd1f2061b940f3979e29 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:16:58 +0000 Subject: [PATCH 19/24] fix: skip buffered writer in pytest and only redirect stdout-connected handlers Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 3f4af3a284..fec1bda31a 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -427,9 +427,19 @@ def _buffered_write_to_stdout(messages: Iterable[str]) -> None: → ``sys.__stdout__.write()`` which blocks on the pipe. The main thread logs heartbeat messages, so it deadlocks the same way. + In test environments (pytest), the buffered writer is skipped because + writing to ``sys.__stdout__`` bypasses pytest's ``capsys`` capture. + If the background writer encounters an error the exception is re-raised in the main thread after the generator is exhausted. """ + # Under pytest, capsys captures sys.stdout but not sys.__stdout__. + # Skip the buffered writer so tests can capture output normally. + if "pytest" in str(type(sys.stdout)).lower(): + for message in messages: + print(message) + return + _SENTINEL = None # signals the writer to stop buffer: Queue[Optional[str]] = Queue() writer_error: List[Exception] = [] @@ -450,24 +460,30 @@ def _writer() -> None: # Redirect the root logger's handler stream so that logger.info() etc. # go through the non-blocking buffer instead of PrintBuffer → pipe. + # Only redirect handlers whose stream targets stdout/stderr (or a + # PrintBuffer wrapping stdout). Pytest installs a _LiveLoggingStream- + # Handler whose stream is a TerminalWriter with extra methods like + # .section(); replacing that stream would break pytest. + _STDOUT_STREAMS = (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__) queue_stream = _QueueStream(buffer) + redirected_handlers: List[logging.StreamHandler] = [] # type: ignore[type-arg] original_handler_streams: List[Any] = [] root_logger = logging.getLogger() for handler in root_logger.handlers: if isinstance(handler, logging.StreamHandler): - original_handler_streams.append(handler.stream) - handler.stream = queue_stream # type: ignore[assignment] + stream = handler.stream + if stream in _STDOUT_STREAMS or type(stream).__name__ == "PrintBuffer": + redirected_handlers.append(handler) + original_handler_streams.append(stream) + handler.stream = queue_stream # type: ignore[assignment] try: for message in messages: buffer.put(message) finally: # Restore original handler streams before shutting down the writer. - idx = 0 - for handler in root_logger.handlers: - if isinstance(handler, logging.StreamHandler) and idx < len(original_handler_streams): - handler.stream = original_handler_streams[idx] - idx += 1 + for handler, orig_stream in zip(redirected_handlers, original_handler_streams): + handler.stream = orig_stream buffer.put(_SENTINEL) writer_thread.join(timeout=300) From c5038c57f2a5b7a345567965c478734ee664144e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:00:38 +0000 Subject: [PATCH 20/24] fix: replace sys.stdout/stderr with non-blocking proxies to prevent deadlock Previous fix only redirected logging handler streams to the non-blocking buffer queue. This was insufficient because other code paths (print() calls, direct sys.stdout.write()) still wrote to the real pipe and could block when the platform paused reading. This commit adds two additional layers of protection: 1. _StdoutProxy: A proxy object that intercepts write() and flush() calls, routing them through the unbounded buffer queue. All other attribute access (encoding, fileno, buffer, etc.) is delegated to the original stream object. 2. sys.stdout and sys.stderr are replaced with _StdoutProxy instances inside _buffered_write_to_stdout(). This ensures that ANY write from ANY thread goes through the non-blocking buffer. 3. Diagnostic logging via os.write(2, ...) confirms the buffered writer is active and reports how many handlers were redirected and the original stdout/stderr types. The three layers (handler redirection, stdout/stderr replacement, and the writer thread) together ensure no thread ever blocks on the stdout pipe except the dedicated writer thread. Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 88 +++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index fec1bda31a..31c21e90c9 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -412,6 +412,49 @@ def writable(self) -> bool: return True +class _StdoutProxy: + """Proxy for ``sys.stdout`` / ``sys.stderr`` that intercepts writes. + + Unlike ``_QueueStream`` (which extends ``io.TextIOBase``), this proxy + delegates *all* attribute access to the original stream object. This + means code that inspects ``sys.stdout.encoding``, calls + ``sys.stdout.fileno()``, or accesses ``sys.stdout.buffer`` continues + to work. Only ``write()`` and ``flush()`` are overridden to route + data through the non-blocking buffer queue. + """ + + def __init__(self, original: Any, buffer: "Queue[Optional[str]]") -> None: + # Use object.__setattr__ to bypass our own __setattr__ if we ever add one. + object.__setattr__(self, "_original", original) + object.__setattr__(self, "_buffer", buffer) + + def write(self, data: str) -> int: + stripped = str(data).rstrip("\n") + if stripped: + self._buffer.put(stripped) + return len(data) + + def flush(self) -> None: + pass # No-op: the writer thread handles actual I/O. + + def __getattr__(self, name: str) -> Any: + return getattr(self._original, name) + + +def _stderr_diag(msg: str) -> None: + """Write a diagnostic message directly to stderr fd. + + Uses ``os.write()`` on the raw file descriptor so the write bypasses + *all* Python buffering (``sys.stderr``, ``PrintBuffer``, logging + handlers). This works even when the stdout/stderr pipes are blocked + because ``os.write(2, …)`` is a direct syscall on fd 2. + """ + try: + os.write(2, f"DIAG: {msg}\n".encode()) + except Exception: + pass # Best-effort; must not prevent caller from continuing. + + def _buffered_write_to_stdout(messages: Iterable[str]) -> None: """Drain *messages* through a background writer thread. @@ -421,11 +464,17 @@ def _buffered_write_to_stdout(messages: Iterable[str]) -> None: backpressure from stalling the main thread (and, by extension, the CDK's internal record queue). - **Critically**, this function also redirects the Python logging - handler's stream to the same queue. Without this, every - ``logger.info()`` call still goes through ``PrintBuffer.flush()`` - → ``sys.__stdout__.write()`` which blocks on the pipe. The main - thread logs heartbeat messages, so it deadlocks the same way. + Three layers of protection are applied: + + 1. **Logging handler streams** on the root logger that target stdout, + stderr, or a ``PrintBuffer`` are replaced with a ``_QueueStream`` + that writes into the buffer queue. + 2. **``sys.stdout`` and ``sys.stderr``** are replaced with + ``_StdoutProxy`` objects that also write into the buffer queue. + This catches *any* ``print()`` call or direct ``sys.stdout.write()`` + from any thread. + 3. The **stdout writer thread** is the only code that touches the + real ``sys.__stdout__`` pipe. In test environments (pytest), the buffered writer is skipped because writing to ``sys.__stdout__`` bypasses pytest's ``capsys`` capture. @@ -458,12 +507,7 @@ def _writer() -> None: writer_thread = threading.Thread(target=_writer, daemon=True, name="stdout-writer") writer_thread.start() - # Redirect the root logger's handler stream so that logger.info() etc. - # go through the non-blocking buffer instead of PrintBuffer → pipe. - # Only redirect handlers whose stream targets stdout/stderr (or a - # PrintBuffer wrapping stdout). Pytest installs a _LiveLoggingStream- - # Handler whose stream is a TerminalWriter with extra methods like - # .section(); replacing that stream would break pytest. + # --- Layer 1: redirect logging handler streams --- _STDOUT_STREAMS = (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__) queue_stream = _QueueStream(buffer) redirected_handlers: List[logging.StreamHandler] = [] # type: ignore[type-arg] @@ -477,11 +521,31 @@ def _writer() -> None: original_handler_streams.append(stream) handler.stream = queue_stream # type: ignore[assignment] + # --- Layer 2: replace sys.stdout / sys.stderr --- + # This catches print() calls and direct sys.stdout.write() from any + # thread, routing them through the non-blocking buffer instead of the + # pipe. _StdoutProxy delegates all other attribute access (encoding, + # fileno, buffer, etc.) to the original stream object. + original_stdout = sys.stdout + original_stderr = sys.stderr + sys.stdout = _StdoutProxy(original_stdout, buffer) # type: ignore[assignment] + sys.stderr = _StdoutProxy(original_stderr, buffer) # type: ignore[assignment] + + _stderr_diag( + f"Buffered writer ACTIVE: " + f"handlers_redirected={len(redirected_handlers)}, " + f"stdout_type={type(original_stdout).__name__}, " + f"stderr_type={type(original_stderr).__name__}" + ) + try: for message in messages: buffer.put(message) finally: - # Restore original handler streams before shutting down the writer. + # Restore sys.stdout / sys.stderr before shutting down. + sys.stdout = original_stdout + sys.stderr = original_stderr + # Restore original handler streams. for handler, orig_stream in zip(redirected_handlers, original_handler_streams): handler.stream = orig_stream buffer.put(_SENTINEL) From c088a519611dbc80f29664af0e86f799b286df3a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:32:37 +0000 Subject: [PATCH 21/24] diag: add os.write(2,...) diagnostics to trace main thread blocking point Adds periodic diagnostic messages written directly to stderr fd 2 (bypassing all Python buffering) at key points: 1. _consume_from_queue: every 10s logs total_items, total_yields, qsize, and last_get_wait. Also logs SLOW yields (>5s). 2. _buffered_write_to_stdout: every 10s logs msg_count, buffer_qsize, and writer_alive status. These diagnostics will be visible in platform logs even when the stdout pipe is blocked, allowing us to pinpoint exactly where the main thread gets stuck. Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 16 +++++ .../concurrent_source/concurrent_source.py | 62 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 31c21e90c9..01a844dee2 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -13,6 +13,7 @@ import sys import tempfile import threading +import time from collections import defaultdict from functools import wraps from queue import Queue @@ -539,8 +540,23 @@ def _writer() -> None: ) try: + msg_count = 0 + last_diag = time.monotonic() for message in messages: buffer.put(message) + msg_count += 1 + now = time.monotonic() + if now - last_diag >= 10.0: + _stderr_diag( + f"_buffered_write_to_stdout: alive, " + f"msg_count={msg_count}, buffer_qsize={buffer.qsize()}, " + f"writer_alive={writer_thread.is_alive()}" + ) + last_diag = now + _stderr_diag( + f"_buffered_write_to_stdout: generator exhausted, " + f"msg_count={msg_count}, buffer_qsize={buffer.qsize()}" + ) finally: # Restore sys.stdout / sys.stderr before shutting down. sys.stdout = original_stdout diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index 566445296d..d277f8fe19 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -162,6 +162,18 @@ def _submit_initial_partition_generators( if status_message: yield status_message + @staticmethod + def _diag(msg: str) -> None: + """Write diagnostic message directly to stderr fd 2. + + Bypasses all Python buffering (sys.stderr, logging, PrintBuffer) + so the message is visible even when stdout/stderr pipes are blocked. + """ + try: + os.write(2, f"DIAG: {msg}\n".encode()) + except Exception: + pass + def _consume_from_queue( self, queue: Queue[QueueItem], @@ -170,12 +182,24 @@ def _consume_from_queue( last_item_time = time.monotonic() heartbeat_interval = 60.0 # Log heartbeat every 60 seconds items_since_last_heartbeat = 0 + total_items = 0 + total_yields = 0 + last_diag_time = time.monotonic() + diag_interval = 10.0 # Diagnostic log every 10 seconds + + self._diag("_consume_from_queue: ENTER") while True: + now_pre_get = time.monotonic() try: airbyte_message_or_record_or_exception = queue.get(timeout=heartbeat_interval) except Empty: elapsed = time.monotonic() - last_item_time + self._diag( + f"_consume_from_queue: EMPTY after {elapsed:.0f}s, " + f"qsize={queue.qsize()}, pool_done={self._threadpool.is_done()}, " + f"threads={threading.active_count()}" + ) self._logger.info( "Queue heartbeat: no items received for %.0fs. " "queue_size=%d, threadpool_done=%s, active_threads=%d", @@ -187,10 +211,26 @@ def _consume_from_queue( continue if not airbyte_message_or_record_or_exception: + self._diag("_consume_from_queue: got sentinel, breaking") break now = time.monotonic() + get_wait = now - now_pre_get + total_items += 1 items_since_last_heartbeat += 1 + + # Periodic diagnostic via os.write(2,...) — visible even when + # stdout pipe is blocked. + if now - last_diag_time >= diag_interval: + item_type = type(airbyte_message_or_record_or_exception).__name__ + self._diag( + f"_consume_from_queue: alive, " + f"total_items={total_items}, total_yields={total_yields}, " + f"qsize={queue.qsize()}, last_get_wait={get_wait:.3f}s, " + f"item_type={item_type}" + ) + last_diag_time = now + if now - last_item_time >= heartbeat_interval: self._logger.info( "Queue heartbeat: processed %d items in last %.0fs. " @@ -204,17 +244,35 @@ def _consume_from_queue( self._last_progress_time = now last_item_time = now - yield from self._handle_item( + pre_yield = time.monotonic() + for msg in self._handle_item( airbyte_message_or_record_or_exception, concurrent_stream_processor, - ) + ): + total_yields += 1 + yield msg + post_yield = time.monotonic() + yield_dur = post_yield - pre_yield + if yield_dur > 5.0: + self._diag( + f"_consume_from_queue: SLOW yield, " + f"duration={yield_dur:.1f}s, " + f"item_type={type(airbyte_message_or_record_or_exception).__name__}" + ) + # In the event that a partition raises an exception, anything remaining in # the queue will be missed because is_done() can raise an exception and exit # out of this loop before remaining items are consumed if queue.empty() and concurrent_stream_processor.is_done(): # all partitions were generated and processed. we're done here + self._diag("_consume_from_queue: all done, breaking") break + self._diag( + f"_consume_from_queue: EXIT, total_items={total_items}, " + f"total_yields={total_yields}" + ) + def _watchdog_loop(self) -> None: """Daemon thread that terminates the process when the main thread stalls. From fd6674d5e11f714325ef0e04475b281f577c23dc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:34:00 +0000 Subject: [PATCH 22/24] style: fix ruff format Co-Authored-By: unknown <> --- airbyte_cdk/sources/concurrent_source/concurrent_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index d277f8fe19..0bb24a3072 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -269,8 +269,7 @@ def _consume_from_queue( break self._diag( - f"_consume_from_queue: EXIT, total_items={total_items}, " - f"total_yields={total_yields}" + f"_consume_from_queue: EXIT, total_items={total_items}, total_yields={total_yields}" ) def _watchdog_loop(self) -> None: From 4c6b4b19360116e247249d4bac445df02037f27e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:37:06 +0000 Subject: [PATCH 23/24] style: add explanatory comment to empty except in _diag Co-Authored-By: unknown <> --- airbyte_cdk/sources/concurrent_source/concurrent_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index 0bb24a3072..f653c661e4 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -172,6 +172,8 @@ def _diag(msg: str) -> None: try: os.write(2, f"DIAG: {msg}\n".encode()) except Exception: + # Intentionally ignored: diagnostics are best-effort and must + # never interfere with program execution. pass def _consume_from_queue( From e4ce817f1b143fcdde3a63987411f0063bf18615 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:31:40 +0000 Subject: [PATCH 24/24] fix: set stderr fd 2 to non-blocking mode to prevent deadlock from pipe backpressure Co-Authored-By: unknown <> --- airbyte_cdk/entrypoint.py | 31 ++++++++++++++-- .../concurrent_source/concurrent_source.py | 35 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 01a844dee2..54c9581fdc 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -3,6 +3,7 @@ # import argparse +import fcntl import importlib import io import ipaddress @@ -442,18 +443,40 @@ def __getattr__(self, name: str) -> Any: return getattr(self._original, name) +def _ensure_stderr_nonblock() -> None: + """Set stderr fd 2 to non-blocking mode (once). + + When the Airbyte platform stops reading from the source container's + stderr pipe, the pipe buffer fills and any ``os.write(2, ...)`` call + blocks the calling thread. If that thread is the main thread, the + CDK's record queue fills and all workers deadlock. + + Setting ``O_NONBLOCK`` makes ``os.write(2, ...)`` raise + ``BlockingIOError`` (EAGAIN) instead of blocking, which + ``_stderr_diag`` already catches. + """ + try: + flags = fcntl.fcntl(2, fcntl.F_GETFL) + fcntl.fcntl(2, fcntl.F_SETFL, flags | os.O_NONBLOCK) + except Exception: + # Best-effort; some environments may not support fcntl on fd 2. + pass + + def _stderr_diag(msg: str) -> None: """Write a diagnostic message directly to stderr fd. Uses ``os.write()`` on the raw file descriptor so the write bypasses *all* Python buffering (``sys.stderr``, ``PrintBuffer``, logging - handlers). This works even when the stdout/stderr pipes are blocked - because ``os.write(2, …)`` is a direct syscall on fd 2. + handlers). fd 2 is set to non-blocking mode so this never stalls + the calling thread. """ try: os.write(2, f"DIAG: {msg}\n".encode()) except Exception: - pass # Best-effort; must not prevent caller from continuing. + # Best-effort; catches BlockingIOError (EAGAIN) when pipe is + # full, plus any other I/O error. + pass def _buffered_write_to_stdout(messages: Iterable[str]) -> None: @@ -490,6 +513,8 @@ def _buffered_write_to_stdout(messages: Iterable[str]) -> None: print(message) return + _ensure_stderr_nonblock() + _SENTINEL = None # signals the writer to stop buffer: Queue[Optional[str]] = Queue() writer_error: List[Exception] = [] diff --git a/airbyte_cdk/sources/concurrent_source/concurrent_source.py b/airbyte_cdk/sources/concurrent_source/concurrent_source.py index f653c661e4..486e1a7af0 100644 --- a/airbyte_cdk/sources/concurrent_source/concurrent_source.py +++ b/airbyte_cdk/sources/concurrent_source/concurrent_source.py @@ -3,6 +3,7 @@ # import concurrent +import fcntl import logging import os import sys @@ -119,6 +120,7 @@ def read( # (consumes an item from the queue). The watchdog reads this to # detect when the main thread is stuck. self._last_progress_time = time.monotonic() + self._ensure_stderr_nonblock() self._watchdog_should_run = True watchdog = threading.Thread( target=self._watchdog_loop, @@ -162,18 +164,49 @@ def _submit_initial_partition_generators( if status_message: yield status_message + _stderr_nonblock_set = False + + @classmethod + def _ensure_stderr_nonblock(cls) -> None: + """Set stderr fd 2 to non-blocking mode (once). + + In Airbyte Cloud the platform reads the source container's stdout and + stderr pipes. If the platform pauses reading (e.g. destination + backpressure), both pipe buffers fill up. A blocking ``os.write(2, + ...)`` would then stall whichever thread called it — including the + main thread, which causes the CDK queue to fill and deadlock all + workers. + + Setting ``O_NONBLOCK`` on fd 2 makes ``os.write(2, ...)`` return + immediately with ``BlockingIOError`` (EAGAIN) instead of blocking. + The ``_diag`` method already catches all exceptions, so the message + is simply dropped when the pipe is full. + """ + if cls._stderr_nonblock_set: + return + try: + flags = fcntl.fcntl(2, fcntl.F_GETFL) + fcntl.fcntl(2, fcntl.F_SETFL, flags | os.O_NONBLOCK) + cls._stderr_nonblock_set = True + except Exception: + # Best-effort; some environments may not support fcntl on fd 2. + pass + @staticmethod def _diag(msg: str) -> None: """Write diagnostic message directly to stderr fd 2. Bypasses all Python buffering (sys.stderr, logging, PrintBuffer) so the message is visible even when stdout/stderr pipes are blocked. + The fd is set to non-blocking mode by ``_ensure_stderr_nonblock`` + so this call never stalls the calling thread. """ try: os.write(2, f"DIAG: {msg}\n".encode()) except Exception: # Intentionally ignored: diagnostics are best-effort and must - # never interfere with program execution. + # never interfere with program execution. In non-blocking mode + # this catches BlockingIOError (EAGAIN) when the pipe is full. pass def _consume_from_queue(