Skip to content

Commit adae9a1

Browse files
authored
BFD-4841: Improve IDR Pipeline error handling (#3244)
1 parent 8a3daad commit adae9a1

7 files changed

Lines changed: 194 additions & 59 deletions

File tree

apps/bfd-pipeline-idr/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"click>=8.3.3",
1616
"anyio",
1717
"loguru",
18+
"tblib>=3.2.2",
1819
]
1920

2021
[dependency-groups]

apps/bfd-pipeline-idr/src/idr_pipeline/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import atexit
22
import multiprocessing
3+
import sys
34
from datetime import UTC, datetime
45

56
import anyio
@@ -149,7 +150,7 @@ async def run_worker_and_stages() -> None:
149150
failure_time=resolve_test_date(load_mode),
150151
)
151152
logger.opt(exception=True).error("Unrecoverable exception raised during pipeline load:")
152-
raise
153+
sys.exit(1)
153154
finally:
154155
if idr_job_events:
155156
update_completion_times(

apps/bfd-pipeline-idr/src/idr_pipeline/batch_worker.py

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import random
44
import signal
55
import string
6+
import sys
67
import threading
78
import uuid
89
from collections import Counter, OrderedDict
@@ -13,6 +14,7 @@
1314
from math import ceil
1415
from multiprocessing import Manager, Process
1516
from multiprocessing.managers import SyncManager
17+
from pathlib import Path
1618
from queue import Empty, Queue
1719
from threading import Event
1820
from types import FrameType
@@ -28,6 +30,11 @@
2830
from psycopg.errors import DeadlockDetected, InFailedSqlTransaction
2931
from psycopg_pool.abc import ACT
3032

33+
from .exception_utils import (
34+
SerializedExceptionChain,
35+
rebuild_exception_chain,
36+
serialize_exception_chain,
37+
)
3138
from .load_partition import LoadPartition
3239
from .model.base_model import DbType, IdrBaseModel
3340
from .model.load_progress import LoadProgress
@@ -183,7 +190,7 @@ class _LoadingBatchWorker(Process):
183190
def __init__(
184191
self,
185192
task_queue: Queue[_TaskSequence],
186-
errors_queue: Queue[BaseException],
193+
errors_queue: Queue[SerializedExceptionChain],
187194
started_signal: Event,
188195
cancel_signal: Event,
189196
root_logger: Logger,
@@ -200,24 +207,34 @@ def __init__(
200207
self._running_tasks: set[_Task] = set()
201208

202209
def run(self) -> None:
203-
self._logger.reinstall()
210+
# The next four lines suppress unhandled/unraised Exception output to prevent noise when
211+
# the ExternallyCanceled signal is used to stop this worker. Without this, Python's default
212+
# behavior prints the full trace and Exception context to stderr, cluttering the logs.
213+
sys.unraisablehook = lambda _: None
214+
sys.excepthook = lambda _, __, ___: None
215+
with Path(os.devnull).open("w") as devnull:
216+
sys.stderr = devnull
204217

205-
def _watch_for_parent_cancel(stop_signal: Event) -> None:
206-
stop_signal.wait()
207-
os.kill(os.getpid(), signal.SIGUSR1)
218+
self._logger.reinstall()
208219

209-
def sigusr1_handler(signum: int, frame: FrameType | None) -> Never: # noqa: ARG001
210-
raise ExternallyCanceled("Externally canceled, interrupting")
220+
def _watch_for_parent_cancel(stop_signal: Event) -> None:
221+
stop_signal.wait()
222+
os.kill(os.getpid(), signal.SIGUSR1)
211223

212-
signal.signal(signal.SIGUSR1, sigusr1_handler)
213-
threading.Thread(
214-
target=lambda: _watch_for_parent_cancel(self._cancel_signal), daemon=True
215-
).start()
224+
def sigusr1_handler(signum: int, frame: FrameType | None) -> Never: # noqa: ARG001
225+
raise ExternallyCanceled("Externally canceled, interrupting")
216226

217-
try:
218-
anyio.run(self._worker_main)
219-
except BaseException as ex:
220-
self.errors_queue.put(ex)
227+
signal.signal(signal.SIGUSR1, sigusr1_handler)
228+
threading.Thread(
229+
target=lambda: _watch_for_parent_cancel(self._cancel_signal), daemon=True
230+
).start()
231+
232+
try:
233+
anyio.run(self._worker_main)
234+
except ExternallyCanceled:
235+
pass
236+
except BaseException as ex:
237+
self.errors_queue.put(serialize_exception_chain(ex))
221238

222239
async def _worker_main(self) -> None:
223240
task_send, task_receive = anyio.create_memory_object_stream[_TaskSequence](
@@ -508,7 +525,7 @@ async def start(self, stop: anyio.Event, task_status: TaskStatus | None = None)
508525
return
509526

510527
self._started_signal.clear()
511-
errors_queue: Queue[BaseException] = self._manager.Queue()
528+
errors_queue: Queue[SerializedExceptionChain] = self._manager.Queue()
512529
cancel_signal = self._manager.Event()
513530

514531
self._worker = _LoadingBatchWorker(
@@ -521,28 +538,29 @@ async def start(self, stop: anyio.Event, task_status: TaskStatus | None = None)
521538
)
522539
self._worker.start()
523540

524-
# Block until the worker signals it has started
525-
self._started_signal.wait()
526-
527-
logger.info("LoadingBatchWorker signaled startup")
541+
# Block until the worker signals it has started, or 10 seconds have passed
542+
self._started_signal.wait(10)
543+
if self._started_signal.is_set():
544+
logger.info("LoadingBatchWorker signaled startup")
545+
else:
546+
logger.error("LoadingBatchWorker start signal never set. See exception for detail")
528547

529548
if task_status:
530549
task_status.started()
531550

532551
async def watch_queue() -> None:
533552
while not stop.is_set():
534553
with contextlib.suppress(Empty):
535-
errors = errors_queue.get_nowait()
536-
raise errors
554+
raise rebuild_exception_chain(errors_queue.get_nowait())
537555

538556
await anyio.sleep(0.01)
539557

540-
async with anyio.create_task_group() as tg:
541-
try:
558+
try:
559+
async with anyio.create_task_group() as tg:
542560
tg.start_soon(watch_queue)
543-
except BaseException:
544-
cancel_signal.set()
545-
raise
561+
except BaseException:
562+
cancel_signal.set()
563+
raise
546564

547565
def cleanup(self, timeout: float = 5.0) -> None:
548566
if self._worker is None:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from dataclasses import dataclass
2+
from types import TracebackType
3+
from typing import Any, cast
4+
5+
from tblib import Traceback # type: ignore
6+
7+
type SerializedExceptionChain = list[SerializedException | SerializedExceptionGroup]
8+
9+
10+
@dataclass(frozen=True, eq=True)
11+
class SerializedException:
12+
ex: BaseException
13+
tb_dict: dict[str, Any]
14+
15+
16+
@dataclass(frozen=True, eq=True)
17+
class SerializedExceptionGroup:
18+
ex_group: BaseExceptionGroup[Exception] | ExceptionGroup[Exception]
19+
tb_dict: dict[str, Any]
20+
exceptions: list[SerializedException]
21+
22+
23+
def get_exception_tb_dict(ex: BaseException) -> dict[str, Any]:
24+
return Traceback(ex.__traceback__).as_dict() # type: ignore
25+
26+
27+
def get_traceback_from_dict(tb_dict: dict[str, Any]) -> TracebackType | None:
28+
return cast(Traceback, Traceback.from_dict(tb_dict)).as_traceback() # type: ignore
29+
30+
31+
def serialize_exception_chain(exc: BaseException) -> SerializedExceptionChain:
32+
chain: list[SerializedException | SerializedExceptionGroup] = []
33+
current: BaseException | None = exc
34+
35+
while current is not None:
36+
if isinstance(current, BaseExceptionGroup | ExceptionGroup):
37+
current = cast(BaseExceptionGroup[Exception], current)
38+
serialized_inner: list[SerializedException] = [
39+
SerializedException(ex=inner, tb_dict=get_exception_tb_dict(inner))
40+
for inner in current.exceptions
41+
]
42+
chain.append(
43+
SerializedExceptionGroup(
44+
ex_group=current,
45+
tb_dict=get_exception_tb_dict(current),
46+
exceptions=serialized_inner,
47+
)
48+
)
49+
else:
50+
chain.append(SerializedException(ex=current, tb_dict=get_exception_tb_dict(current)))
51+
52+
current = current.__cause__
53+
54+
return chain
55+
56+
57+
def rebuild_exception_chain(chain: SerializedExceptionChain) -> BaseException:
58+
if not chain:
59+
raise ValueError("Chain must contain at least one exception")
60+
61+
resolved: list[tuple[BaseException, dict[str, Any]]] = []
62+
for entry in chain:
63+
if isinstance(entry, SerializedExceptionGroup):
64+
# Restore tracebacks on every inner exception.
65+
restored_inners: list[BaseException] = []
66+
for serialized_inner in entry.exceptions:
67+
inner_exc = serialized_inner.ex
68+
inner_exc.__traceback__ = get_traceback_from_dict(serialized_inner.tb_dict)
69+
restored_inners.append(inner_exc)
70+
71+
# Re-create the group with the restored inner exceptions so that
72+
# the group's own .exceptions tuple reflects the restored state.
73+
restored_group = entry.ex_group.derive(restored_inners)
74+
resolved.append((restored_group, entry.tb_dict))
75+
else:
76+
resolved.append((entry.ex, entry.tb_dict))
77+
78+
# Re-create the "exception chain" (of __cause__s) and restore top-level (non-inner Exception)
79+
# tracebacks
80+
for i, (exc, tb_dict) in enumerate(resolved):
81+
exc.__traceback__ = get_traceback_from_dict(tb_dict)
82+
exc.__cause__ = resolved[i + 1][0] if i < len(resolved) - 1 else None
83+
84+
return resolved[0][0]

apps/bfd-pipeline-idr/src/idr_pipeline/parallel_executor.py

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import contextlib
22
import os
33
import signal
4+
import sys
45
import threading
56
from collections.abc import Callable, Generator
67
from concurrent.futures import Future, ProcessPoolExecutor
78
from multiprocessing import Manager
9+
from pathlib import Path
810
from queue import Empty, Queue
911
from threading import Event
1012
from types import FrameType
@@ -13,6 +15,12 @@
1315
import anyio
1416
from loguru import logger
1517

18+
from .exception_utils import (
19+
SerializedExceptionChain,
20+
rebuild_exception_chain,
21+
serialize_exception_chain,
22+
)
23+
1624
type StageTask[T] = Callable[[], T]
1725
type Stage[T] = Generator[StageTask[T]]
1826

@@ -30,40 +38,50 @@ def __init__(self, max_workers: int | None = None) -> None:
3038
def _wrap[T](
3139
task: StageTask[T],
3240
index: int,
33-
errors_queue: Queue[BaseException],
41+
errors_queue: Queue[SerializedExceptionChain],
3442
cancel_signal: Event,
3543
) -> tuple[int, T | None]:
44+
# The next four lines suppress unhandled/unraised Exception output to prevent noise when
45+
# the ExternallyCanceled signal is used to stop this worker. Without this, Python's default
46+
# behavior prints the full trace and Exception context to stderr, cluttering the logs.
47+
sys.unraisablehook = lambda _: None
48+
sys.excepthook = lambda _, __, ___: None
49+
with Path(os.devnull).open("w") as devnull:
50+
sys.stderr = devnull
51+
52+
def _watch_for_parent_cancel(cancel_signal: Event) -> None:
53+
cancel_signal.wait()
54+
os.kill(os.getpid(), signal.SIGUSR1)
55+
56+
def sigusr1_handler(signum: int, frame: FrameType | None) -> Never: # noqa: ARG001
57+
raise ExternallyCanceled("Externally canceled, interrupting")
58+
59+
signal.signal(signal.SIGUSR1, sigusr1_handler)
60+
threading.Thread(
61+
target=lambda: _watch_for_parent_cancel(cancel_signal), daemon=True
62+
).start()
3663

37-
def _watch_for_parent_cancel(cancel_signal: Event) -> None:
38-
cancel_signal.wait()
39-
os.kill(os.getpid(), signal.SIGUSR1)
40-
41-
def sigusr1_handler(signum: int, frame: FrameType | None) -> Never: # noqa: ARG001
42-
raise ExternallyCanceled("Externally canceled, interrupting")
43-
44-
signal.signal(signal.SIGUSR1, sigusr1_handler)
45-
threading.Thread(
46-
target=lambda: _watch_for_parent_cancel(cancel_signal), daemon=True
47-
).start()
64+
try:
65+
return (index, task())
66+
except ExternallyCanceled:
67+
pass
68+
except BaseException as ex:
69+
errors_queue.put(serialize_exception_chain(ex))
4870

49-
try:
50-
return (index, task())
51-
except BaseException as e:
52-
errors_queue.put(e)
5371
return (index, None)
5472

5573
async def _run_stage[T](
5674
self,
5775
stage: Stage[T],
58-
errors_queue: Queue[BaseException],
76+
errors_queue: Queue[SerializedExceptionChain],
5977
pool: ProcessPoolExecutor,
6078
results: dict[int, T | None],
6179
) -> None:
6280
done = anyio.Event()
6381

6482
cancel_signal = self._manager.Event()
65-
async with anyio.create_task_group() as tg:
66-
try:
83+
try:
84+
async with anyio.create_task_group() as tg:
6785
tg.start_soon(self._poll_errors, errors_queue, done)
6886

6987
async with anyio.create_task_group() as tg2:
@@ -77,16 +95,12 @@ async def _task(task: StageTask[T] = task, idx: int = idx) -> None:
7795
tg2.start_soon(_task)
7896

7997
done.set()
80-
except BaseException:
81-
cancel_signal.set()
82-
raise
83-
84-
# Final drain in case an error landed after tasks completed
85-
if not errors_queue.empty():
86-
raise errors_queue.get()
98+
except BaseException:
99+
cancel_signal.set()
100+
raise
87101

88102
async def execute[T](self, stages: list[Stage[T]]) -> list[list[T | None]]:
89-
errors_queue: Queue[BaseException] = self._manager.Queue()
103+
errors_queue: Queue[SerializedExceptionChain] = self._manager.Queue()
90104
all_results: list[list[T | None]] = []
91105

92106
with ProcessPoolExecutor(
@@ -100,10 +114,13 @@ async def execute[T](self, stages: list[Stage[T]]) -> list[list[T | None]]:
100114
return all_results
101115

102116
@staticmethod
103-
async def _poll_errors(errors_queue: Queue[BaseException], done: anyio.Event) -> None:
117+
async def _poll_errors(
118+
errors_queue: Queue[SerializedExceptionChain], done: anyio.Event
119+
) -> None:
104120
while not done.is_set():
105121
with contextlib.suppress(Empty):
106-
raise errors_queue.get_nowait()
122+
raise rebuild_exception_chain(errors_queue.get_nowait())
123+
107124
await anyio.sleep(0.01)
108125

109126
@staticmethod

apps/bfd-pipeline-idr/src/idr_pipeline/pipeline_utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
from .settings import BENEFICIARY_PART_D_PRUNE_BATCH_LIMIT, BENEFICIARY_PRUNE_BATCH_LIMIT
3535

3636

37+
class ModelExtractError(Exception):
38+
pass
39+
40+
3741
def get_progress(
3842
load_mode: LoadMode,
3943
source: Source,
@@ -124,8 +128,7 @@ def extract_and_load(
124128
raise ex
125129
time.sleep(1)
126130
except Exception as ex:
127-
logger.opt(exception=True).error("error loading {}", cls.table())
128-
raise ex
131+
raise ModelExtractError(f"error loading {cls.table()}-{partition.name}") from ex
129132

130133

131134
def prune_phase_1_ss_claims(

0 commit comments

Comments
 (0)