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.
Files changed (78) hide show
  1. application_sdk/__init__.py +7 -0
  2. application_sdk/_build_info.py +62 -0
  3. application_sdk/_context_errors.py +65 -0
  4. application_sdk/_install.py +130 -0
  5. application_sdk/_logging.py +171 -0
  6. application_sdk/_runtime/__init__.py +29 -0
  7. application_sdk/_runtime/offload.py +1020 -0
  8. application_sdk/_runtime/progress.py +757 -0
  9. application_sdk/clients/__init__.py +63 -0
  10. application_sdk/clients/_interface.py +58 -0
  11. application_sdk/clients/_sql_failure_classifier.py +112 -0
  12. application_sdk/clients/base.py +565 -0
  13. application_sdk/clients/http_errors.py +28 -0
  14. application_sdk/clients/models.py +49 -0
  15. application_sdk/clients/sql.py +1218 -0
  16. application_sdk/clients/sql_errors.py +192 -0
  17. application_sdk/clients/sql_typecasters.py +431 -0
  18. application_sdk/clients/ssl_utils.py +247 -0
  19. application_sdk/common/__init__.py +53 -0
  20. application_sdk/common/_generated_tree.py +321 -0
  21. application_sdk/common/_listing.py +192 -0
  22. application_sdk/common/aws_utils.py +522 -0
  23. application_sdk/common/aws_utils_errors.py +88 -0
  24. application_sdk/common/build_identity.py +120 -0
  25. application_sdk/common/concurrency.py +45 -0
  26. application_sdk/common/errors.py +99 -0
  27. application_sdk/common/filter_matching.py +261 -0
  28. application_sdk/common/logger.py +11 -0
  29. application_sdk/common/models.py +31 -0
  30. application_sdk/common/sql_filters.py +719 -0
  31. application_sdk/common/sql_filters_errors.py +17 -0
  32. application_sdk/common/task_queue.py +431 -0
  33. application_sdk/common/types.py +51 -0
  34. application_sdk/common/utils.py +87 -0
  35. application_sdk/constants.py +1120 -0
  36. application_sdk/contracts/__init__.py +158 -0
  37. application_sdk/contracts/base.py +1178 -0
  38. application_sdk/contracts/events.py +251 -0
  39. application_sdk/contracts/types.py +801 -0
  40. application_sdk/contracts/types_errors.py +17 -0
  41. application_sdk/credentials/__init__.py +326 -0
  42. application_sdk/credentials/errors.py +226 -0
  43. application_sdk/credentials/extra.py +114 -0
  44. application_sdk/credentials/ingress.py +283 -0
  45. application_sdk/credentials/ref.py +376 -0
  46. application_sdk/credentials/spec.py +182 -0
  47. application_sdk/credentials/types.py +339 -0
  48. application_sdk/credentials/utils.py +257 -0
  49. application_sdk/errors/__init__.py +224 -0
  50. application_sdk/errors/base.py +504 -0
  51. application_sdk/errors/categories.py +118 -0
  52. application_sdk/errors/http.py +81 -0
  53. application_sdk/errors/leaves.py +526 -0
  54. application_sdk/errors/wire.py +124 -0
  55. application_sdk/handler/__init__.py +97 -0
  56. application_sdk/handler/_warmup.py +198 -0
  57. application_sdk/handler/asgi.py +252 -0
  58. application_sdk/handler/base.py +281 -0
  59. application_sdk/handler/context.py +212 -0
  60. application_sdk/handler/contracts.py +1166 -0
  61. application_sdk/handler/manifest.py +81 -0
  62. application_sdk/handler/metadata_routes.py +699 -0
  63. application_sdk/handler/object_routes.py +418 -0
  64. application_sdk/handler/request_contract.py +91 -0
  65. application_sdk/handler/routes.py +1118 -0
  66. application_sdk/handler/service_errors.py +37 -0
  67. application_sdk/server/__init__.py +15 -0
  68. application_sdk/server/fastapi/models.py +338 -0
  69. application_sdk/templates/__init__.py +52 -0
  70. application_sdk/templates/contracts/__init__.py +111 -0
  71. application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
  72. application_sdk/templates/contracts/incremental_sql.py +563 -0
  73. application_sdk/templates/contracts/sql_metadata.py +824 -0
  74. application_sdk/templates/contracts/sql_query.py +105 -0
  75. application_sdk/version.py +40 -0
  76. atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
  77. atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
  78. 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