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,757 @@
1
+ """Observed forward progress for one activity attempt (ADR-0018).
2
+
3
+ This is the foundation object for the stall watchdog. It answers one question —
4
+ *how long has it been since this attempt did anything observable?* — and it
5
+ answers it with **no Temporal dependency**, so local runs, unit tests and
6
+ production exercise the same code path, and a task with
7
+ ``heartbeat_timeout_seconds=None`` still gets a stall watchdog.
8
+
9
+ Progress tracking is deliberately a new object rather than a change to
10
+ :class:`~application_sdk.execution.heartbeat.HeartbeatController`: the
11
+ controller's job (send beats to Temporal) is unchanged, so its Protocol and
12
+ ``NoopHeartbeatController`` keep their current contract.
13
+
14
+ Two signals feed it (see ADR-0018 → *Feeding the tracker*):
15
+
16
+ - :meth:`ProgressTracker.mark_progress` — one observable unit of work
17
+ completed: a batch written, a chunk or file transferred, a page fetched, an
18
+ explicit ``context.heartbeat()``. Never wall-clock time, and never per
19
+ record: batch, chunk and page boundaries only.
20
+ - :meth:`ProgressTracker.enter_hold` / :meth:`ProgressTracker.exit_hold` — a
21
+ *vouch* for one in-flight operation the SDK cannot see into (a single large
22
+ query, one slow API call, a blocking call offloaded through
23
+ ``run_in_thread``). The SDK never invents the allowance; a human declares it
24
+ at the one call site that knows, or declares nothing and gets an unbounded
25
+ hold that the duration backstop owns. App code does not pair these two calls
26
+ by hand — :func:`holding_progress` is the public front door, and it is the
27
+ one piece of ADR-0018 an app author is expected to type.
28
+
29
+ Both signals are produced deep inside code that has no reference to the
30
+ tracker — the SDK's transfer loops, a blocking call offloaded through
31
+ ``run_in_thread``, an app's own ``holding_progress`` block. They reach the
32
+ current attempt's tracker through the ContextVar in this module:
33
+ :func:`current_progress_tracker` for consumers, and
34
+ :func:`bind_progress_tracker` for ``activities.py``, which owns one tracker per
35
+ activity attempt.
36
+
37
+ The watchdog that consumes the tracker lives in
38
+ :func:`~application_sdk.execution.heartbeat.auto_heartbeat_loop` and runs in one
39
+ of the three
40
+ :class:`~application_sdk.execution.progress.ProgressWatchdogMode` states — that
41
+ enum stays in the execution layer, since nothing here reads it and it is the
42
+ watchdog's own vocabulary. It is handed the tracker by
43
+ injection rather than reading the ContextVar, so it stays unit-testable without
44
+ an activity. The framework hooks (FND-288), the ``run_in_thread`` auto-holds
45
+ (FND-290) and the warn-mode telemetry that consumes :class:`ClosedHold`
46
+ (FND-292) are separate pieces built on this one; :func:`holding_progress`
47
+ (FND-291) lives here, next to the tracker whose holds it opens.
48
+
49
+ In enforce mode the watchdog's verdict comes back through
50
+ :meth:`ProgressTracker.flag_stalled`, and that :class:`StallObservation` is what
51
+ tells ``activities.py`` that a landing ``asyncio.CancelledError`` is a stall kill
52
+ rather than a worker eviction — the two are otherwise indistinguishable at the
53
+ handler that sees them.
54
+
55
+ **Why this module lives in the substrate** (ADR-0019). Its consumers span every
56
+ layer — ``storage/`` marks progress from its transfer and writer loops,
57
+ ``app/`` binds the tracker, ``execution/`` runs the watchdog — so it has to be
58
+ importable at module scope from all of them, ``storage/`` included. Under
59
+ ``execution/`` it was not: importing any ``execution`` submodule runs that
60
+ package's ``__init__``, which reaches back into ``storage.ops``. The app-facing
61
+ path is still ``application_sdk.execution.progress``, which re-exports every
62
+ name below.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import itertools
68
+ import threading
69
+ import time
70
+ from collections.abc import AsyncIterator, Callable, Iterator
71
+ from contextlib import asynccontextmanager, contextmanager
72
+ from contextvars import ContextVar
73
+ from dataclasses import dataclass
74
+
75
+ from application_sdk._logging import get_logger
76
+
77
+ logger = get_logger(__name__)
78
+
79
+ #: How long a task may go without an observable progress signal, in seconds
80
+ #: (ADR-0018 → *Decisions taken*). Roughly app-independent: it answers "how long
81
+ #: may this attempt be silent?", not "how much data does this tenant have?".
82
+ #:
83
+ #: One number with two uses, deliberately not two numbers. The stall watchdog
84
+ #: takes it as ``max_no_progress_seconds`` — this is the fallback beneath
85
+ #: ``ATLAN_MAX_NO_PROGRESS_SECONDS`` and ``@task(max_no_progress_seconds=...)``,
86
+ #: both resolved in :mod:`application_sdk.execution.progress` — and the warn-mode
87
+ #: hold report takes it as the floor above which a hold is worth naming in the
88
+ #: log: a hold longer than the budget is exactly a hold that *would* have tripped
89
+ #: the watchdog had it not been vouched for, which is the work-list criterion.
90
+ DEFAULT_MAX_NO_PROGRESS_SECONDS = 900.0
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ClosedHold:
95
+ """One completed hold, observed at the moment its token was released.
96
+
97
+ Handed to the ``on_hold_closed`` observer so warn mode can rank hold sites
98
+ without anyone reading code. The shape that needs action is a *long
99
+ unbounded* hold: it is invisible to any other audit precisely because it is
100
+ auto-vouched-for, and it is the site that wants an explicit allowance
101
+ instead of relying on the duration backstop.
102
+
103
+ Attributes:
104
+ label: The label the hold was entered with (a call site, not a value).
105
+ duration_seconds: Wall-clock seconds the operation actually took.
106
+ allowance_seconds: The allowance a human declared, or ``None`` for an
107
+ unbounded hold.
108
+ """
109
+
110
+ label: str
111
+ duration_seconds: float
112
+ allowance_seconds: float | None
113
+
114
+ @property
115
+ def bounded(self) -> bool:
116
+ """Whether an allowance was declared for this hold."""
117
+ return self.allowance_seconds is not None
118
+
119
+ @property
120
+ def lapsed(self) -> bool:
121
+ """Whether the operation outlived its declared allowance.
122
+
123
+ A lapsed hold means the watchdog resumed while the operation was still
124
+ running — the allowance was too tight, or the source really did hang.
125
+ Always ``False`` for an unbounded hold.
126
+ """
127
+ return (
128
+ self.allowance_seconds is not None
129
+ and self.duration_seconds > self.allowance_seconds
130
+ )
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class StallObservation:
135
+ """The no-progress gap the watchdog acted on, frozen at the moment it acted.
136
+
137
+ Written by the activity layer's ``on_stall`` handler and read by the
138
+ cancellation handler that turns the resulting ``CancelledError`` into a
139
+ typed ``TaskStalledError`` (ADR-0018 → *Failing the activity*). Frozen at
140
+ detection time rather than re-derived at the raise site because by then the
141
+ numbers have moved: :meth:`ProgressTracker.stalled_for` keeps growing while
142
+ the cancellation travels to the attempt's next ``await``, and any progress
143
+ signal in flight would re-arm it entirely.
144
+
145
+ Attributes:
146
+ stalled_for_seconds: Seconds without an observable progress signal, as
147
+ observed by the watchdog tick that decided to fail the attempt.
148
+ last_progress_label: The last progress label seen, ``""`` if the attempt
149
+ never reported one.
150
+ """
151
+
152
+ stalled_for_seconds: float
153
+ last_progress_label: str
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class _Hold:
158
+ """An in-flight vouch. ``allowance_seconds=None`` means unbounded."""
159
+
160
+ label: str
161
+ started_at: float
162
+ allowance_seconds: float | None
163
+
164
+ @property
165
+ def deadline(self) -> float | None:
166
+ """Clock reading past which this hold no longer vouches for anything."""
167
+ if self.allowance_seconds is None:
168
+ return None
169
+ return self.started_at + self.allowance_seconds
170
+
171
+
172
+ # Hold tokens are process-wide unique, not per-tracker. A per-tracker counter
173
+ # would let two concurrently bound trackers (a nested attempt, or a test binding
174
+ # its own tracker inside an activity) both consider token 0 valid, so a consumer
175
+ # that paired one tracker's enter_hold with another's exit_hold would silently
176
+ # release the wrong hold. Drawing every token from one shared counter makes a
177
+ # token collide with only its owning tracker, so a cross-tracker exit_hold hits
178
+ # the unknown-token warning instead. `next()` on the counter is a single
179
+ # thread-safe step, so allocation stays correct without taking each tracker's
180
+ # lock.
181
+ _token_counter = itertools.count()
182
+
183
+
184
+ class ProgressTracker:
185
+ """Observed forward progress for one activity attempt.
186
+
187
+ One instance per activity attempt. Cheap: a dict, two floats and an
188
+ uncontended lock.
189
+
190
+ Args:
191
+ clock: Monotonic time source, in seconds. Injected rather than read from
192
+ a patchable global on purpose: an asyncio loop shares
193
+ ``time.monotonic``, so patching it globally in tests makes the loop
194
+ itself misbehave (flaky ``StopIteration`` from an exhausted
195
+ side-effect list). Tests advance their own clock instead.
196
+ on_hold_closed: Called once per :meth:`exit_hold` with the observed
197
+ :class:`ClosedHold`. This is the warn-mode audit seam (FND-292);
198
+ it defaults to no observer. Exceptions raised by the observer are
199
+ logged and swallowed — telemetry must never fail an activity.
200
+ max_no_progress_seconds: The attempt's resolved no-progress allowance,
201
+ carried so code below ``execution/`` can apply the same number
202
+ without importing the resolver. ``None`` when the owner did not
203
+ resolve one. The tracker itself never reads it.
204
+ """
205
+
206
+ def __init__(
207
+ self,
208
+ clock: Callable[[], float] = time.monotonic,
209
+ on_hold_closed: Callable[[ClosedHold], None] | None = None,
210
+ max_no_progress_seconds: float | None = None,
211
+ ) -> None:
212
+ self._clock = clock
213
+ self._on_hold_closed = on_hold_closed
214
+ self.max_no_progress_seconds = max_no_progress_seconds
215
+ self._last_label: str = ""
216
+ self._last_at: float = clock()
217
+ self._holds: dict[int, _Hold] = {}
218
+ self._stall: StallObservation | None = None
219
+ # Mutations are not confined to the event loop: contextvars propagate
220
+ # into `run_in_thread`, so framework hooks and `context.heartbeat()`
221
+ # inside an offloaded blocking call reach this object from a worker
222
+ # thread. Token allocation especially must be serialised — two holds
223
+ # sharing a token would let one release the other's deadline.
224
+ self._lock = threading.Lock()
225
+
226
+ @property
227
+ def last_label(self) -> str:
228
+ """Label of the most recent labelled progress signal, ``""`` if none.
229
+
230
+ Named in the stall log and the stall metric so an operator sees *where*
231
+ the attempt went quiet rather than only *that* it did.
232
+ """
233
+ with self._lock:
234
+ return self._last_label
235
+
236
+ def mark_progress(self, label: str = "") -> None:
237
+ """Record one observable unit of work completing.
238
+
239
+ Args:
240
+ label: What made progress, e.g. ``"write_batch"``. An empty label
241
+ records the progress but leaves :attr:`last_label` alone, so
242
+ warn mode can re-arm the stall clock after reporting a gap
243
+ without erasing the signal it just reported.
244
+ """
245
+ with self._lock:
246
+ now = self._clock()
247
+ self._last_at = max(self._last_at, now)
248
+ if label:
249
+ self._last_label = label
250
+
251
+ def enter_hold(self, label: str, timeout: float | None) -> int:
252
+ """Vouch for an in-flight operation the SDK cannot see into.
253
+
254
+ Args:
255
+ label: The call site being vouched for, e.g.
256
+ ``"snapshot metadata query"``. Carried into the stall log and
257
+ the :class:`ClosedHold` observation, so make it identify a
258
+ site — never interpolate a query, a credential or a customer
259
+ value into it.
260
+ timeout: How long the caller would let this one operation run
261
+ before it would rather it failed — *not* a prediction of how
262
+ long it takes. ``None`` is an unbounded hold: the stall
263
+ watchdog is inactive for the whole call and the duration
264
+ backstop is its only bound (an accepted residual, surfaced by
265
+ warn mode). A negative allowance is a programming error; it is
266
+ logged and treated as already exhausted.
267
+
268
+ Returns:
269
+ An opaque token to pass to :meth:`exit_hold`. Holds are keyed by
270
+ token, never popped off a stack, so concurrent holds in one
271
+ activity — ``asyncio.gather`` over several ``run_in_thread``
272
+ calls — cannot release each other's deadlines. Tokens are drawn
273
+ from a process-wide counter rather than a per-tracker one, so a
274
+ token is unique across every live tracker: a consumer that pairs
275
+ this ``enter_hold`` with a *different* tracker's :meth:`exit_hold`
276
+ (a rebind in between) hits the unknown-token warning instead of
277
+ silently releasing that tracker's hold.
278
+ """
279
+ if timeout is not None and timeout < 0:
280
+ logger.warning(
281
+ "Hold '%s' was entered with a negative allowance (%.3fs); treating "
282
+ "it as already exhausted — the stall watchdog will not pause for it",
283
+ label,
284
+ timeout,
285
+ )
286
+ now = self._clock()
287
+ token = next(_token_counter)
288
+ with self._lock:
289
+ self._holds[token] = _Hold(
290
+ label=label, started_at=now, allowance_seconds=timeout
291
+ )
292
+ return token
293
+
294
+ def exit_hold(self, token: int) -> None:
295
+ """Release the hold ``token`` vouched for.
296
+
297
+ A completed operation *is* progress, so this marks progress under the
298
+ hold's label. It also reports the observed duration to
299
+ ``on_hold_closed``, which is what makes long unbounded holds visible to
300
+ the warn-mode report — without it, blocking work auto-held by
301
+ ``run_in_thread`` would be invisible to the audit precisely because it
302
+ was vouched for.
303
+
304
+ Args:
305
+ token: A token returned by :meth:`enter_hold`. An unknown or
306
+ already-released token means the hold plumbing is broken (stall
307
+ accounting is then wrong in the *lenient* direction); it is
308
+ logged and otherwise ignored.
309
+ """
310
+ with self._lock:
311
+ now = self._clock()
312
+ hold = self._holds.pop(token, None)
313
+ if hold is not None:
314
+ self._last_at = max(self._last_at, now)
315
+ if hold.label:
316
+ self._last_label = hold.label
317
+ if hold is None:
318
+ # The token value is deliberately not logged: "token" reads as a
319
+ # credential to the L010 security scanner, and a per-attempt counter
320
+ # adds nothing an operator can act on.
321
+ logger.warning(
322
+ "exit_hold() found no such hold — it was already released, or the "
323
+ "token came from a different tracker; stall accounting may be lenient"
324
+ )
325
+ return
326
+ self._notify_hold_closed(
327
+ ClosedHold(
328
+ label=hold.label,
329
+ duration_seconds=max(0.0, now - hold.started_at),
330
+ allowance_seconds=hold.allowance_seconds,
331
+ )
332
+ )
333
+
334
+ def has_open_hold(self, token: int) -> bool:
335
+ """Whether ``token`` names a hold that has not been released yet.
336
+
337
+ Deliberately *not* :meth:`held`: this asks whether one specific hold is
338
+ still open, not whether anything is still vouching. A bounded hold whose
339
+ allowance has lapsed answers ``True`` here and stops counting in
340
+ :meth:`held` — which is what the automatic holds need, since a lapsed
341
+ declared hold must keep them suppressed (re-arming an unbounded auto-hold
342
+ at the moment an allowance lapses would defeat the allowance) while the
343
+ watchdog is free to resume.
344
+
345
+ Args:
346
+ token: A token from :meth:`enter_hold`.
347
+
348
+ Returns:
349
+ ``True`` while that hold is open on *this* tracker.
350
+ """
351
+ with self._lock:
352
+ return token in self._holds
353
+
354
+ def held(self) -> bool:
355
+ """Whether any hold is currently vouching for the attempt.
356
+
357
+ An unbounded hold always vouches. A bounded one stops vouching once its
358
+ allowance is spent, even though its token is still open — that is how a
359
+ wedged call inside a hold is eventually caught instead of being excused
360
+ forever.
361
+ """
362
+ now = self._clock()
363
+ with self._lock:
364
+ return any(
365
+ hold.deadline is None or hold.deadline > now
366
+ for hold in self._holds.values()
367
+ )
368
+
369
+ def stalled_for(self) -> float:
370
+ """Seconds since the last observable progress, ``0.0`` while vouched-for.
371
+
372
+ A lapsed bounded hold *resumes* the stall clock from its deadline rather
373
+ than from the last progress signal before it: the allowance vouched for
374
+ everything up to the deadline, so the earliest instant the attempt can
375
+ be accused of stalling is the deadline itself. That is what makes the
376
+ effective kill time for a wedged held call ``allowance + budget``
377
+ (ADR-0018 → *Holds*) instead of firing the moment the allowance lapses.
378
+ """
379
+ now = self._clock()
380
+ with self._lock:
381
+ since = self._last_at
382
+ for hold in self._holds.values():
383
+ deadline = hold.deadline
384
+ if deadline is None or deadline > now:
385
+ return 0.0
386
+ # A hold only vouches forward from where it started, so a
387
+ # non-positive allowance vouches for nothing and must not
388
+ # forgive the quiet that preceded it.
389
+ if deadline > hold.started_at:
390
+ since = max(since, deadline)
391
+ return max(0.0, now - since)
392
+
393
+ @property
394
+ def stall(self) -> StallObservation | None:
395
+ """The gap the watchdog failed this attempt for, ``None`` if it has not.
396
+
397
+ The one thing that tells the activity's ``except CancelledError`` handler
398
+ *whose* cancellation it is holding: a stall kill and a worker eviction
399
+ arrive at the same handler as the same exception type, and only this
400
+ distinguishes them (ADR-0018 → *Failing the activity*). Read it before
401
+ the shutdown check — a stall and a SIGTERM can coincide, and attributing
402
+ such a cancel to eviction would re-dispatch a wedged attempt outside the
403
+ normal retry budget.
404
+ """
405
+ with self._lock:
406
+ return self._stall
407
+
408
+ def flag_stalled(
409
+ self, stalled_for_seconds: float, last_progress_label: str
410
+ ) -> None:
411
+ """Record that the watchdog has judged this attempt stalled.
412
+
413
+ Called by the activity layer's ``on_stall`` handler immediately before it
414
+ cancels the attempt, so the observation is already here when the
415
+ cancellation lands. Not progress, and deliberately not a mutation of the
416
+ stall clock: this records a verdict, it does not change what was
417
+ observed.
418
+
419
+ First flag wins. A second call cannot happen through the watchdog (the
420
+ loop returns as soon as it enforces), and if it ever did, the
421
+ observation that decided to cancel the attempt is the one worth naming in
422
+ the failure.
423
+
424
+ Args:
425
+ stalled_for_seconds: The gap the watchdog observed.
426
+ last_progress_label: The last progress label it saw, ``""`` if none.
427
+ """
428
+ with self._lock:
429
+ if self._stall is None:
430
+ self._stall = StallObservation(
431
+ stalled_for_seconds=stalled_for_seconds,
432
+ last_progress_label=last_progress_label,
433
+ )
434
+
435
+ def _notify_hold_closed(self, closed: ClosedHold) -> None:
436
+ observer = self._on_hold_closed
437
+ if observer is None:
438
+ return
439
+ try:
440
+ observer(closed)
441
+ # conformance: ignore[E004] best-effort audit telemetry; an observer that
442
+ # raises must never fail the activity it is only observing. Logged at
443
+ # WARNING with the traceback because a broken observer means the
444
+ # warn-mode work-list is silently incomplete.
445
+ except Exception:
446
+ logger.warning(
447
+ "Hold-closed observer failed for hold '%s' (%.3fs); the warn-mode "
448
+ "report will be missing this observation",
449
+ closed.label,
450
+ closed.duration_seconds,
451
+ exc_info=True,
452
+ )
453
+
454
+
455
+ class _InertProgressTracker(ProgressTracker):
456
+ """The no-tracker case, as a well-defined no-op.
457
+
458
+ Returned by :func:`current_progress_tracker` when no activity attempt owns
459
+ the current context: a local run, a unit test, an HTTP handler, the SDK's
460
+ own transfer loops called from a script. Progress signals from those callers
461
+ are recorded nowhere and no watchdog observes them, so the honest answer to
462
+ *"is anything vouching for this?"* and *"how long has it been quiet?"* is
463
+ ``False`` and ``0.0`` — not the process's uptime.
464
+
465
+ Existing as a null object rather than letting the accessor return ``None``
466
+ is deliberate: every consumer of the tracker is a paired
467
+ ``enter_hold`` / ``exit_hold`` or a bare ``mark_progress`` buried in a hot
468
+ loop, and a ``None`` check at each of those sites is the exact thing that
469
+ gets half-written. It mirrors ``NoopHeartbeatController``'s role on the
470
+ heartbeat path.
471
+
472
+ The mutators are overridden rather than inherited because this instance is a
473
+ process-wide singleton: inherited ``enter_hold`` would accumulate a hold per
474
+ never-exited token for the life of the process, and inherited
475
+ ``mark_progress`` would take a shared lock on every framework-hook call made
476
+ outside an activity.
477
+ """
478
+
479
+ def mark_progress(self, label: str = "") -> None:
480
+ """Discard the signal — nothing is observing this context."""
481
+
482
+ def enter_hold(self, label: str, timeout: float | None) -> int:
483
+ """Vouch for nothing, and hand back a token no real tracker can own.
484
+
485
+ Real tokens are non-negative, so if a consumer ever pairs this
486
+ ``enter_hold`` with a *real* tracker's ``exit_hold`` — the ContextVar
487
+ having been set in between — the mismatch is logged rather than silently
488
+ releasing somebody else's hold.
489
+ """
490
+ return -1
491
+
492
+ def exit_hold(self, token: int) -> None:
493
+ """Release nothing, and stay quiet: there was no hold to release."""
494
+
495
+ def has_open_hold(self, token: int) -> bool:
496
+ """Always ``False`` — an inert tracker never opened one.
497
+
498
+ Overridden rather than inherited for the same reason the mutators are:
499
+ this instance is a process-wide singleton, and the inherited reader would
500
+ take its shared lock on every offload made outside an activity.
501
+ """
502
+ return False
503
+
504
+ def held(self) -> bool:
505
+ """Always ``False`` — an inert tracker vouches for nothing."""
506
+ return False
507
+
508
+ def stalled_for(self) -> float:
509
+ """Always ``0.0`` — nothing is watching, so nothing is stalled."""
510
+ return 0.0
511
+
512
+ @property
513
+ def stall(self) -> StallObservation | None:
514
+ """Always ``None`` — no watchdog observes this context."""
515
+ return None
516
+
517
+ def flag_stalled(
518
+ self, stalled_for_seconds: float, last_progress_label: str
519
+ ) -> None:
520
+ """Record nothing.
521
+
522
+ Overridden for the same reason the other mutators are: this instance is a
523
+ process-wide singleton, so a verdict recorded on it would outlive its
524
+ caller and make every later cancellation anywhere in the process read as
525
+ a stall kill.
526
+ """
527
+
528
+
529
+ _INERT_TRACKER = _InertProgressTracker()
530
+
531
+ _progress_tracker: ContextVar[ProgressTracker | None] = ContextVar(
532
+ "progress_tracker", default=None
533
+ )
534
+
535
+ #: The declared hold covering the current context, as ``(tracker, token)``.
536
+ #: Set by :func:`holding_progress` for its block's dynamic extent, which is what
537
+ #: makes it answer *"is this work already covered by an allowance a human
538
+ #: declared?"* rather than the tracker-wide *"is anything vouching?"* that
539
+ #: :meth:`ProgressTracker.held` answers. The distinction matters because the
540
+ #: tracker's hold set is flat: it cannot tell an enclosing hold from a concurrent
541
+ #: one, and only the enclosing relationship should suppress anything.
542
+ #:
543
+ #: A *reference to the hold* rather than a boolean, because a boolean can outlive
544
+ #: what it describes. ``asyncio.create_task`` copies the creating context
545
+ #: (PEP 567), so a task spawned inside the block carries the mark into a lifetime
546
+ #: the parent's ``reset`` can never reach — and a *detached* one, still running
547
+ #: after the block exits, would then suppress its own auto-hold with no declared
548
+ #: hold left to cover it. Naming the hold makes the mark falsifiable: the token
549
+ #: is either still open on that tracker or it is not.
550
+ _declared_hold: ContextVar[tuple["ProgressTracker", int] | None] = ContextVar(
551
+ "declared_hold", default=None
552
+ )
553
+
554
+
555
+ def declared_hold_active() -> bool:
556
+ """Whether an explicit declared allowance is *still* covering this context.
557
+
558
+ Read by the automatic holds (FND-290) so they can stand down inside a
559
+ :func:`holding_progress` block. An author who declares an allowance is
560
+ making a statement the SDK must not quietly outvote: an unbounded auto-hold
561
+ added *inside* a bounded one keeps vouching after the declared allowance
562
+ lapses — ``held()`` and ``stalled_for()`` treat the hold set as a union, and
563
+ an unbounded hold never lapses — which would hand a site its author had
564
+ bounded back to the duration backstop.
565
+
566
+ Both halves of the check earn their place:
567
+
568
+ - **The hold is still open** (:meth:`ProgressTracker.has_open_hold`), not
569
+ merely marked. A task spawned inside the block inherits a *copy* of the
570
+ context, so the mark survives in that task after the block exits and
571
+ releases the hold. Trusting the mark alone would let such a task stand
572
+ down with nothing vouching for it at all — no declared hold, and no
573
+ auto-hold either — which is a worse hole than the one this suppression
574
+ closes. Once the declared hold is released, that task's next offload is
575
+ auto-held again like any other.
576
+ - **It is open on the tracker bound right now.** A hold belonging to a
577
+ different attempt cannot govern this one, so a rebind in between must not
578
+ suppress anything.
579
+
580
+ Context-scoped rather than tracker-scoped on purpose. A concurrent task that
581
+ never entered the block does not observe the mark, so its own offload is
582
+ still auto-held; suppressing that one would leave real blocking work
583
+ unvouched, which is the false-kill this whole mechanism exists to prevent.
584
+ """
585
+ declared = _declared_hold.get()
586
+ if declared is None:
587
+ return False
588
+ tracker, token = declared
589
+ return tracker is current_progress_tracker() and tracker.has_open_hold(token)
590
+
591
+
592
+ def current_progress_tracker() -> ProgressTracker:
593
+ """Get the :class:`ProgressTracker` for the current activity attempt.
594
+
595
+ The read seam for every progress producer — the framework hooks, the
596
+ ``run_in_thread`` auto-hold, ``holding_progress()`` — none of which is
597
+ handed the tracker by its caller.
598
+
599
+ Reachable from anywhere inside the attempt, including from a worker thread:
600
+ ``run_in_thread`` propagates the calling context via
601
+ ``contextvars.copy_context()``, so blocking work offloaded through it reads
602
+ the same tracker as the coroutine that offloaded it. Mutations inside that
603
+ thread stay in the thread's copy of the context (copy semantics), but the
604
+ tracker *object* is shared, so progress marked from the thread lands on the
605
+ attempt's tracker.
606
+
607
+ Returns:
608
+ The current attempt's tracker, or an inert one when no attempt owns this
609
+ context (local runs, unit tests, non-activity callers). Never ``None``
610
+ and never raises, so a caller outside an activity behaves exactly as it
611
+ did before this plumbing existed.
612
+
613
+ Note:
614
+ Callers that pair ``enter_hold`` with ``exit_hold`` must hold on to the
615
+ object this returns and call both on it, rather than calling this twice.
616
+ Re-reading picks up whatever tracker the context has by then, which for
617
+ a hold spanning a context change would release the wrong deadline.
618
+ """
619
+ tracker = _progress_tracker.get()
620
+ return _INERT_TRACKER if tracker is None else tracker
621
+
622
+
623
+ @contextmanager
624
+ def bind_progress_tracker(tracker: ProgressTracker) -> Iterator[ProgressTracker]:
625
+ """Bind ``tracker`` as the current attempt's tracker for the block's extent.
626
+
627
+ Entered once per activity attempt by
628
+ ``application_sdk.execution._temporal.activities``, which owns the tracker's
629
+ lifetime. Each activity runs as its own asyncio task with its own copy of
630
+ the context, so concurrent activities in one worker bind their own tracker
631
+ and never observe each other's.
632
+
633
+ A block rather than a ``set``/``reset`` pair, because a bind that outlives
634
+ its attempt is a silent bug of exactly the wrong shape: the next caller in
635
+ that context reports progress into a finished attempt's tracker, and pairing
636
+ an ``enter_hold`` across the boundary releases the wrong deadline. Nothing
637
+ here can leave a binding behind, and there is no token for a caller to
638
+ mislay — the pairing is not something a call site can get wrong.
639
+
640
+ Restores whatever was bound before, so a nested bind — or a test binding its
641
+ own tracker — leaves the caller's binding intact.
642
+
643
+ Args:
644
+ tracker: The tracker for this attempt.
645
+
646
+ Yields:
647
+ The same ``tracker``, so the owner can hold on to it for the pieces that
648
+ take it by injection rather than through the ContextVar (the stall
649
+ watchdog in ``auto_heartbeat_loop``).
650
+ """
651
+ token = _progress_tracker.set(tracker)
652
+ try:
653
+ yield tracker
654
+ finally:
655
+ _progress_tracker.reset(token)
656
+
657
+
658
+ @asynccontextmanager
659
+ async def holding_progress(label: str, *, timeout: float | None) -> AsyncIterator[None]:
660
+ """Vouch for one opaque operation for as long as you would let it run.
661
+
662
+ The explicit half of ADR-0018, and the only part of it an app author types.
663
+ One context manager covers both shapes of opaque work — an ``await`` against
664
+ the connector's own async client, and a blocking call offloaded through
665
+ ``run_in_thread``:
666
+
667
+ .. code-block:: python
668
+
669
+ # async: the connector's own client, which the SDK cannot see into
670
+ async with holding_progress("snapshot metadata query", timeout=1800):
671
+ rows = await long_single_query(...)
672
+
673
+ # blocking: the same wrapper around the offload
674
+ async with holding_progress("full table scan", timeout=7200):
675
+ rows = await run_in_thread(cursor.execute, sql)
676
+
677
+ Inside an ``App`` subclass, reach it as ``self.holding_progress(...)`` or
678
+ ``self.task_context.holding_progress(...)`` — the same two receivers as
679
+ ``run_in_thread``. Import this function directly for app code that sits
680
+ outside the app class.
681
+
682
+ Inside the block the stall watchdog is paused for this attempt; on exit the
683
+ completed operation is recorded as progress under ``label``. Past the
684
+ declared allowance the hold **lapses**: the watchdog resumes from the
685
+ deadline and the stall fires ``max_no_progress_seconds`` later, so the
686
+ effective kill time for a wedged call is ``timeout + budget`` rather than the
687
+ duration backstop's 24h.
688
+
689
+ **The allowance you declare governs everything inside the block**, including
690
+ the automatic holds ``run_in_thread`` and ``run_fault_isolated`` would
691
+ otherwise add (FND-290): those stand down here, so the blocking example below
692
+ lapses at ``timeout`` like the async one rather than inheriting an unbounded
693
+ auto-hold that would outlive it. That is why the two examples have the same
694
+ kill time despite one of them offloading.
695
+
696
+ **Expect to need this in almost every connector.** Blocking calls are
697
+ auto-held because ``run_in_thread`` is a mandatory seam, but async source
698
+ calls have no equivalent SDK-owned seam — a connector talks to its source
699
+ with its *own* async client (async SQLAlchemy, ``httpx.AsyncClient``, a
700
+ vendor SDK), and the SDK's internal SQL/HTTP clients are not on that path.
701
+ Interleaved streaming reads (fetch a page, write a batch, repeat) are
702
+ already covered by the write side and need no hold; the residual this exists
703
+ for is the genuinely opaque *single* call — one large metadata query, one
704
+ slow list/export that returns everything at once. That is a standard part of
705
+ writing a long-running async task, not a rare escape hatch.
706
+
707
+ Args:
708
+ label: The call site being vouched for, e.g.
709
+ ``"snapshot metadata query"``. Named in the stall log and in the
710
+ warn-mode hold report, so it must identify a *site* — never
711
+ interpolate a query, a key, a credential or a customer value into
712
+ it.
713
+ timeout: How long you would let this one operation run before you would
714
+ rather it failed — **not** a prediction of how long it takes. That
715
+ question is answerable here because it is a property of one
716
+ operation against one resource, rather than of a tenant's data
717
+ volume. Keyword-only and required: the SDK never derives or defaults
718
+ this number, so declaring nothing is spelled ``timeout=None``, which
719
+ makes the hold unbounded and hands the whole bound to the duration
720
+ backstop — an accepted residual, and one warn mode reports rather
721
+ than hides.
722
+
723
+ Err generous. The error is asymmetric: too generous only delays
724
+ detection toward the backstop, while too tight kills a healthy run —
725
+ and because stall kills retry, a too-tight allowance burns the same
726
+ wasted work up to three times. Use warn mode's observed p99 for the
727
+ site plus headroom rather than a number invented at the desk.
728
+
729
+ Yields:
730
+ ``None`` — the block's value is the vouch, not an object.
731
+
732
+ Note:
733
+ Outside an activity (a local run, a unit test, a script) this is inert:
734
+ there is no attempt to vouch for, so the hold is recorded nowhere and
735
+ nothing observes it.
736
+
737
+ The hold is released in a ``finally``, so an exception or a cancellation
738
+ inside the block releases it rather than leaving the watchdog paused for
739
+ the rest of the attempt. Holds are keyed by token rather than stacked, so
740
+ nesting and concurrent blocks — ``asyncio.gather`` over several opaque
741
+ calls — cannot release each other's deadlines.
742
+ """
743
+ # Read the tracker once and release on the same object. Re-reading at exit
744
+ # would pick up whatever the context has bound by then, which for a hold
745
+ # spanning a context change releases the wrong deadline.
746
+ tracker = current_progress_tracker()
747
+ token = tracker.enter_hold(label, timeout)
748
+ declared = _declared_hold.set((tracker, token))
749
+ try:
750
+ yield
751
+ finally:
752
+ # Reset first, then release. Both orders are correct for this context —
753
+ # the mark names this token either way — but releasing last keeps the
754
+ # window in which the mark points at a live hold as small as possible for
755
+ # any task still reading a *copy* of this context.
756
+ _declared_hold.reset(declared)
757
+ tracker.exit_hold(token)