atlan-application-sdk-api 3.51.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
"""The SDK's offload seam — four sanctioned primitives (ADR-0010, ADR-0017).
|
|
2
|
+
|
|
3
|
+
``run_in_thread`` offloads blocking calls so they don't starve the event loop
|
|
4
|
+
and the heartbeat that rides on it; ``submit_in_thread`` is its detached variant
|
|
5
|
+
for cleanup that cancellation must not be able to skip; ``run_fault_isolated``
|
|
6
|
+
runs work in a child process so a native fault can't kill the worker; and
|
|
7
|
+
``run_best_effort`` is the policy layer over it for non-essential work — it
|
|
8
|
+
isolates *and* swallows failures so best-effort work can never break the caller.
|
|
9
|
+
|
|
10
|
+
**Why this module lives in the substrate** (ADR-0019). Every layer offloads:
|
|
11
|
+
``storage/`` (writers, transfer loops, integrity hashing), ``clients/`` (SQL
|
|
12
|
+
drivers, Azure SDK), ``handler/``, ``observability/``, the SQL templates.
|
|
13
|
+
``run_in_thread`` is a *mandatory* seam per ADR-0010, so a layer that cannot
|
|
14
|
+
import it at module scope is a layer that cannot follow the rule cleanly. Under
|
|
15
|
+
``execution/`` ``storage/`` could not: importing any ``execution`` submodule runs
|
|
16
|
+
that package's ``__init__``, which reaches back into ``storage.ops``. The
|
|
17
|
+
app-facing path is still ``application_sdk.execution.heartbeat``, which
|
|
18
|
+
re-exports the four primitives; SDK-internal callers import this module.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import concurrent.futures
|
|
23
|
+
import contextlib
|
|
24
|
+
import contextvars
|
|
25
|
+
import functools
|
|
26
|
+
import multiprocessing
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable, Iterator
|
|
32
|
+
from concurrent.futures.process import BrokenProcessPool
|
|
33
|
+
from typing import Any, Generic, ParamSpec, TypeVar
|
|
34
|
+
|
|
35
|
+
from application_sdk._logging import get_logger
|
|
36
|
+
from application_sdk._runtime.progress import (
|
|
37
|
+
DEFAULT_MAX_NO_PROGRESS_SECONDS,
|
|
38
|
+
ProgressTracker,
|
|
39
|
+
current_progress_tracker,
|
|
40
|
+
declared_hold_active,
|
|
41
|
+
)
|
|
42
|
+
from application_sdk.errors import InvalidInputValueError
|
|
43
|
+
|
|
44
|
+
logger = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
T = TypeVar("T")
|
|
47
|
+
P = ParamSpec("P")
|
|
48
|
+
|
|
49
|
+
# Dedicated executor for blocking operations dispatched via run_in_thread().
|
|
50
|
+
#
|
|
51
|
+
# Why not None (asyncio's default executor)?
|
|
52
|
+
# Temporal's Python SDK uses the event loop's default executor for its own
|
|
53
|
+
# internal scheduling. Sharing that pool with long-running blocking calls
|
|
54
|
+
# (database queries, metadata extractions) can exhaust it and deadlock the
|
|
55
|
+
# worker, especially when multiple activities are running concurrently.
|
|
56
|
+
#
|
|
57
|
+
# Why not a per-call ThreadPoolExecutor?
|
|
58
|
+
# Creating one per call and calling shutdown(wait=False) leaks threads:
|
|
59
|
+
# the executor object is detached but live threads are not joined and
|
|
60
|
+
# accumulate over the lifetime of a worker process.
|
|
61
|
+
#
|
|
62
|
+
# This single instance is created once at module import and intentionally
|
|
63
|
+
# outlives individual calls. Named threads ("sdk-blocking-N") make it
|
|
64
|
+
# distinguishable from Temporal's "activity-pool-N" threads in stack traces.
|
|
65
|
+
_BLOCKING_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
66
|
+
max_workers=min(32, (os.cpu_count() or 1) + 4),
|
|
67
|
+
thread_name_prefix="sdk-blocking-",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# The pool ``run_in_thread`` uses instead of _BLOCKING_EXECUTOR in this context.
|
|
71
|
+
# The consolidated API host serves many apps from one process and gives each its
|
|
72
|
+
# own pool (``handler.asgi``), so one app's stuck blocking calls exhaust only
|
|
73
|
+
# that app's pool. Unset on the worker, which keeps the shared pool.
|
|
74
|
+
_BLOCKING_EXECUTOR_OVERRIDE: contextvars.ContextVar[
|
|
75
|
+
concurrent.futures.Executor | None
|
|
76
|
+
] = contextvars.ContextVar("_BLOCKING_EXECUTOR_OVERRIDE", default=None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@contextlib.contextmanager
|
|
80
|
+
def blocking_executor(executor: concurrent.futures.Executor) -> Iterator[None]:
|
|
81
|
+
"""Make ``run_in_thread`` use ``executor`` for the duration of the block."""
|
|
82
|
+
token = _BLOCKING_EXECUTOR_OVERRIDE.set(executor)
|
|
83
|
+
try:
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
_BLOCKING_EXECUTOR_OVERRIDE.reset(token)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Executor for CancelHandle actions — a driver cancel (cursor.cancel(), a
|
|
90
|
+
# statement-cancel request) fired when the task awaiting a run_in_thread call is
|
|
91
|
+
# cancelled.
|
|
92
|
+
#
|
|
93
|
+
# Why not _BLOCKING_EXECUTOR?
|
|
94
|
+
# The cancel matters most when that pool is saturated by the very calls being
|
|
95
|
+
# abandoned. Queued behind them, the cancel would run only once one of them
|
|
96
|
+
# finished on its own — which is what it exists to avoid.
|
|
97
|
+
#
|
|
98
|
+
# Why not the loop?
|
|
99
|
+
# A driver cancel is a blocking network round trip.
|
|
100
|
+
#
|
|
101
|
+
# Small, because an action is one short call; created at import like the pool
|
|
102
|
+
# above, and for the same reason.
|
|
103
|
+
_CANCEL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
104
|
+
max_workers=4,
|
|
105
|
+
thread_name_prefix="sdk-cancel-",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CancelHandle:
|
|
110
|
+
"""Cancel a blocking call at the driver when its awaiting task is cancelled.
|
|
111
|
+
|
|
112
|
+
``run_in_thread`` cannot stop its worker thread: Python has no way to kill
|
|
113
|
+
one. Cancelling the awaiting task hands the loop back at once, but the
|
|
114
|
+
thread runs on, holding a pool slot, a connection and server-side compute
|
|
115
|
+
until the call returns by itself. A handle closes that gap for calls whose
|
|
116
|
+
driver *can* be told to stop::
|
|
117
|
+
|
|
118
|
+
handle = CancelHandle()
|
|
119
|
+
|
|
120
|
+
def _query() -> list[Row]:
|
|
121
|
+
cursor = connection.cursor()
|
|
122
|
+
handle.set(cursor.cancel) # from the worker thread, once it exists
|
|
123
|
+
cursor.execute(sql)
|
|
124
|
+
return cursor.fetchall()
|
|
125
|
+
|
|
126
|
+
rows = await run_in_thread(handle.bind(_query))
|
|
127
|
+
|
|
128
|
+
:meth:`bind` attaches the handle to the callable rather than to
|
|
129
|
+
``run_in_thread``'s own arguments, so every keyword still reaches ``func``.
|
|
130
|
+
When the awaiting task is cancelled, ``run_in_thread`` calls
|
|
131
|
+
:meth:`request`, which runs the registered action on a small dedicated
|
|
132
|
+
``sdk-cancel-`` pool — never on the event loop, and never on the
|
|
133
|
+
``sdk-blocking-`` pool, which is likely full of exactly the calls being
|
|
134
|
+
abandoned. The driver call then fails or returns early in its own thread,
|
|
135
|
+
and the thread and its pool slot are freed.
|
|
136
|
+
|
|
137
|
+
**The action is set from the worker thread**, because that is usually the
|
|
138
|
+
only place the driver handle exists, and often only part-way through the
|
|
139
|
+
call (a query id known only after submit). If a cancel was requested before
|
|
140
|
+
:meth:`set` is called, the action is fired as soon as it is set.
|
|
141
|
+
|
|
142
|
+
Each action fires at most once. A failing action is logged at WARNING and
|
|
143
|
+
never raised: by then the caller has already been cancelled.
|
|
144
|
+
|
|
145
|
+
Thread-safe. One handle per call; it is not reusable across calls.
|
|
146
|
+
|
|
147
|
+
**Never stop or suspend a warehouse from an action.** A cancel stops *this*
|
|
148
|
+
statement. A warehouse that the statement started resuming keeps resuming,
|
|
149
|
+
and stopping it would take it away from every other user of it.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self) -> None:
|
|
153
|
+
self._lock = threading.Lock()
|
|
154
|
+
self._action: Callable[[], None] | None = None
|
|
155
|
+
self._requested = False
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def requested(self) -> bool:
|
|
159
|
+
"""Whether a cancel has been requested.
|
|
160
|
+
|
|
161
|
+
Worker code checks this between steps (a ``fetchmany`` loop, a batch of
|
|
162
|
+
statements) to stop work nobody is waiting for.
|
|
163
|
+
"""
|
|
164
|
+
return self._requested
|
|
165
|
+
|
|
166
|
+
def bind(self, func: Callable[P, T]) -> Callable[P, T]:
|
|
167
|
+
"""*func*, carrying this handle to :func:`run_in_thread`.
|
|
168
|
+
|
|
169
|
+
The returned callable calls *func* with whatever it is given, so
|
|
170
|
+
``run_in_thread(handle.bind(func), *args, **kwargs)`` passes every
|
|
171
|
+
argument through unchanged; ``run_in_thread`` only reads the handle off
|
|
172
|
+
it. Called directly, it behaves exactly like *func*.
|
|
173
|
+
"""
|
|
174
|
+
return _BoundCall(func, self)
|
|
175
|
+
|
|
176
|
+
def set(self, action: Callable[[], None]) -> None:
|
|
177
|
+
"""Register the driver-level cancel for the call in progress.
|
|
178
|
+
|
|
179
|
+
Call this from the worker thread as soon as the driver handle exists.
|
|
180
|
+
A later ``set`` replaces an action that has not fired. If a cancel was
|
|
181
|
+
already requested, ``action`` is fired immediately instead of stored.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
action: Zero-argument callable that tells the driver to stop, e.g.
|
|
185
|
+
``cursor.cancel``. Must not stop or suspend a warehouse.
|
|
186
|
+
"""
|
|
187
|
+
with self._lock:
|
|
188
|
+
if not self._requested:
|
|
189
|
+
self._action = action
|
|
190
|
+
return
|
|
191
|
+
_fire_cancel_action(action)
|
|
192
|
+
|
|
193
|
+
def request(self) -> None:
|
|
194
|
+
"""Request a cancel: fire the registered action, if any, at most once.
|
|
195
|
+
|
|
196
|
+
Called by ``run_in_thread`` when its awaiting task is cancelled. Returns
|
|
197
|
+
without waiting for the action, which runs on the ``sdk-cancel-`` pool.
|
|
198
|
+
"""
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._requested = True
|
|
201
|
+
action, self._action = self._action, None
|
|
202
|
+
if action is not None:
|
|
203
|
+
_fire_cancel_action(action)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _BoundCall(Generic[P, T]):
|
|
207
|
+
"""A callable carrying the :class:`CancelHandle` it was bound to."""
|
|
208
|
+
|
|
209
|
+
__slots__ = ("func", "handle")
|
|
210
|
+
|
|
211
|
+
def __init__(self, func: Callable[P, T], handle: CancelHandle) -> None:
|
|
212
|
+
self.func = func
|
|
213
|
+
self.handle = handle
|
|
214
|
+
|
|
215
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
216
|
+
return self.func(*args, **kwargs)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _bound_handle(func: Callable[..., Any]) -> CancelHandle | None:
|
|
220
|
+
"""The handle *func* was bound to, looking through ``functools.partial``."""
|
|
221
|
+
target: Any = func
|
|
222
|
+
while isinstance(target, functools.partial):
|
|
223
|
+
target = target.func
|
|
224
|
+
return target.handle if isinstance(target, _BoundCall) else None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fire_cancel_action(action: Callable[[], None]) -> None:
|
|
228
|
+
"""Run a cancel action on the cancel pool; log, never raise, a failure."""
|
|
229
|
+
try:
|
|
230
|
+
_CANCEL_EXECUTOR.submit(_run_cancel_action, action)
|
|
231
|
+
except RuntimeError:
|
|
232
|
+
# Shut down at interpreter exit; the process is going, and its
|
|
233
|
+
# connections with it.
|
|
234
|
+
logger.debug("Cancel pool is shut down; skipped cancel action", exc_info=True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _run_cancel_action(action: Callable[[], None]) -> None:
|
|
238
|
+
try:
|
|
239
|
+
action()
|
|
240
|
+
except Exception:
|
|
241
|
+
logger.warning(
|
|
242
|
+
"Cancel action %s failed; the blocking call it was meant to stop "
|
|
243
|
+
"may run to completion",
|
|
244
|
+
_offloaded_callable_name(action),
|
|
245
|
+
exc_info=True,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
#: Label prefixes for the auto-holds on the two offload seams (ADR-0018 →
|
|
250
|
+
#: *Feeding the tracker*, mechanism 2). The offloaded callable's own name is
|
|
251
|
+
#: appended, so warn mode ranks *sites* — "run_in_thread.Cursor.execute, p99
|
|
252
|
+
#: 40min, unbounded" is a work-list entry — instead of collapsing every blocking
|
|
253
|
+
#: call in an app into one bucket. Naming the seam is part of the signal: it
|
|
254
|
+
#: tells an operator which primitive to wrap in ``holding_progress()``.
|
|
255
|
+
_THREAD_HOLD_PREFIX = "run_in_thread."
|
|
256
|
+
_ISOLATED_HOLD_PREFIX = "run_fault_isolated."
|
|
257
|
+
|
|
258
|
+
#: What a dotted Python qualname can contain, and nothing else: identifier
|
|
259
|
+
#: characters, the dots joining them, and the angle brackets CPython puts around
|
|
260
|
+
#: ``<lambda>`` and ``<locals>``. A name carrying anything outside this — a
|
|
261
|
+
#: space, a quote, a path separator, a newline — is not a code identifier, so it
|
|
262
|
+
#: is not something this label may repeat.
|
|
263
|
+
_CALLABLE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.<>]+$")
|
|
264
|
+
|
|
265
|
+
#: Longest callable name kept in a label. Real qualnames are short; the deepest
|
|
266
|
+
#: shape in the SDK's own tests (a lambda nested in a test method) is under 90.
|
|
267
|
+
_MAX_CALLABLE_NAME_LENGTH = 120
|
|
268
|
+
|
|
269
|
+
#: Stand-in for a callable whose name is unusable. A stable constant, so a name
|
|
270
|
+
#: this function refuses adds exactly one series to a metric rather than one per
|
|
271
|
+
#: distinct value.
|
|
272
|
+
_UNNAMEABLE_CALLABLE = "<callable>"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _offloaded_callable_name(func: Callable[..., Any]) -> str:
|
|
276
|
+
"""Name the offloaded callable, for its auto-hold label.
|
|
277
|
+
|
|
278
|
+
Reads the callable's own ``__qualname__`` rather than inspecting the caller's
|
|
279
|
+
frame. All three ``run_in_thread`` entry points funnel into one
|
|
280
|
+
implementation, so a frame walk would name an SDK wrapper on two of the
|
|
281
|
+
three; and the callee is the half of the site an operator can act on anyway.
|
|
282
|
+
|
|
283
|
+
Only ever a code identifier — never an argument — so no query, path,
|
|
284
|
+
credential or customer value can reach a hold label.
|
|
285
|
+
|
|
286
|
+
Each fallback exists because something real lacks a usable ``__qualname__``:
|
|
287
|
+
``functools.partial`` carries no name of its own (the wrapped callable
|
|
288
|
+
does), a callable *instance* has one only on its class, and a ``Mock``
|
|
289
|
+
fabricates a child mock for whatever attribute is asked of it, which would
|
|
290
|
+
otherwise put a repr into a label.
|
|
291
|
+
|
|
292
|
+
The name is then checked against what a qualname can actually look like, and
|
|
293
|
+
replaced with :data:`_UNNAMEABLE_CALLABLE` if it is not. ``__qualname__`` is
|
|
294
|
+
an ordinary writable attribute, so "it comes from code" is a convention
|
|
295
|
+
rather than a guarantee — a decorator, a factory or a dynamically-built
|
|
296
|
+
class can set it to anything, including a per-call value. That matters here
|
|
297
|
+
and not at a normal call site: this string lands in the stall log and in the
|
|
298
|
+
``last_label`` *metric attribute*, where a per-call value is unbounded
|
|
299
|
+
series cardinality and a long one is dead weight on every stall report. This
|
|
300
|
+
is defence in depth, not a live threat — but the guarantee this function
|
|
301
|
+
advertises is cheap to actually enforce.
|
|
302
|
+
"""
|
|
303
|
+
target: Any = func
|
|
304
|
+
while isinstance(target, (functools.partial, _BoundCall)):
|
|
305
|
+
target = target.func
|
|
306
|
+
for attribute in ("__qualname__", "__name__"):
|
|
307
|
+
name = getattr(target, attribute, None)
|
|
308
|
+
if isinstance(name, str) and _is_usable_callable_name(name):
|
|
309
|
+
return name
|
|
310
|
+
fallback = type(target).__name__
|
|
311
|
+
return fallback if _is_usable_callable_name(fallback) else _UNNAMEABLE_CALLABLE
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _is_usable_callable_name(name: str) -> bool:
|
|
315
|
+
"""Whether ``name`` is short enough and shaped like a dotted qualname."""
|
|
316
|
+
return (
|
|
317
|
+
0 < len(name) <= _MAX_CALLABLE_NAME_LENGTH
|
|
318
|
+
and _CALLABLE_NAME_PATTERN.match(name) is not None
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@contextlib.contextmanager
|
|
323
|
+
def _auto_hold(label: str, timeout: float | None) -> Iterator[None]:
|
|
324
|
+
"""Vouch for an offload — unless a human already vouched for it.
|
|
325
|
+
|
|
326
|
+
The automatic half of ADR-0018's mechanism 2, shared by both offload seams so
|
|
327
|
+
the precedence rule below cannot be right at one and wrong at the other.
|
|
328
|
+
|
|
329
|
+
**An explicit allowance wins.** Inside a ``holding_progress`` block the auto-
|
|
330
|
+
hold stands down entirely and the declared hold governs. It has to: the
|
|
331
|
+
tracker's hold set is a union — ``held()`` and ``stalled_for()`` say
|
|
332
|
+
"vouched" while *any* hold is unlapsed — so an unbounded auto-hold nested
|
|
333
|
+
inside a bounded declared one keeps vouching after the declared allowance
|
|
334
|
+
lapses. The author asked for a wedged call to be caught at
|
|
335
|
+
``timeout + budget``; adding a hold they did not ask for would silently hand
|
|
336
|
+
the site back to the duration backstop, which is the exact outcome
|
|
337
|
+
``holding_progress`` exists to prevent. Standing down is also the honest
|
|
338
|
+
reading: the operation *is* already vouched for, by someone who knows the
|
|
339
|
+
number.
|
|
340
|
+
|
|
341
|
+
That leaves exactly one hold around the offload rather than two, so the
|
|
342
|
+
warn-mode work-list ranks the site an author can act on — the declared one —
|
|
343
|
+
instead of double-counting the same wall-clock under a second label.
|
|
344
|
+
|
|
345
|
+
The suppression is context-scoped (:func:`declared_hold_active`), never
|
|
346
|
+
tracker-scoped: a concurrent offload in a task that never entered the block
|
|
347
|
+
still gets its own hold. Suppressing on "some hold is open somewhere on this
|
|
348
|
+
tracker" would leave real blocking work unvouched and re-introduce the
|
|
349
|
+
false-kill this mechanism exists to remove.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
label: Hold label — a site, never a value.
|
|
353
|
+
timeout: Allowance for the hold, or ``None`` for an unbounded one.
|
|
354
|
+
|
|
355
|
+
Yields:
|
|
356
|
+
Nothing; the caller runs its offload inside the block.
|
|
357
|
+
"""
|
|
358
|
+
if declared_hold_active():
|
|
359
|
+
yield
|
|
360
|
+
return
|
|
361
|
+
# The tracker is read once and both calls go to that same object. Re-reading
|
|
362
|
+
# for the release would pick up whatever tracker the context has by then,
|
|
363
|
+
# which for a hold spanning a context change releases the wrong deadline.
|
|
364
|
+
tracker = current_progress_tracker()
|
|
365
|
+
hold = tracker.enter_hold(label, timeout)
|
|
366
|
+
try:
|
|
367
|
+
yield
|
|
368
|
+
finally:
|
|
369
|
+
# `finally`, not the success path: an offload that raises, and an
|
|
370
|
+
# activity cancelled mid-offload, must still release *their own* token.
|
|
371
|
+
# A leaked unbounded hold vouches for the rest of the attempt, which
|
|
372
|
+
# turns one exception into a watchdog that never fires again.
|
|
373
|
+
tracker.exit_hold(hold)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def run_in_thread(
|
|
377
|
+
func: Callable[..., T],
|
|
378
|
+
*args: Any,
|
|
379
|
+
**kwargs: Any,
|
|
380
|
+
) -> T:
|
|
381
|
+
"""Last-resort escape hatch: run a blocking function in a thread pool.
|
|
382
|
+
|
|
383
|
+
.. warning::
|
|
384
|
+
**Use only when no async-native alternative exists.** This is the
|
|
385
|
+
bottom of the preference list, not the default tool for "I have I/O
|
|
386
|
+
to do". Per ADR-0010 (async-first design), the SDK runs on Temporal's
|
|
387
|
+
asyncio event loop; blocking the loop breaks auto-heartbeats and
|
|
388
|
+
causes activities to be retried even though they are making progress.
|
|
389
|
+
|
|
390
|
+
**Decision order for blocking work (apps and SDK alike):**
|
|
391
|
+
|
|
392
|
+
1. **Prefer an async-native library.** If one exists, use it. No
|
|
393
|
+
``run_in_thread`` needed:
|
|
394
|
+
|
|
395
|
+
========================= ====================== ====================
|
|
396
|
+
Need Use (async) Avoid (blocking)
|
|
397
|
+
========================= ====================== ====================
|
|
398
|
+
HTTP requests ``httpx``, ``aiohttp`` ``requests``
|
|
399
|
+
AWS SDK ``aioboto3``, ``boto3``
|
|
400
|
+
``aiobotocore``
|
|
401
|
+
PostgreSQL ``asyncpg`` ``psycopg2``
|
|
402
|
+
MySQL ``aiomysql`` ``pymysql``
|
|
403
|
+
File I/O ``aiofiles`` ``open()``
|
|
404
|
+
========================= ====================== ====================
|
|
405
|
+
|
|
406
|
+
2. **Then check the SDK.** Many helpers are already async — for example,
|
|
407
|
+
``self.context.storage`` (ObjectStore), ``self.context.state``
|
|
408
|
+
(StateStore), and credential resolution all expose ``await``-able
|
|
409
|
+
methods. Don't wrap them in ``run_in_thread``.
|
|
410
|
+
3. **Only then** fall back to ``run_in_thread`` — and only after
|
|
411
|
+
confirming there is no async-native alternative for the library
|
|
412
|
+
you're calling.
|
|
413
|
+
|
|
414
|
+
**Examples of incorrect use (do not do this):**
|
|
415
|
+
|
|
416
|
+
.. code-block:: python
|
|
417
|
+
|
|
418
|
+
# WRONG — boto3 has aioboto3; use that instead.
|
|
419
|
+
await self.task_context.run_in_thread(s3_client.put_object, ...)
|
|
420
|
+
|
|
421
|
+
# WRONG — requests has httpx; use that instead.
|
|
422
|
+
await self.task_context.run_in_thread(requests.get, url, timeout=30)
|
|
423
|
+
|
|
424
|
+
**Behavior:**
|
|
425
|
+
|
|
426
|
+
- ContextVars (ObjectStore, logger context, correlation ID, infrastructure
|
|
427
|
+
handles) are propagated to the worker thread via
|
|
428
|
+
``contextvars.copy_context()``. Mutations inside the thread stay
|
|
429
|
+
isolated from the caller (copy semantics).
|
|
430
|
+
- Threads run on a dedicated ``sdk-blocking-*`` pool, separate from
|
|
431
|
+
Temporal's activity pool, to avoid deadlocking the worker.
|
|
432
|
+
- The offload is automatically wrapped in an **unbounded** progress hold
|
|
433
|
+
(ADR-0018), so the stall watchdog never accuses a legitimately long
|
|
434
|
+
blocking call of stalling. Nothing to do at the call site, and nothing
|
|
435
|
+
about a long blocking call behaves differently than it did before the
|
|
436
|
+
watchdog existed. The SDK does not invent a duration for somebody else's
|
|
437
|
+
blocking call, so the watchdog is inactive for the call's whole duration
|
|
438
|
+
and the activity's duration bound is the only thing still holding it.
|
|
439
|
+
If you can say how long you would let this one call run before you would
|
|
440
|
+
rather it failed, declare it — wrap the offload in
|
|
441
|
+
``holding_progress(label, timeout=...)``
|
|
442
|
+
(:func:`application_sdk.execution.progress.holding_progress`) and a wedged
|
|
443
|
+
call is caught at ``timeout`` + the no-progress budget instead of at the
|
|
444
|
+
backstop. Inside such a block the automatic hold **stands down** and your
|
|
445
|
+
declared allowance governs; the SDK never adds a vouch that would outlive
|
|
446
|
+
the one you asked for.
|
|
447
|
+
|
|
448
|
+
**CRITICAL: your blocking code MUST have its own timeout.**
|
|
449
|
+
Python threads cannot be forcibly killed. If the wrapped call hangs
|
|
450
|
+
forever, the thread runs forever — this orphans state and consumes
|
|
451
|
+
pool slots even after the activity is retried.
|
|
452
|
+
|
|
453
|
+
**Cancelling at the driver.** Cancelling the awaiting task returns control
|
|
454
|
+
at once, but the thread runs on. If the driver can stop the call from
|
|
455
|
+
another thread, bind a :class:`CancelHandle` to ``func``
|
|
456
|
+
(``run_in_thread(handle.bind(func), ...)``) and register the driver's
|
|
457
|
+
cancel on it from inside ``func``; the handle is fired when the awaiting
|
|
458
|
+
task is cancelled, and ``CancelledError`` is re-raised as usual. The handle
|
|
459
|
+
travels on the callable, never in ``**kwargs``, so every keyword argument
|
|
460
|
+
still reaches ``func`` — no name is reserved.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
func: Blocking function to run. MUST have internal timeout handling.
|
|
464
|
+
A :meth:`CancelHandle.bind` result is cancelled at the driver too.
|
|
465
|
+
*args: Positional arguments for ``func``.
|
|
466
|
+
**kwargs: Keyword arguments for ``func``, all passed through.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Result of ``func(*args, **kwargs)``.
|
|
470
|
+
|
|
471
|
+
See Also:
|
|
472
|
+
- ``docs/adr/0010-async-first-blocking-code.md`` — full rationale.
|
|
473
|
+
- ``self.context.storage`` / ``self.context.state`` — already async.
|
|
474
|
+
"""
|
|
475
|
+
ctx = contextvars.copy_context()
|
|
476
|
+
loop = asyncio.get_running_loop()
|
|
477
|
+
# Auto-hold, unbounded (ADR-0018 → *Feeding the tracker*, mechanism 2).
|
|
478
|
+
# `run_in_thread` is a mandatory seam, so this is the one place the SDK can
|
|
479
|
+
# vouch for blocking work for free — and it must vouch without inventing a
|
|
480
|
+
# duration. `func`'s own `timeout=` kwarg is not one: it is per-operation (a
|
|
481
|
+
# `requests` read timeout bounds the gap between socket reads, a
|
|
482
|
+
# `statement_timeout` bounds one statement of many), so a bound derived from
|
|
483
|
+
# it is systematically *smaller* than the call's legitimate duration, and
|
|
484
|
+
# the dominant shape — `run_in_thread(cursor.execute, sql)` — carries no
|
|
485
|
+
# timeout at all. Any fallback tight enough to be useful would be tighter
|
|
486
|
+
# than today's `start_to_close`, i.e. the upgrade would make legitimate long
|
|
487
|
+
# blocking work fail sooner than before. So: unbounded, the watchdog is
|
|
488
|
+
# inactive for the call's duration, the duration backstop owns it, and warn
|
|
489
|
+
# mode reports every closed hold with its observed duration. That residual
|
|
490
|
+
# is accepted and surfaced, not closed.
|
|
491
|
+
call: Callable[[], T] = functools.partial(
|
|
492
|
+
ctx.run, functools.partial(func, *args, **kwargs)
|
|
493
|
+
)
|
|
494
|
+
label = _offloaded_callable_name(func)
|
|
495
|
+
scopes = _OFFLOAD_SCOPES.get()
|
|
496
|
+
if scopes:
|
|
497
|
+
call = _tracked_offload(call, scopes, label)
|
|
498
|
+
cancel_handle = _bound_handle(func)
|
|
499
|
+
executor = _BLOCKING_EXECUTOR_OVERRIDE.get() or _BLOCKING_EXECUTOR
|
|
500
|
+
with _auto_hold(_THREAD_HOLD_PREFIX + label, None):
|
|
501
|
+
if cancel_handle is None:
|
|
502
|
+
return await loop.run_in_executor(executor, call)
|
|
503
|
+
try:
|
|
504
|
+
return await loop.run_in_executor(executor, call)
|
|
505
|
+
except asyncio.CancelledError:
|
|
506
|
+
# Non-blocking: the action is handed to the cancel pool.
|
|
507
|
+
cancel_handle.request()
|
|
508
|
+
raise
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
#: The open :func:`tracking_offloads` scopes, innermost last. A tuple rather
|
|
512
|
+
#: than one set so a nested scope does not hide its calls from an enclosing one:
|
|
513
|
+
#: each ``run_in_thread`` records its marker in every open scope. Tasks copy the
|
|
514
|
+
#: context when they are created, so a ``TaskGroup`` opened inside a scope hands
|
|
515
|
+
#: the same sets to every child.
|
|
516
|
+
_OFFLOAD_SCOPES: contextvars.ContextVar[tuple[set["_OffloadMarker"], ...]] = (
|
|
517
|
+
contextvars.ContextVar("_OFFLOAD_SCOPES", default=())
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class _OffloadMarker(concurrent.futures.Future[None]):
|
|
522
|
+
"""A tracked call's marker, carrying the callable's name for the drain's log."""
|
|
523
|
+
|
|
524
|
+
def __init__(self, label: str) -> None:
|
|
525
|
+
super().__init__()
|
|
526
|
+
self.label = label
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _tracked_offload(
|
|
530
|
+
call: Callable[[], T],
|
|
531
|
+
scopes: tuple[set[_OffloadMarker], ...],
|
|
532
|
+
label: str,
|
|
533
|
+
) -> Callable[[], T]:
|
|
534
|
+
"""Wrap *call* so the open scopes can see whether its thread is still running.
|
|
535
|
+
|
|
536
|
+
The asyncio future ``run_in_executor`` returns cannot answer that: cancelling
|
|
537
|
+
the awaiting task cancels it at once, while the thread it stood for runs on.
|
|
538
|
+
The marker is a separate ``concurrent.futures.Future`` the thread itself
|
|
539
|
+
drives — ``RUNNING`` for exactly as long as *call* is executing — so a drain
|
|
540
|
+
waits on the work, not on the task that stopped waiting for it.
|
|
541
|
+
"""
|
|
542
|
+
marker = _OffloadMarker(label)
|
|
543
|
+
for pending in scopes:
|
|
544
|
+
pending.add(marker)
|
|
545
|
+
|
|
546
|
+
def _forget(done: "concurrent.futures.Future[None]") -> None:
|
|
547
|
+
for pending in scopes:
|
|
548
|
+
pending.discard(done)
|
|
549
|
+
|
|
550
|
+
marker.add_done_callback(_forget)
|
|
551
|
+
|
|
552
|
+
def _run() -> T:
|
|
553
|
+
# A drain cancels markers still PENDING — calls queued behind a full
|
|
554
|
+
# pool whose caller has already gone — so they never start, rather
|
|
555
|
+
# than being waited on to run work nobody wants.
|
|
556
|
+
if not marker.set_running_or_notify_cancel():
|
|
557
|
+
raise concurrent.futures.CancelledError
|
|
558
|
+
try:
|
|
559
|
+
return call()
|
|
560
|
+
finally:
|
|
561
|
+
marker.set_result(None)
|
|
562
|
+
|
|
563
|
+
return _run
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@contextlib.contextmanager
|
|
567
|
+
def tracking_offloads() -> Iterator[set[_OffloadMarker]]:
|
|
568
|
+
"""Record every ``run_in_thread`` call made under this block, in any task.
|
|
569
|
+
|
|
570
|
+
Yields the live set of markers for calls that have not finished; pass it to
|
|
571
|
+
:func:`drain_offloads` to wait for them. Calls made in tasks created inside
|
|
572
|
+
the block are recorded too, because a task inherits the context it was
|
|
573
|
+
created in.
|
|
574
|
+
"""
|
|
575
|
+
pending: set[_OffloadMarker] = set()
|
|
576
|
+
token = _OFFLOAD_SCOPES.set((*_OFFLOAD_SCOPES.get(), pending))
|
|
577
|
+
try:
|
|
578
|
+
yield pending
|
|
579
|
+
finally:
|
|
580
|
+
_OFFLOAD_SCOPES.reset(token)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def drain_offloads(
|
|
584
|
+
pending: set[_OffloadMarker],
|
|
585
|
+
*,
|
|
586
|
+
max_no_progress_seconds: float | None = None,
|
|
587
|
+
clock: Callable[[], float] = time.monotonic,
|
|
588
|
+
) -> None:
|
|
589
|
+
"""Wait until no thread recorded in *pending* is still running, or none is progressing.
|
|
590
|
+
|
|
591
|
+
For the unwind path of a fan-out that was cancelled or failed: its tasks
|
|
592
|
+
have stopped awaiting their threads, but the threads have not stopped, and
|
|
593
|
+
whatever they write can land after the caller has moved on — a retry
|
|
594
|
+
included. Calls still queued are cancelled so they never start.
|
|
595
|
+
|
|
596
|
+
A cancellation arriving *during* the drain does not cut it short — that
|
|
597
|
+
would reopen the gap being closed — but it is not lost: it is re-raised once
|
|
598
|
+
the drain ends.
|
|
599
|
+
|
|
600
|
+
**The drain waits only while it is getting somewhere.** It keeps its own
|
|
601
|
+
stall clock, re-armed each time one of its threads finishes, and gives up
|
|
602
|
+
once that clock reaches the no-progress allowance — the attempt's resolved
|
|
603
|
+
``max_no_progress_seconds``, the number the stall watchdog applies, or
|
|
604
|
+
:data:`DEFAULT_MAX_NO_PROGRESS_SECONDS` outside an activity. This is
|
|
605
|
+
enforced whatever ``ATLAN_PROGRESS_WATCHDOG`` says, because the alternative
|
|
606
|
+
is not "wait a little longer": the calls drained here (an ``fsync``, a file
|
|
607
|
+
read) take no timeout, so one wedged volume would hold the drain, the
|
|
608
|
+
cancellation it is holding back, and the worker's shutdown behind it
|
|
609
|
+
forever.
|
|
610
|
+
|
|
611
|
+
It is deliberately not the attempt's own tracker. That one is re-armed by
|
|
612
|
+
any work in the attempt and vouched for by every other offload's unbounded
|
|
613
|
+
hold, so it cannot say whether *these* threads are moving; only their
|
|
614
|
+
finishing can.
|
|
615
|
+
|
|
616
|
+
Giving up is logged at WARNING with the calls still running, and then the
|
|
617
|
+
drain returns — or re-raises the cancellation it was holding — so the
|
|
618
|
+
caller's own failure propagates unchanged. Those threads are not stopped
|
|
619
|
+
(a thread cannot be); a call that later completes can still write.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
pending: The live marker set from :func:`tracking_offloads`.
|
|
623
|
+
max_no_progress_seconds: Override for the allowance; tests use it.
|
|
624
|
+
clock: Monotonic time source for the drain's stall clock.
|
|
625
|
+
"""
|
|
626
|
+
# list() snapshots under the GIL; worker threads discard as they finish.
|
|
627
|
+
running = [
|
|
628
|
+
marker for marker in list(pending) if not marker.cancel() and not marker.done()
|
|
629
|
+
]
|
|
630
|
+
if not running:
|
|
631
|
+
return
|
|
632
|
+
budget = max_no_progress_seconds
|
|
633
|
+
if budget is None:
|
|
634
|
+
budget = current_progress_tracker().max_no_progress_seconds
|
|
635
|
+
if budget is None:
|
|
636
|
+
budget = DEFAULT_MAX_NO_PROGRESS_SECONDS
|
|
637
|
+
|
|
638
|
+
# Only a drained thread finishing is progress here; see the docstring.
|
|
639
|
+
progress = ProgressTracker(clock=clock)
|
|
640
|
+
waiting = {asyncio.wrap_future(marker): marker for marker in running}
|
|
641
|
+
for marker in running:
|
|
642
|
+
marker.add_done_callback(
|
|
643
|
+
lambda _done, label=marker.label: progress.mark_progress(label)
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
cancelled = False
|
|
647
|
+
while True:
|
|
648
|
+
remaining = [w for w, marker in waiting.items() if not marker.done()]
|
|
649
|
+
if not remaining:
|
|
650
|
+
break
|
|
651
|
+
quiet = progress.stalled_for()
|
|
652
|
+
if quiet >= budget:
|
|
653
|
+
logger.warning(
|
|
654
|
+
"Stopped waiting for %d offloaded call(s) that made no progress "
|
|
655
|
+
"for %.0fs: %s. They are still running and may write after the "
|
|
656
|
+
"caller has moved on",
|
|
657
|
+
len(remaining),
|
|
658
|
+
quiet,
|
|
659
|
+
", ".join(sorted(waiting[w].label for w in remaining)),
|
|
660
|
+
)
|
|
661
|
+
break
|
|
662
|
+
try:
|
|
663
|
+
# Not wait_for: asyncio.wait never cancels what it waits on, so a
|
|
664
|
+
# timeout or a cancellation here leaves the markers untouched.
|
|
665
|
+
await asyncio.wait(
|
|
666
|
+
remaining,
|
|
667
|
+
timeout=budget - quiet,
|
|
668
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
669
|
+
)
|
|
670
|
+
except asyncio.CancelledError:
|
|
671
|
+
cancelled = True
|
|
672
|
+
if cancelled:
|
|
673
|
+
raise asyncio.CancelledError
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def submit_in_thread(
|
|
677
|
+
func: Callable[..., Any], *args: Any, **kwargs: Any
|
|
678
|
+
) -> "concurrent.futures.Future[Any]":
|
|
679
|
+
"""Run a blocking *cleanup* call on the offload pool without awaiting it.
|
|
680
|
+
|
|
681
|
+
``run_in_thread`` is the primitive to reach for. This is its narrow
|
|
682
|
+
companion for one specific problem: **cleanup that cancellation must not be
|
|
683
|
+
able to skip.** An ``await`` inside a ``finally`` is itself a cancellation
|
|
684
|
+
point, so a cancelled coroutine can be cut off before its own cleanup runs
|
|
685
|
+
and leak whatever it was holding. Submitting instead of awaiting takes the
|
|
686
|
+
event loop out of the path entirely: the call is handed to the pool and runs
|
|
687
|
+
to completion whether or not the caller survives — and, because it is not on
|
|
688
|
+
the loop, it may block on a lock held by an orphaned worker thread.
|
|
689
|
+
|
|
690
|
+
Nothing awaits the result, so a failure has nowhere to surface. It is logged
|
|
691
|
+
here rather than being swallowed by an unretrieved future.
|
|
692
|
+
|
|
693
|
+
Use only for short, self-contained cleanup — closing a handle, releasing a
|
|
694
|
+
lock — whose result no caller needs. Anything a caller must observe, or must
|
|
695
|
+
know has finished before the next step, belongs in ``run_in_thread``.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
func: Blocking cleanup callable.
|
|
699
|
+
*args: Positional arguments for ``func``.
|
|
700
|
+
**kwargs: Keyword arguments for ``func``.
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
The pool future, for callers (in practice, tests) that want to wait on
|
|
704
|
+
it. A cancelled future means the pool was already shut down and the call
|
|
705
|
+
never ran. Ignoring the return value is the normal case.
|
|
706
|
+
"""
|
|
707
|
+
ctx = contextvars.copy_context()
|
|
708
|
+
executor = _BLOCKING_EXECUTOR_OVERRIDE.get() or _BLOCKING_EXECUTOR
|
|
709
|
+
try:
|
|
710
|
+
future = executor.submit(ctx.run, functools.partial(func, *args, **kwargs))
|
|
711
|
+
except RuntimeError:
|
|
712
|
+
# The pool refuses work only once it has been shut down, which happens
|
|
713
|
+
# at interpreter exit. Nothing is left running to clean up after, and the
|
|
714
|
+
# OS reclaims whatever the process was holding — so this is ordinary
|
|
715
|
+
# shutdown ordering rather than a failure, and a caller unwinding in a
|
|
716
|
+
# ``finally`` must not have an exception raised at it here.
|
|
717
|
+
logger.debug(
|
|
718
|
+
"Offload pool is shut down; skipped detached cleanup call to %r",
|
|
719
|
+
getattr(func, "__qualname__", func),
|
|
720
|
+
exc_info=True,
|
|
721
|
+
)
|
|
722
|
+
skipped: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
|
723
|
+
skipped.cancel()
|
|
724
|
+
return skipped
|
|
725
|
+
future.add_done_callback(_report_detached_failure)
|
|
726
|
+
return future
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _report_detached_failure(future: "concurrent.futures.Future[Any]") -> None:
|
|
730
|
+
"""Log a detached cleanup failure, since no caller will ever await it."""
|
|
731
|
+
if future.cancelled():
|
|
732
|
+
return
|
|
733
|
+
error = future.exception()
|
|
734
|
+
if error is not None:
|
|
735
|
+
logger.warning(
|
|
736
|
+
"Detached cleanup call failed: %s: %s",
|
|
737
|
+
type(error).__name__,
|
|
738
|
+
error,
|
|
739
|
+
exc_info=error,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# Executor for work that must not be able to take the worker down with it.
|
|
744
|
+
#
|
|
745
|
+
# Why a process, not a thread?
|
|
746
|
+
# A native fault (SIGSEGV in a C extension) is not a Python exception: it
|
|
747
|
+
# bypasses every try/except and kills the whole process. In a thread that
|
|
748
|
+
# means the Temporal worker dies mid-poll. In a child process the kernel
|
|
749
|
+
# kills only the child, and the parent observes an ordinary, catchable
|
|
750
|
+
# BrokenProcessPool.
|
|
751
|
+
#
|
|
752
|
+
# Why spawn, not fork?
|
|
753
|
+
# fork() in a multi-threaded process (a Temporal worker always is) copies a
|
|
754
|
+
# single thread but every lock, in whatever state the other threads left
|
|
755
|
+
# them — a deadlock/corruption factory. spawn starts a clean interpreter.
|
|
756
|
+
#
|
|
757
|
+
# Lazy and cached by width: callers passing the same max_workers share one
|
|
758
|
+
# ProcessPoolExecutor of that width, created on first use; distinct widths get
|
|
759
|
+
# distinct pools, so discarding one width's pool on a crash never disturbs
|
|
760
|
+
# another's. Processes that never need isolation never pay for a child.
|
|
761
|
+
#
|
|
762
|
+
# Concurrency: the default pool runs several children so concurrent best-effort
|
|
763
|
+
# callers decode in PARALLEL, not serialised behind one child. Isolation here is
|
|
764
|
+
# purely fault containment — NOT a serialisation crutch to dodge the msgspec
|
|
765
|
+
# 0.20.0 concurrent-decode segfault: that bug is same-process (a shared
|
|
766
|
+
# in-process decoder across threads); separate child processes don't share that
|
|
767
|
+
# state, and once msgspec 0.21.1 lands even same-process concurrent decode is
|
|
768
|
+
# safe. The default width is capped low because each spawn child re-imports the
|
|
769
|
+
# decode stack (pyatlan/msgspec), which costs memory under a pod limit. A caller
|
|
770
|
+
# that wants to bound — or serialise (max_workers=1) — its own work passes an
|
|
771
|
+
# explicit width. (A crash in any one child breaks the whole ProcessPoolExecutor
|
|
772
|
+
# — inherent to the executor — so concurrent callers on that width then see
|
|
773
|
+
# BrokenProcessPool; for best-effort work that is a benign skip.)
|
|
774
|
+
_DEFAULT_PROCESS_POOL_MAX_WORKERS = min(4, (os.cpu_count() or 1))
|
|
775
|
+
_PROCESS_EXECUTORS: dict[int, concurrent.futures.ProcessPoolExecutor] = {}
|
|
776
|
+
_PROCESS_EXECUTORS_LOCK = threading.Lock()
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _get_process_executor(max_workers: int) -> concurrent.futures.ProcessPoolExecutor:
|
|
780
|
+
with _PROCESS_EXECUTORS_LOCK:
|
|
781
|
+
executor = _PROCESS_EXECUTORS.get(max_workers)
|
|
782
|
+
if executor is None:
|
|
783
|
+
executor = concurrent.futures.ProcessPoolExecutor(
|
|
784
|
+
max_workers=max_workers,
|
|
785
|
+
mp_context=multiprocessing.get_context("spawn"),
|
|
786
|
+
)
|
|
787
|
+
_PROCESS_EXECUTORS[max_workers] = executor
|
|
788
|
+
return executor
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _discard_process_executor(max_workers: int) -> None:
|
|
792
|
+
"""Drop the width-``max_workers`` pool (and kill its children) so the next
|
|
793
|
+
call at that width starts fresh."""
|
|
794
|
+
with _PROCESS_EXECUTORS_LOCK:
|
|
795
|
+
executor = _PROCESS_EXECUTORS.pop(max_workers, None)
|
|
796
|
+
if executor is None:
|
|
797
|
+
return
|
|
798
|
+
# Kill the children BEFORE shutdown(): a dead child is the pool's
|
|
799
|
+
# well-trodden unwind path — the manager thread sees it, marks the pool
|
|
800
|
+
# broken, resolves every still-queued work item with BrokenProcessPool,
|
|
801
|
+
# and every internal thread exits. The reverse order (shutdown, then kill)
|
|
802
|
+
# strands a manager/feeder thread on a lock and hangs interpreter exit.
|
|
803
|
+
# No cancel_futures=True: cancelling a *foreign* caller's queued future
|
|
804
|
+
# would surface as CancelledError (a BaseException) in that innocent
|
|
805
|
+
# caller; the broken-pool resolution reaches it as a catchable
|
|
806
|
+
# BrokenProcessPool instead. shutdown() never kills a *running* child
|
|
807
|
+
# (e.g. one hung past a timeout) and ProcessPoolExecutor exposes no
|
|
808
|
+
# supported kill, so reach for the internal process table (None once the
|
|
809
|
+
# pool is broken); on a future CPython that renames it, the child leaks
|
|
810
|
+
# until it finishes — degraded, not fatal.
|
|
811
|
+
for process in list((getattr(executor, "_processes", None) or {}).values()):
|
|
812
|
+
process.kill()
|
|
813
|
+
executor.shutdown(wait=False)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
async def run_fault_isolated(
|
|
817
|
+
func: Callable[..., T],
|
|
818
|
+
*args: Any,
|
|
819
|
+
timeout: float | None = None,
|
|
820
|
+
max_workers: int | None = None,
|
|
821
|
+
**kwargs: Any,
|
|
822
|
+
) -> T:
|
|
823
|
+
"""Run ``func`` in an isolated child process (native-crash containment).
|
|
824
|
+
|
|
825
|
+
The mechanism layer. Unlike :func:`run_in_thread`, this survives faults that
|
|
826
|
+
are not Python exceptions: if ``func`` segfaults a C extension, only the
|
|
827
|
+
child dies and the caller gets a catchable :class:`BrokenProcessPool`. Use it
|
|
828
|
+
for work whose native fault must never take the worker process down.
|
|
829
|
+
|
|
830
|
+
This *raises* on failure (``BrokenProcessPool`` / ``TimeoutError``) — the
|
|
831
|
+
caller decides what to do. For non-essential work that should be silently
|
|
832
|
+
skipped on failure, prefer :func:`run_best_effort`, which wraps this and
|
|
833
|
+
swallows failures. Essential work — where a failure should fail the activity
|
|
834
|
+
— should not be isolated per-call at all; run it in-process or via
|
|
835
|
+
:func:`run_in_thread` and let Temporal/k8s recover a crash.
|
|
836
|
+
|
|
837
|
+
Constraints that :func:`run_in_thread` does not have:
|
|
838
|
+
|
|
839
|
+
- ``func``, ``args``, ``kwargs`` and the return value must be picklable;
|
|
840
|
+
``func`` must be a module-level function (pickled by reference).
|
|
841
|
+
- ContextVars do **not** propagate — the child is a fresh interpreter.
|
|
842
|
+
Have the child return data and log from the parent.
|
|
843
|
+
- The child imports ``func``'s module on first use (one-time cost,
|
|
844
|
+
amortized by the pooled worker).
|
|
845
|
+
|
|
846
|
+
Like :func:`run_in_thread`, the call is automatically wrapped in a progress
|
|
847
|
+
hold (ADR-0018) so an isolated call — which emits nothing at all until the
|
|
848
|
+
child returns — is never read as a stall. Here the hold is *bounded* by
|
|
849
|
+
``timeout`` when one is given, because that number is already a wall-clock
|
|
850
|
+
kill enforced below; ``timeout=None`` gives an unbounded hold, matching its
|
|
851
|
+
"wait forever" semantics.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
func: Module-level function to run in the child.
|
|
855
|
+
timeout: Seconds to wait before killing the child and raising
|
|
856
|
+
``TimeoutError``. ``None`` waits forever.
|
|
857
|
+
max_workers: Width of the (width-keyed) process pool this call runs on.
|
|
858
|
+
``None`` (default) uses ``min(4, cpu_count)`` so concurrent callers
|
|
859
|
+
decode in parallel. Pass ``1`` to opt into sequential execution —
|
|
860
|
+
all ``max_workers=1`` callers share a single child and queue behind
|
|
861
|
+
one another; pass another integer to bound concurrency at a
|
|
862
|
+
different width. Must be >= 1.
|
|
863
|
+
|
|
864
|
+
Raises:
|
|
865
|
+
BrokenProcessPool: The child died abnormally (native crash), or a
|
|
866
|
+
concurrent caller on the same pool discarded it (timeout) while this
|
|
867
|
+
call was in flight. That pool is discarded; the next call at this
|
|
868
|
+
width gets a fresh child.
|
|
869
|
+
TimeoutError: ``timeout`` elapsed. The child is killed and the pool
|
|
870
|
+
discarded.
|
|
871
|
+
InvalidInputValueError: ``max_workers`` is < 1 (also a ``ValueError``).
|
|
872
|
+
"""
|
|
873
|
+
workers = _DEFAULT_PROCESS_POOL_MAX_WORKERS if max_workers is None else max_workers
|
|
874
|
+
if workers < 1:
|
|
875
|
+
raise InvalidInputValueError(
|
|
876
|
+
message=f"max_workers must be >= 1, got {workers}",
|
|
877
|
+
field="max_workers",
|
|
878
|
+
constraint=">= 1",
|
|
879
|
+
value_summary=str(workers),
|
|
880
|
+
)
|
|
881
|
+
loop = asyncio.get_running_loop()
|
|
882
|
+
# Auto-hold, bounded by this call's own `timeout` (ADR-0018 → *Feeding the
|
|
883
|
+
# tracker*, mechanism 2 — the same treatment as `run_in_thread`, since this
|
|
884
|
+
# is the SDK's other offload seam and an isolated call emits nothing at all
|
|
885
|
+
# until the child returns; the SDK's own default `timeout` for the upload
|
|
886
|
+
# validation scan is 600s, well past any plausible no-progress budget).
|
|
887
|
+
#
|
|
888
|
+
# Bounded rather than unbounded because unlike `run_in_thread` there is a
|
|
889
|
+
# real wall-clock number here and it belongs to *this* function, not to
|
|
890
|
+
# `func`: the block below enforces `timeout` by killing the child and raising
|
|
891
|
+
# `TimeoutError`, so it means precisely what `enter_hold`'s allowance means —
|
|
892
|
+
# how long the caller would let this one operation run before it would rather
|
|
893
|
+
# it failed. What ADR-0018 rejects is deriving a bound from the *callee's*
|
|
894
|
+
# per-operation kwargs, which is a different number. `timeout=None` still
|
|
895
|
+
# means an unbounded hold, matching `None`'s "wait forever" here.
|
|
896
|
+
#
|
|
897
|
+
# Inside a `holding_progress` block this stands down like `run_in_thread`'s
|
|
898
|
+
# does — see `_auto_hold`. Harmless even when this call's own `timeout` is
|
|
899
|
+
# tighter than the declared allowance: `timeout` is enforced here by killing
|
|
900
|
+
# the child, so the call returns at its deadline whether or not a hold is
|
|
901
|
+
# watching.
|
|
902
|
+
with _auto_hold(_ISOLATED_HOLD_PREFIX + _offloaded_callable_name(func), timeout):
|
|
903
|
+
future = loop.run_in_executor(
|
|
904
|
+
_get_process_executor(workers), functools.partial(func, *args, **kwargs)
|
|
905
|
+
)
|
|
906
|
+
if timeout is not None:
|
|
907
|
+
# Not asyncio.wait_for: on timeout it cancels the future and then
|
|
908
|
+
# waits for the cancellation to land — but a running executor call
|
|
909
|
+
# cannot be cancelled, so wait_for would hang exactly when the child
|
|
910
|
+
# hangs. asyncio.wait just stops waiting; we then kill the child
|
|
911
|
+
# ourselves.
|
|
912
|
+
done, _ = await asyncio.wait({future}, timeout=timeout)
|
|
913
|
+
if not done:
|
|
914
|
+
_discard_process_executor(workers)
|
|
915
|
+
# The kill resolves the abandoned future with BrokenProcessPool;
|
|
916
|
+
# consume it so asyncio never logs "exception was never
|
|
917
|
+
# retrieved".
|
|
918
|
+
future.add_done_callback(
|
|
919
|
+
lambda f: None if f.cancelled() else f.exception()
|
|
920
|
+
)
|
|
921
|
+
raise TimeoutError(f"run_fault_isolated timed out after {timeout}s")
|
|
922
|
+
try:
|
|
923
|
+
return await future
|
|
924
|
+
except BrokenProcessPool:
|
|
925
|
+
_discard_process_executor(workers)
|
|
926
|
+
raise
|
|
927
|
+
except asyncio.CancelledError:
|
|
928
|
+
task = asyncio.current_task()
|
|
929
|
+
if task is not None and task.cancelling():
|
|
930
|
+
raise # real cancellation of the caller — must propagate
|
|
931
|
+
# Foreign cancellation: a concurrent caller's timeout discarded the
|
|
932
|
+
# shared pool while this call was still queued. From this caller's
|
|
933
|
+
# perspective that is exactly a broken pool — surface it as the
|
|
934
|
+
# catchable exception the contract promises, never CancelledError.
|
|
935
|
+
raise BrokenProcessPool(
|
|
936
|
+
"process pool was discarded while this call was queued"
|
|
937
|
+
) from None
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
async def run_best_effort(
|
|
941
|
+
func: Callable[..., T],
|
|
942
|
+
*args: Any,
|
|
943
|
+
label: str,
|
|
944
|
+
logger: Any,
|
|
945
|
+
timeout: float | None = None,
|
|
946
|
+
max_workers: int | None = None,
|
|
947
|
+
**kwargs: Any,
|
|
948
|
+
) -> T | None:
|
|
949
|
+
"""Run non-essential native work fault-isolated; never let it break the caller.
|
|
950
|
+
|
|
951
|
+
The policy layer over :func:`run_fault_isolated`. Runs ``func`` in an
|
|
952
|
+
isolated child process and, on *any* failure — a native crash
|
|
953
|
+
(``BrokenProcessPool``), a ``timeout``, or an ordinary exception — logs a
|
|
954
|
+
warning via ``logger`` and returns ``None`` rather than propagating. This is
|
|
955
|
+
the SDK's sanctioned home for *best-effort* native work: work whose result is
|
|
956
|
+
used when present and safely skipped when absent, and which must never crash
|
|
957
|
+
or fail the worker (e.g. the warn-only upload validation scan). Essential
|
|
958
|
+
work — where a failure *should* fail the activity — must not use this.
|
|
959
|
+
|
|
960
|
+
A genuine caller cancellation (``asyncio.CancelledError`` from cooperative
|
|
961
|
+
task cancellation) is deliberately **not** swallowed — it propagates.
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
func: Module-level function to run in the child. Same picklability /
|
|
965
|
+
ContextVar constraints as :func:`run_fault_isolated`.
|
|
966
|
+
label: Human label for the work, interpolated into the warning
|
|
967
|
+
(e.g. ``"Transformed-asset validation"``).
|
|
968
|
+
logger: The caller's logger, so the warning is attributed to the
|
|
969
|
+
caller's module (OTel source) rather than this one.
|
|
970
|
+
timeout: Seconds before the child is killed and the run is skipped.
|
|
971
|
+
max_workers: Pool width, forwarded to :func:`run_fault_isolated`. ``None``
|
|
972
|
+
uses the default ``min(4, cpu_count)``; pass ``1`` to serialise this
|
|
973
|
+
work.
|
|
974
|
+
|
|
975
|
+
Returns:
|
|
976
|
+
``func``'s result, or ``None`` if the run crashed, timed out, or errored.
|
|
977
|
+
"""
|
|
978
|
+
try:
|
|
979
|
+
return await run_fault_isolated(
|
|
980
|
+
func, *args, timeout=timeout, max_workers=max_workers, **kwargs
|
|
981
|
+
)
|
|
982
|
+
except BrokenProcessPool:
|
|
983
|
+
logger.warning(
|
|
984
|
+
"%s subprocess died or was discarded (a native fault in a "
|
|
985
|
+
"dependency, or a concurrent call's timeout); continuing without it",
|
|
986
|
+
label,
|
|
987
|
+
exc_info=True,
|
|
988
|
+
)
|
|
989
|
+
except TimeoutError:
|
|
990
|
+
logger.warning(
|
|
991
|
+
"%s timed out after %ss; continuing without it",
|
|
992
|
+
label,
|
|
993
|
+
timeout,
|
|
994
|
+
exc_info=True,
|
|
995
|
+
)
|
|
996
|
+
except Exception: # noqa: BLE001 — best-effort work must never break the caller
|
|
997
|
+
logger.warning("%s skipped due to an unexpected error", label, exc_info=True)
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
#: Names this module re-exported before it moved to the light logger; served on
|
|
1002
|
+
#: first access (the worker's structured logger class — absent on an api-only
|
|
1003
|
+
#: install, which never asks for it).
|
|
1004
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
1005
|
+
"AtlanLoggerAdapter": (
|
|
1006
|
+
"application_sdk.observability.logger_adaptor",
|
|
1007
|
+
"AtlanLoggerAdapter",
|
|
1008
|
+
),
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def __getattr__(name: str) -> Any:
|
|
1013
|
+
target = _LAZY.get(name)
|
|
1014
|
+
if target is None:
|
|
1015
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
1016
|
+
import importlib # noqa: PLC0415 — only for the lazy names above
|
|
1017
|
+
|
|
1018
|
+
value = getattr(importlib.import_module(target[0]), target[1])
|
|
1019
|
+
globals()[name] = value
|
|
1020
|
+
return value
|