Skip to content

Commit 475fca6

Browse files
feat: add CDK deadlock diagnostics
Co-Authored-By: gl_anatolii.yatsuk <gl_anatolii.yatsuk@airbyte.io>
1 parent f4c0779 commit 475fca6

4 files changed

Lines changed: 317 additions & 28 deletions

File tree

airbyte_cdk/entrypoint.py

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
import ipaddress
88
import json
99
import logging
10+
import os
1011
import os.path
1112
import socket
1213
import sys
1314
import tempfile
15+
import threading
16+
import time
17+
import traceback
1418
from collections import defaultdict
1519
from functools import wraps
16-
from typing import Any, DefaultDict, Iterable, List, Mapping, Optional
20+
from typing import Any, Callable, DefaultDict, Iterable, List, Mapping, Optional
1721
from urllib.parse import urlparse
1822

1923
import orjson
@@ -49,6 +53,145 @@
4953
VALID_URL_SCHEMES = ["https"]
5054
CLOUD_DEPLOYMENT_MODE = "cloud"
5155
_HAS_LOGGED_FOR_SERIALIZATION_ERROR = False
56+
_DEADLOCK_DIAGNOSTICS_ENV = "AIRBYTE_CDK_DEADLOCK_DIAGNOSTICS"
57+
58+
59+
class _DeadlockDiagnostics:
60+
def __init__(
61+
self,
62+
*,
63+
interval_seconds: float = 30.0,
64+
stall_interval_count: int = 3,
65+
dump_repeat_interval_count: int = 10,
66+
time_fn: Callable[[], float] = time.monotonic,
67+
write_fn: Callable[[bytes], None] | None = None,
68+
) -> None:
69+
self._interval_seconds = interval_seconds
70+
self._stall_interval_count = stall_interval_count
71+
self._dump_repeat_interval_count = dump_repeat_interval_count
72+
self._time_fn = time_fn
73+
self._write_fn = write_fn or self._write_to_stderr
74+
self._stop = threading.Event()
75+
self._lock = threading.Lock()
76+
self._thread: threading.Thread | None = None
77+
self._start_time = self._time_fn()
78+
self._messages_written = 0
79+
self._bytes_written = 0
80+
self._print_blocked = False
81+
self._print_blocked_since = 0.0
82+
self._last_messages_written = 0
83+
self._stall_count = 0
84+
85+
def start(self) -> None:
86+
self._thread = threading.Thread(
87+
target=self._run,
88+
name="airbyte-deadlock-diagnostics",
89+
daemon=True,
90+
)
91+
self._thread.start()
92+
93+
def stop(self) -> None:
94+
self._stop.set()
95+
96+
def mark_print_started(self) -> None:
97+
with self._lock:
98+
self._print_blocked = True
99+
self._print_blocked_since = self._time_fn()
100+
101+
def mark_print_finished(self) -> None:
102+
with self._lock:
103+
self._print_blocked = False
104+
105+
def record_message(self, data: str) -> None:
106+
with self._lock:
107+
self._messages_written += 1
108+
self._bytes_written += len(data.encode())
109+
110+
def emit_heartbeat(self) -> None:
111+
now = self._time_fn()
112+
with self._lock:
113+
messages_written = self._messages_written
114+
bytes_written = self._bytes_written
115+
print_blocked = self._print_blocked
116+
print_blocked_since = self._print_blocked_since
117+
118+
if messages_written == self._last_messages_written and messages_written > 0:
119+
self._stall_count += 1
120+
else:
121+
self._stall_count = 0
122+
self._last_messages_written = messages_written
123+
stall_count = self._stall_count
124+
125+
line = self._heartbeat_line(
126+
now,
127+
messages_written,
128+
bytes_written,
129+
print_blocked,
130+
print_blocked_since,
131+
)
132+
if self._should_dump_threads(stall_count):
133+
line += self._thread_dump()
134+
135+
try:
136+
self._write_fn(line.encode())
137+
except OSError:
138+
return
139+
140+
def _run(self) -> None:
141+
while not self._stop.wait(timeout=self._interval_seconds):
142+
self.emit_heartbeat()
143+
144+
def _heartbeat_line(
145+
self,
146+
now: float,
147+
messages_written: int,
148+
bytes_written: int,
149+
print_blocked: bool,
150+
print_blocked_since: float,
151+
) -> str:
152+
blocked = "YES" if print_blocked else "NO"
153+
blocked_duration = (
154+
f" blocked_since={now - print_blocked_since:.0f}s" if print_blocked else ""
155+
)
156+
return (
157+
f"STDOUT_HEARTBEAT: t={now - self._start_time:.0f}s "
158+
f"msgs={messages_written} bytes={bytes_written} "
159+
f"print_blocked={blocked}{blocked_duration}"
160+
f"{self._queue_stats()}\n"
161+
)
162+
163+
def _queue_stats(self) -> str:
164+
from airbyte_cdk.sources.concurrent_source.queue_registry import get_queue
165+
166+
queue = get_queue()
167+
if queue is None:
168+
return ""
169+
170+
try:
171+
return f" queue_size={queue.qsize()} queue_full={queue.full()}"
172+
except NotImplementedError:
173+
return ""
174+
175+
def _should_dump_threads(self, stall_count: int) -> bool:
176+
if stall_count == self._stall_interval_count:
177+
return True
178+
return (
179+
stall_count > self._stall_interval_count
180+
and (stall_count - self._stall_interval_count) % self._dump_repeat_interval_count == 0
181+
)
182+
183+
def _thread_dump(self) -> str:
184+
thread_names = {thread.ident: thread.name for thread in threading.enumerate()}
185+
lines = ["=== THREAD DUMP (stall detected) ===\n"]
186+
for thread_id, frame in sys._current_frames().items():
187+
lines.append(f"\nThread {thread_names.get(thread_id, 'unknown')} ({thread_id}):\n")
188+
lines.extend(traceback.format_stack(frame))
189+
lines.append("=== END THREAD DUMP ===\n")
190+
return "".join(lines)
191+
192+
@staticmethod
193+
def _write_to_stderr(data: bytes) -> None:
194+
os.write(2, data)
52195

