atlan-application-sdk-api 3.51.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,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)
|