53196

54197
class AirbyteEntrypoint(object):
@@ -391,13 +534,34 @@ def _emit_queued_messages(self, source: Source) -> Iterable[AirbyteMessage]:
391534
def launch(source: Source, args: List[str]) -> None:
392535
source_entrypoint = AirbyteEntrypoint(source)
393536
parsed_args = source_entrypoint.parse_args(args)
537+
diagnostics = (
538+
_DeadlockDiagnostics()
539+
if os.environ.get(_DEADLOCK_DIAGNOSTICS_ENV, "").lower() == "true"
540+
else None
541+
)
542+
if diagnostics:
543+
diagnostics.start()
544+
394545
# temporarily removes the PrintBuffer because we're seeing weird print behavior for concurrent syncs
395546
# Refer to: https://github.com/airbytehq/oncall/issues/6235
396-
with PRINT_BUFFER:
397-
for message in source_entrypoint.run(parsed_args):
398-
# simply printing is creating issues for concurrent CDK as Python uses different two instructions to print: one for the message and
399-
# the other for the break line. Adding `\n` to the message ensure that both are printed at the same time
400-
print(f"{message}\n", end="")
547+
try:
548+
with PRINT_BUFFER:
549+
for message in source_entrypoint.run(parsed_args):
550+
# simply printing is creating issues for concurrent CDK as Python uses different two instructions to print: one for the message and
551+
# the other for the break line. Adding `\n` to the message ensure that both are printed at the same time
552+
data = f"{message}\n"
553+
if diagnostics:
554+
diagnostics.mark_print_started()
555+
try:
556+
print(data, end="")
557+
finally:
558+
if diagnostics:
559+
diagnostics.mark_print_finished()
560+
if diagnostics:
561+
diagnostics.record_message(data)
562+
finally:
563+
if diagnostics:
564+
diagnostics.stop()
401565

402566

403567
def _init_internal_request_filter() -> None:

airbyte_cdk/sources/concurrent_source/concurrent_source.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from airbyte_cdk.sources.concurrent_source.partition_generation_completed_sentinel import (
1313
PartitionGenerationCompletedSentinel,
1414
)
15+
from airbyte_cdk.sources.concurrent_source.queue_registry import register_queue, unregister_queue
1516
from airbyte_cdk.sources.concurrent_source.stream_thread_exception import StreamThreadException
1617
from airbyte_cdk.sources.concurrent_source.thread_pool_manager import ThreadPoolManager
1718
from airbyte_cdk.sources.message import InMemoryMessageRepository, MessageRepository
@@ -110,30 +111,34 @@ def read(
110111
streams: List[AbstractStream],
111112
) -> Iterator[AirbyteMessage]:
112113
self._logger.info("Starting syncing")
113-
concurrent_stream_processor = ConcurrentReadProcessor(
114-
streams,
115-
PartitionEnqueuer(self._queue, self._threadpool),
116-
self._threadpool,
117-
self._logger,
118-
self._slice_logger,
119-
self._message_repository,
120-
PartitionReader(
121-
self._queue,
122-
PartitionLogger(self._slice_logger, self._logger, self._message_repository),
123-
),
124-
max_concurrent_partition_generators=self._initial_number_partitions_to_generate,
125-
)
114+
register_queue(self._queue)
115+
try:
116+
concurrent_stream_processor = ConcurrentReadProcessor(
117+
streams,
118+
PartitionEnqueuer(self._queue, self._threadpool),
119+
self._threadpool,
120+
self._logger,
121+
self._slice_logger,
122+
self._message_repository,
123+
PartitionReader(
124+
self._queue,
125+
PartitionLogger(self._slice_logger, self._logger, self._message_repository),
126+
),
127+
max_concurrent_partition_generators=self._initial_number_partitions_to_generate,
128+
)
126129

127-
# Enqueue initial partition generation tasks
128-
yield from self._submit_initial_partition_generators(concurrent_stream_processor)
130+
# Enqueue initial partition generation tasks
131+
yield from self._submit_initial_partition_generators(concurrent_stream_processor)
129132

130-
# Read from the queue until all partitions were generated and read
131-
yield from self._consume_from_queue(
132-
self._queue,
133-
concurrent_stream_processor,
134-
)
135-
self._threadpool.check_for_errors_and_shutdown()
136-
self._logger.info("Finished syncing")
133+
# Read from the queue until all partitions were generated and read
134+
yield from self._consume_from_queue(
135+
self._queue,
136+
concurrent_stream_processor,
137+
)
138+
self._threadpool.check_for_errors_and_shutdown()
139+
self._logger.info("Finished syncing")
140+
finally:
141+
unregister_queue()
137142

138143
def _submit_initial_partition_generators(
139144
self, concurrent_stream_processor: ConcurrentReadProcessor
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# Copyright (c) 2026 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
from queue import Queue
6+
from typing import Optional
7+
8+
from airbyte_cdk.sources.streams.concurrent.partitions.types import QueueItem
9+
10+
_queue: Optional[Queue[QueueItem]] = None
11+
12+
13+
def register_queue(queue: Queue[QueueItem]) -> None:
14+
global _queue
15+
_queue = queue
16+
17+
18+
def get_queue() -> Optional[Queue[QueueItem]]:
19+
return _queue
20+
21+
22+
def unregister_queue() -> None:
23+
global _queue
24+
_queue = None

0 commit comments

Comments
 (0)