atlan-application-sdk-api 3.51.2__tar.gz → 3.52.0__tar.gz
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.
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/PKG-INFO +2 -2
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/README.md +1 -1
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_runtime/offload.py +340 -6
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/__init__.py +2 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/asgi.py +21 -7
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/base.py +28 -2
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/context.py +7 -1
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/contracts.py +14 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/metadata_routes.py +82 -20
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/object_routes.py +73 -9
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/version.py +1 -1
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/pyproject.toml +1 -1
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/.gitignore +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/api-files.txt +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_build_info.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_context_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_install.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_logging.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_runtime/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_runtime/progress.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/_interface.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/_sql_failure_classifier.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/base.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/http_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/models.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/sql.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/sql_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/sql_typecasters.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/ssl_utils.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/_generated_tree.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/_listing.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/aws_utils.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/aws_utils_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/build_identity.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/concurrency.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/filter_matching.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/logger.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/models.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/sql_filters.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/sql_filters_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/task_queue.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/types.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/common/utils.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/constants.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/contracts/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/contracts/base.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/contracts/events.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/contracts/types.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/contracts/types_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/extra.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/ingress.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/ref.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/spec.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/types.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/credentials/utils.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/base.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/categories.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/http.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/leaves.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/wire.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/_dispatch.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/_warmup.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/manifest.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/request_contract.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/routes.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/handler/service_errors.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/server/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/server/fastapi/models.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/contracts/__init__.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/contracts/base_metadata_extraction.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/contracts/incremental_sql.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/contracts/sql_metadata.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/templates/contracts/sql_query.py +0 -0
- {atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/hatch_build.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: atlan-application-sdk-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.52.0
|
|
4
4
|
Summary: Experimental: the handler surface of the Atlan Application SDK, for the consolidated API host
|
|
5
5
|
Project-URL: Homepage, https://github.com/atlanhq/application-sdk
|
|
6
6
|
Project-URL: Source, https://github.com/atlanhq/application-sdk/tree/main/packages/api
|
|
@@ -160,7 +160,7 @@ an app answers identically from either one:
|
|
|
160
160
|
| `POST /workflows/v1/metadata` | `Handler.fetch_metadata` |
|
|
161
161
|
| `POST /workflows/v1/warmup` | `Handler.warmup` (optional) |
|
|
162
162
|
| `GET /workflows/v1/configmap/{id}`, `/configmaps`, `/input-contract`, `GET`/`POST /workflows/v1/manifest` | The app's `generated/` folder, if the app package ships one |
|
|
163
|
-
| `GET`/`POST /workflows/v1/config/{id}`, `POST /workflows/v1/file` | An S3 bucket named by `ATLAN_S3_BUCKET` (and `ATLAN_S3_REGION`); needs the `aws` extra. These routes return 503 when
|
|
163
|
+
| `GET`/`POST /workflows/v1/config/{id}`, `POST /workflows/v1/file` | An S3 bucket named by `ATLAN_S3_BUCKET` (and `ATLAN_S3_REGION`), or on GCP by `ATLAN_GCP_BUCKET` (and `ATLAN_GCP_REGION`) through GCS's S3-compatible API with the `ATLAN_GCP_HMAC_ACCESS_KEY` / `ATLAN_GCP_HMAC_SECRET` keys; needs the `aws` extra. These routes return 503 when neither is set |
|
|
164
164
|
| Your own routes | FastAPI routers returned from `Handler.routers()`, served after the SDK's |
|
|
165
165
|
|
|
166
166
|
`build_asgi_app` takes a few keyword options:
|
|
@@ -122,7 +122,7 @@ an app answers identically from either one:
|
|
|
122
122
|
| `POST /workflows/v1/metadata` | `Handler.fetch_metadata` |
|
|
123
123
|
| `POST /workflows/v1/warmup` | `Handler.warmup` (optional) |
|
|
124
124
|
| `GET /workflows/v1/configmap/{id}`, `/configmaps`, `/input-contract`, `GET`/`POST /workflows/v1/manifest` | The app's `generated/` folder, if the app package ships one |
|
|
125
|
-
| `GET`/`POST /workflows/v1/config/{id}`, `POST /workflows/v1/file` | An S3 bucket named by `ATLAN_S3_BUCKET` (and `ATLAN_S3_REGION`); needs the `aws` extra. These routes return 503 when
|
|
125
|
+
| `GET`/`POST /workflows/v1/config/{id}`, `POST /workflows/v1/file` | An S3 bucket named by `ATLAN_S3_BUCKET` (and `ATLAN_S3_REGION`), or on GCP by `ATLAN_GCP_BUCKET` (and `ATLAN_GCP_REGION`) through GCS's S3-compatible API with the `ATLAN_GCP_HMAC_ACCESS_KEY` / `ATLAN_GCP_HMAC_SECRET` keys; needs the `aws` extra. These routes return 503 when neither is set |
|
|
126
126
|
| Your own routes | FastAPI routers returned from `Handler.routers()`, served after the SDK's |
|
|
127
127
|
|
|
128
128
|
`build_asgi_app` takes a few keyword options:
|
|
@@ -39,7 +39,7 @@ from application_sdk._runtime.progress import (
|
|
|
39
39
|
current_progress_tracker,
|
|
40
40
|
declared_hold_active,
|
|
41
41
|
)
|
|
42
|
-
from application_sdk.errors import InvalidInputValueError
|
|
42
|
+
from application_sdk.errors import InvalidInputValueError, ResourceExhaustedError
|
|
43
43
|
|
|
44
44
|
logger = get_logger(__name__)
|
|
45
45
|
|
|
@@ -373,6 +373,314 @@ def _auto_hold(label: str, timeout: float | None) -> Iterator[None]:
|
|
|
373
373
|
tracker.exit_hold(hold)
|
|
374
374
|
|
|
375
375
|
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Per-app containment of the blocking pool (FND-2973)
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
#
|
|
380
|
+
# A timeout frees the *caller* of a ``run_in_thread`` call but cannot stop its
|
|
381
|
+
# thread, so a driver call hung on a dead host keeps its pool slot until the
|
|
382
|
+
# driver returns on its own. On the worker (one app per pod, the process-wide
|
|
383
|
+
# ``_BLOCKING_EXECUTOR``) that damage stays inside the app that caused it. The
|
|
384
|
+
# consolidated API host gives each app its own pool (``blocking_executor``), so
|
|
385
|
+
# one app's hung calls cannot take another app's slots; within that app they
|
|
386
|
+
# can still take every slot and queue the app's other calls behind them.
|
|
387
|
+
#
|
|
388
|
+
# Two pieces contain it:
|
|
389
|
+
#
|
|
390
|
+
# * **A per-app cap**, opt-in through :data:`OFFLOAD_MAX_THREADS_PER_APP_ENV`.
|
|
391
|
+
# A call that would take an app past its cap fails at once with a retryable
|
|
392
|
+
# ``ResourceExhaustedError`` instead of queueing, so a misbehaving app
|
|
393
|
+
# degrades only itself. On the host, keep the cap at or below the app's pool
|
|
394
|
+
# width (``blocking_threads``): above it, hung calls fill the pool first and
|
|
395
|
+
# the app's next calls queue behind them until the cap is reached. Unset (the
|
|
396
|
+
# default), nothing changes: calls queue
|
|
397
|
+
# exactly as before. A default cap would turn today's queueing into failures
|
|
398
|
+
# for any fan-out wider than the cap, and the pool width it would be derived
|
|
399
|
+
# from is not a reliable number — ``os.cpu_count()`` reports the node's
|
|
400
|
+
# cores, not the pod's CPU limit.
|
|
401
|
+
# * **A per-app stranded count**: calls whose caller stopped waiting while the
|
|
402
|
+
# thread kept running. Exported as the ``offload.stranded_threads`` gauge,
|
|
403
|
+
# labelled by app only, and logged once per strand with the callable's name.
|
|
404
|
+
#
|
|
405
|
+
# A slot is held from submit until the *thread* finishes — released in the
|
|
406
|
+
# executor future's done callback, never when the awaiting coroutine returns.
|
|
407
|
+
# Releasing on the caller's side would let a hung thread hand back its quota
|
|
408
|
+
# while it still holds its pool slot, which is the one thing the cap exists to
|
|
409
|
+
# prevent. The done callback also fires for a queued call cancelled before it
|
|
410
|
+
# started, so that case does not leak a slot either.
|
|
411
|
+
|
|
412
|
+
#: Env var capping how many ``run_in_thread`` calls one app may have in flight
|
|
413
|
+
#: (running or queued) at once. Unset or empty means no cap.
|
|
414
|
+
OFFLOAD_MAX_THREADS_PER_APP_ENV = "ATLAN_OFFLOAD_MAX_THREADS_PER_APP"
|
|
415
|
+
|
|
416
|
+
#: The app a ``run_in_thread`` call is charged to. Bound by the handler layer
|
|
417
|
+
#: (``bind_handler_context``) for each handler invocation, and on the
|
|
418
|
+
#: consolidated host for every request to an app (``handler.asgi``); otherwise
|
|
419
|
+
#: the process's own ``ATLAN_APPLICATION_NAME``. On the host that fallback is
|
|
420
|
+
#: the host's name, which is correct for what reaches it: startup and background
|
|
421
|
+
#: work that belongs to no single app.
|
|
422
|
+
_OFFLOAD_OWNER: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
423
|
+
"_OFFLOAD_OWNER", default=None
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
#: Read at call time, not through ``application_sdk.constants``: that module
|
|
427
|
+
#: loads ``.env`` and is not part of the api distribution's core.
|
|
428
|
+
_APPLICATION_NAME_ENV = "ATLAN_APPLICATION_NAME"
|
|
429
|
+
|
|
430
|
+
#: Owner used when neither a binding nor ``ATLAN_APPLICATION_NAME`` names the app.
|
|
431
|
+
_UNKNOWN_OWNER = "<unknown>"
|
|
432
|
+
|
|
433
|
+
#: Cap values already reported as invalid, so a bad setting warns once.
|
|
434
|
+
_REPORTED_BAD_CAPS: set[str] = set()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@contextlib.contextmanager
|
|
438
|
+
def offload_owner(app_name: str) -> Iterator[None]:
|
|
439
|
+
"""Charge every ``run_in_thread`` call made in this block to *app_name*.
|
|
440
|
+
|
|
441
|
+
The binding is a ContextVar, so it is per task and is inherited by tasks
|
|
442
|
+
created inside the block. An empty *app_name* leaves the fallback in place.
|
|
443
|
+
"""
|
|
444
|
+
token = _OFFLOAD_OWNER.set(app_name or None)
|
|
445
|
+
try:
|
|
446
|
+
yield
|
|
447
|
+
finally:
|
|
448
|
+
_OFFLOAD_OWNER.reset(token)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _current_offload_owner() -> str:
|
|
452
|
+
"""The app the current ``run_in_thread`` call is charged to."""
|
|
453
|
+
bound = _OFFLOAD_OWNER.get()
|
|
454
|
+
if bound:
|
|
455
|
+
return bound
|
|
456
|
+
return os.environ.get(_APPLICATION_NAME_ENV, "").strip() or _UNKNOWN_OWNER
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _per_app_cap() -> int | None:
|
|
460
|
+
"""The configured per-app cap, or ``None`` when no cap applies.
|
|
461
|
+
|
|
462
|
+
Read on every call so a deployment can change it without a code path that
|
|
463
|
+
caches a stale value. An unparseable or non-positive value is logged once
|
|
464
|
+
and ignored rather than raised: raising here would fail every offload in
|
|
465
|
+
the process over a configuration typo.
|
|
466
|
+
"""
|
|
467
|
+
raw = os.environ.get(OFFLOAD_MAX_THREADS_PER_APP_ENV, "").strip()
|
|
468
|
+
if not raw:
|
|
469
|
+
return None
|
|
470
|
+
try:
|
|
471
|
+
cap = int(raw)
|
|
472
|
+
except ValueError:
|
|
473
|
+
cap = 0
|
|
474
|
+
if cap >= 1:
|
|
475
|
+
return cap
|
|
476
|
+
if raw not in _REPORTED_BAD_CAPS:
|
|
477
|
+
_REPORTED_BAD_CAPS.add(raw)
|
|
478
|
+
logger.warning(
|
|
479
|
+
"Ignoring %s=%r: expected a positive integer; offloads are not capped "
|
|
480
|
+
"per app",
|
|
481
|
+
OFFLOAD_MAX_THREADS_PER_APP_ENV,
|
|
482
|
+
raw,
|
|
483
|
+
)
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class _OffloadSlot:
|
|
488
|
+
"""One ``run_in_thread`` call's claim on its app's share of the pool."""
|
|
489
|
+
|
|
490
|
+
__slots__ = ("owner", "label", "stranded_at", "finished")
|
|
491
|
+
|
|
492
|
+
def __init__(self, owner: str, label: str) -> None:
|
|
493
|
+
self.owner = owner
|
|
494
|
+
self.label = label
|
|
495
|
+
self.stranded_at: float | None = None
|
|
496
|
+
self.finished = False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class _AppOffloadLedger:
|
|
500
|
+
"""Per-app in-flight and stranded counts for the blocking pool. Thread-safe.
|
|
501
|
+
|
|
502
|
+
Mutated from the event loop (claim, strand) and from pool threads (the
|
|
503
|
+
executor future's done callback), hence a ``threading.Lock`` rather than
|
|
504
|
+
anything asyncio-side.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def __init__(self) -> None:
|
|
508
|
+
self._lock = threading.Lock()
|
|
509
|
+
self._in_flight: dict[str, int] = {}
|
|
510
|
+
self._stranded: dict[str, int] = {}
|
|
511
|
+
|
|
512
|
+
def claim(self, owner: str, label: str, cap: int | None) -> _OffloadSlot | None:
|
|
513
|
+
"""Take a slot for *owner*; ``None`` if that would exceed *cap*."""
|
|
514
|
+
with self._lock:
|
|
515
|
+
held = self._in_flight.get(owner, 0)
|
|
516
|
+
if cap is not None and held >= cap:
|
|
517
|
+
return None
|
|
518
|
+
self._in_flight[owner] = held + 1
|
|
519
|
+
return _OffloadSlot(owner, label)
|
|
520
|
+
|
|
521
|
+
def strand(self, slot: _OffloadSlot) -> int | None:
|
|
522
|
+
"""Record that *slot*'s caller stopped waiting while its thread runs on.
|
|
523
|
+
|
|
524
|
+
Returns the owner's stranded count after this one, or ``None`` if the
|
|
525
|
+
thread had already finished (nothing is stranded).
|
|
526
|
+
"""
|
|
527
|
+
with self._lock:
|
|
528
|
+
if slot.finished or slot.stranded_at is not None:
|
|
529
|
+
return None
|
|
530
|
+
slot.stranded_at = time.monotonic()
|
|
531
|
+
count = self._stranded.get(slot.owner, 0) + 1
|
|
532
|
+
self._stranded[slot.owner] = count
|
|
533
|
+
return count
|
|
534
|
+
|
|
535
|
+
def release(self, slot: _OffloadSlot) -> float | None:
|
|
536
|
+
"""Give *slot* back once its thread is done.
|
|
537
|
+
|
|
538
|
+
Returns how long the thread ran after being stranded, or ``None`` if it
|
|
539
|
+
was never stranded.
|
|
540
|
+
"""
|
|
541
|
+
with self._lock:
|
|
542
|
+
if slot.finished:
|
|
543
|
+
return None
|
|
544
|
+
slot.finished = True
|
|
545
|
+
self._in_flight[slot.owner] = self._in_flight.get(slot.owner, 1) - 1
|
|
546
|
+
if self._in_flight[slot.owner] <= 0:
|
|
547
|
+
del self._in_flight[slot.owner]
|
|
548
|
+
if slot.stranded_at is None:
|
|
549
|
+
return None
|
|
550
|
+
self._stranded[slot.owner] = self._stranded.get(slot.owner, 1) - 1
|
|
551
|
+
if self._stranded[slot.owner] <= 0:
|
|
552
|
+
del self._stranded[slot.owner]
|
|
553
|
+
return time.monotonic() - slot.stranded_at
|
|
554
|
+
|
|
555
|
+
def in_flight(self, owner: str) -> int:
|
|
556
|
+
with self._lock:
|
|
557
|
+
return self._in_flight.get(owner, 0)
|
|
558
|
+
|
|
559
|
+
def stranded(self) -> dict[str, int]:
|
|
560
|
+
"""A snapshot of every app's stranded count, omitting apps at zero."""
|
|
561
|
+
with self._lock:
|
|
562
|
+
return dict(self._stranded)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
_LEDGER = _AppOffloadLedger()
|
|
566
|
+
|
|
567
|
+
#: Lazily-created OTel instruments, keyed by name.
|
|
568
|
+
_INSTRUMENTS: dict[str, Any] = {}
|
|
569
|
+
_INSTRUMENTS_LOCK = threading.Lock()
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _observe_stranded(_options: Any) -> Iterator[Any]:
|
|
573
|
+
try:
|
|
574
|
+
from opentelemetry.metrics import ( # noqa: PLC0415 — only reached from the OTel collection callback
|
|
575
|
+
Observation,
|
|
576
|
+
)
|
|
577
|
+
except ImportError:
|
|
578
|
+
# Absent on an api-only install; the gauge is never registered there.
|
|
579
|
+
return
|
|
580
|
+
for owner, count in _LEDGER.stranded().items():
|
|
581
|
+
yield Observation(count, {"app.name": owner})
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _ensure_stranded_gauge() -> None:
|
|
585
|
+
"""Register the ``offload.stranded_threads`` gauge, once, on the first strand.
|
|
586
|
+
|
|
587
|
+
Observable rather than pushed: the count only matters while it is non-zero,
|
|
588
|
+
and a callback reads the ledger at collection time instead of every strand
|
|
589
|
+
and release writing a sample. Never raises — telemetry must not turn a
|
|
590
|
+
cancellation into a different failure.
|
|
591
|
+
"""
|
|
592
|
+
if "stranded" in _INSTRUMENTS:
|
|
593
|
+
return
|
|
594
|
+
with _INSTRUMENTS_LOCK:
|
|
595
|
+
if "stranded" in _INSTRUMENTS:
|
|
596
|
+
return
|
|
597
|
+
try:
|
|
598
|
+
from opentelemetry import ( # noqa: PLC0415 — cold path: only reached on the first strand
|
|
599
|
+
metrics as _otel_metrics,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
_INSTRUMENTS["stranded"] = _otel_metrics.get_meter(
|
|
603
|
+
"application_sdk.runtime"
|
|
604
|
+
).create_observable_gauge(
|
|
605
|
+
"offload.stranded_threads",
|
|
606
|
+
callbacks=[_observe_stranded],
|
|
607
|
+
unit="1",
|
|
608
|
+
description=(
|
|
609
|
+
"run_in_thread calls whose caller stopped waiting (timeout or "
|
|
610
|
+
"cancellation) while the thread is still running and holding a "
|
|
611
|
+
"blocking-pool slot. Labelled by app; a sustained non-zero value "
|
|
612
|
+
"names the app whose blocking calls ignore their own deadlines."
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
except ImportError:
|
|
616
|
+
# An api-only install has no OpenTelemetry: the strand is still
|
|
617
|
+
# logged, there is just no gauge.
|
|
618
|
+
_INSTRUMENTS["stranded"] = None
|
|
619
|
+
except Exception:
|
|
620
|
+
_INSTRUMENTS["stranded"] = None
|
|
621
|
+
logger.debug("Could not register offload.stranded_threads", exc_info=True)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _on_offload_done(slot: _OffloadSlot) -> None:
|
|
625
|
+
"""Executor done callback: release *slot*, reporting a stranded thread's end."""
|
|
626
|
+
stranded_for = _LEDGER.release(slot)
|
|
627
|
+
if stranded_for is not None:
|
|
628
|
+
logger.info(
|
|
629
|
+
"Stranded offloaded call %s for app %s finished %.1fs after its caller "
|
|
630
|
+
"stopped waiting; its blocking-pool slot is free again",
|
|
631
|
+
slot.label,
|
|
632
|
+
slot.owner,
|
|
633
|
+
stranded_for,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _abandon(slot: _OffloadSlot, future: "concurrent.futures.Future[Any]") -> None:
|
|
638
|
+
"""The caller of *future* stopped waiting: cancel it if queued, else record a strand."""
|
|
639
|
+
if future.cancel():
|
|
640
|
+
# Still queued: it will never start, and the done callback has already
|
|
641
|
+
# given the slot back.
|
|
642
|
+
return
|
|
643
|
+
if future.done():
|
|
644
|
+
return
|
|
645
|
+
count = _LEDGER.strand(slot)
|
|
646
|
+
if count is None:
|
|
647
|
+
return
|
|
648
|
+
_ensure_stranded_gauge()
|
|
649
|
+
logger.warning(
|
|
650
|
+
"Offloaded call %s for app %s outlived its caller; the thread keeps a "
|
|
651
|
+
"blocking-pool slot until the call returns on its own (%d stranded for "
|
|
652
|
+
"this app). Give the blocking call a driver-level timeout so it returns "
|
|
653
|
+
"when its caller gives up",
|
|
654
|
+
slot.label,
|
|
655
|
+
slot.owner,
|
|
656
|
+
count,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _claim_slot(label: str) -> _OffloadSlot:
|
|
661
|
+
"""Claim a slot for the current app, or raise if that app is at its cap."""
|
|
662
|
+
owner = _current_offload_owner()
|
|
663
|
+
cap = _per_app_cap()
|
|
664
|
+
slot = _LEDGER.claim(owner, label, cap)
|
|
665
|
+
if slot is not None:
|
|
666
|
+
return slot
|
|
667
|
+
stranded = _LEDGER.stranded().get(owner, 0)
|
|
668
|
+
raise ResourceExhaustedError(
|
|
669
|
+
message=(
|
|
670
|
+
f"App {owner!r} already has {cap} blocking calls in flight "
|
|
671
|
+
f"({stranded} stranded past their caller); refusing {label} rather "
|
|
672
|
+
"than queueing it behind them"
|
|
673
|
+
),
|
|
674
|
+
resource="offload_threads",
|
|
675
|
+
limit=str(cap),
|
|
676
|
+
observed=str(_LEDGER.in_flight(owner)),
|
|
677
|
+
suggested_action=(
|
|
678
|
+
"Give this app's blocking calls driver-level timeouts so a hung call "
|
|
679
|
+
f"returns its slot, or raise {OFFLOAD_MAX_THREADS_PER_APP_ENV}."
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
376
684
|
async def run_in_thread(
|
|
377
685
|
func: Callable[..., T],
|
|
378
686
|
*args: Any,
|
|
@@ -429,6 +737,18 @@ async def run_in_thread(
|
|
|
429
737
|
isolated from the caller (copy semantics).
|
|
430
738
|
- Threads run on a dedicated ``sdk-blocking-*`` pool, separate from
|
|
431
739
|
Temporal's activity pool, to avoid deadlocking the worker.
|
|
740
|
+
- Each call is charged to an app: the handler invocation's app when one is
|
|
741
|
+
bound (see :func:`offload_owner`), else the process's
|
|
742
|
+
``ATLAN_APPLICATION_NAME``. When ``ATLAN_OFFLOAD_MAX_THREADS_PER_APP`` is set,
|
|
743
|
+
a call that would take its app past that many calls in flight raises a
|
|
744
|
+
retryable ``ResourceExhaustedError`` at once instead of queueing. Unset,
|
|
745
|
+
calls queue as they always have. A call counts against its app until its
|
|
746
|
+
**thread** finishes, not until its caller stops waiting.
|
|
747
|
+
- A call whose caller stops waiting (timeout, cancellation) while its
|
|
748
|
+
thread runs on is *stranded*: logged at WARNING with the callable's name
|
|
749
|
+
and counted per app on the ``offload.stranded_threads`` gauge until the
|
|
750
|
+
thread returns. A call still queued when its caller stops waiting is
|
|
751
|
+
cancelled and never starts.
|
|
432
752
|
- The offload is automatically wrapped in an **unbounded** progress hold
|
|
433
753
|
(ADR-0018), so the stall watchdog never accuses a legitimately long
|
|
434
754
|
blocking call of stalling. Nothing to do at the call site, and nothing
|
|
@@ -496,15 +816,29 @@ async def run_in_thread(
|
|
|
496
816
|
if scopes:
|
|
497
817
|
call = _tracked_offload(call, scopes, label)
|
|
498
818
|
cancel_handle = _bound_handle(func)
|
|
819
|
+
# The app's own pool on the consolidated host (``blocking_executor``), else
|
|
820
|
+
# the process-wide one.
|
|
499
821
|
executor = _BLOCKING_EXECUTOR_OVERRIDE.get() or _BLOCKING_EXECUTOR
|
|
822
|
+
# Claimed before the hold opens: an app at its cap fails here at once, with
|
|
823
|
+
# nothing to unwind (FND-2973).
|
|
824
|
+
slot = _claim_slot(label)
|
|
500
825
|
with _auto_hold(_THREAD_HOLD_PREFIX + label, None):
|
|
501
|
-
if cancel_handle is None:
|
|
502
|
-
return await loop.run_in_executor(executor, call)
|
|
503
826
|
try:
|
|
504
|
-
|
|
827
|
+
future = executor.submit(call)
|
|
828
|
+
except BaseException:
|
|
829
|
+
# Never submitted (pool shut down at interpreter exit): no thread
|
|
830
|
+
# will ever release the slot, so release it here.
|
|
831
|
+
_LEDGER.release(slot)
|
|
832
|
+
raise
|
|
833
|
+
# Released when the *thread* is done — see the containment notes above.
|
|
834
|
+
future.add_done_callback(lambda _done: _on_offload_done(slot))
|
|
835
|
+
try:
|
|
836
|
+
return await asyncio.wrap_future(future, loop=loop)
|
|
505
837
|
except asyncio.CancelledError:
|
|
506
|
-
|
|
507
|
-
|
|
838
|
+
if cancel_handle is not None:
|
|
839
|
+
# Non-blocking: the action is handed to the cancel pool.
|
|
840
|
+
cancel_handle.request()
|
|
841
|
+
_abandon(slot, future)
|
|
508
842
|
raise
|
|
509
843
|
|
|
510
844
|
|
|
@@ -20,6 +20,7 @@ from application_sdk.handler.contracts import (
|
|
|
20
20
|
BaseMetadataConfig,
|
|
21
21
|
CheckTier,
|
|
22
22
|
HandlerCredential,
|
|
23
|
+
ManifestInput,
|
|
23
24
|
MetadataInput,
|
|
24
25
|
MetadataOutput,
|
|
25
26
|
PreflightCheck,
|
|
@@ -69,6 +70,7 @@ __all__ = [
|
|
|
69
70
|
"HandlerContext",
|
|
70
71
|
"HandlerCredential",
|
|
71
72
|
"HandlerError",
|
|
73
|
+
"ManifestInput",
|
|
72
74
|
"MetadataInput",
|
|
73
75
|
"MetadataOutput",
|
|
74
76
|
"PreflightCheck",
|
|
@@ -15,7 +15,8 @@ shared by every app in the process; handler code is async and offloads through
|
|
|
15
15
|
|
|
16
16
|
The metadata routes (configmaps, manifest, input contract) are served from the
|
|
17
17
|
``generated/`` folder ``gen_app_api.py`` ships inside the app's package
|
|
18
|
-
(:mod:`application_sdk.handler.metadata_routes`)
|
|
18
|
+
(:mod:`application_sdk.handler.metadata_routes`), through the handler's
|
|
19
|
+
``compute_manifest`` when it overrides that. The served manifest's task
|
|
19
20
|
queue is the one the app's worker polls: its ``ATLAN_TASK_QUEUE`` override when
|
|
20
21
|
the host's app-env sync carries one (``ATLAN_APPENV__<APP>__ATLAN_TASK_QUEUE``),
|
|
21
22
|
else ``derive_task_queue`` over the app's name and its deployment name, read as
|
|
@@ -36,12 +37,13 @@ from typing import TYPE_CHECKING, Literal
|
|
|
36
37
|
from fastapi import FastAPI
|
|
37
38
|
|
|
38
39
|
from application_sdk._logging import get_logger
|
|
39
|
-
from application_sdk._runtime.offload import blocking_executor
|
|
40
|
+
from application_sdk._runtime.offload import blocking_executor, offload_owner
|
|
40
41
|
from application_sdk.common.task_queue import derive_task_queue
|
|
41
42
|
from application_sdk.handler.base import Handler
|
|
42
43
|
from application_sdk.handler.metadata_routes import (
|
|
43
44
|
MetadataSource,
|
|
44
45
|
generated_dir_of,
|
|
46
|
+
handler_compute_manifest,
|
|
45
47
|
register_metadata_routes,
|
|
46
48
|
)
|
|
47
49
|
from application_sdk.handler.object_routes import (
|
|
@@ -83,7 +85,7 @@ def app_env(app_name: str, key: str, default: str = "") -> str:
|
|
|
83
85
|
|
|
84
86
|
@functools.cache
|
|
85
87
|
def default_object_store() -> S3ObjectStore | None:
|
|
86
|
-
"""The host's object store, built once
|
|
88
|
+
"""The host's object store, built once by :meth:`S3ObjectStore.from_env`.
|
|
87
89
|
|
|
88
90
|
One bucket and one client for every hosted app: each app's configs live
|
|
89
91
|
under its own ``persistent-artifacts/apps/<app>/`` prefix in that bucket.
|
|
@@ -173,14 +175,25 @@ def _package_generated_dir(app_package: str) -> Path | None:
|
|
|
173
175
|
|
|
174
176
|
|
|
175
177
|
class _AppBlockingExecutor:
|
|
176
|
-
"""ASGI middleware: ``run_in_thread`` in this app's requests uses its pool.
|
|
178
|
+
"""ASGI middleware: ``run_in_thread`` in this app's requests uses its pool.
|
|
177
179
|
|
|
178
|
-
|
|
180
|
+
Every request is also charged to ``app_name`` for the per-app offload cap and
|
|
181
|
+
the stranded-thread count, including the metadata, config and file routes,
|
|
182
|
+
which run outside a handler context.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
app: ASGIApp,
|
|
188
|
+
executor: concurrent.futures.Executor,
|
|
189
|
+
app_name: str,
|
|
190
|
+
) -> None:
|
|
179
191
|
self.app = app
|
|
180
192
|
self.executor = executor
|
|
193
|
+
self.app_name = app_name
|
|
181
194
|
|
|
182
195
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
183
|
-
with blocking_executor(self.executor):
|
|
196
|
+
with blocking_executor(self.executor), offload_owner(self.app_name):
|
|
184
197
|
await self.app(scope, receive, send)
|
|
185
198
|
|
|
186
199
|
|
|
@@ -210,7 +223,7 @@ def build_asgi_app(
|
|
|
210
223
|
max_workers=blocking_threads, thread_name_prefix=f"api-{app_name}-"
|
|
211
224
|
)
|
|
212
225
|
app.state.blocking_executor = executor
|
|
213
|
-
app.add_middleware(_AppBlockingExecutor, executor=executor)
|
|
226
|
+
app.add_middleware(_AppBlockingExecutor, executor=executor, app_name=app_name)
|
|
214
227
|
|
|
215
228
|
@app.get("/health")
|
|
216
229
|
async def health() -> dict[str, str]:
|
|
@@ -242,6 +255,7 @@ def build_asgi_app(
|
|
|
242
255
|
app_package=app_package,
|
|
243
256
|
task_queue=lambda: _task_queue(app_name, _deployment),
|
|
244
257
|
deployment_name=_deployment,
|
|
258
|
+
compute_manifest=handler_compute_manifest(handler, lambda: app_name),
|
|
245
259
|
from_process_env=False,
|
|
246
260
|
),
|
|
247
261
|
)
|
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import warnings
|
|
13
13
|
from abc import ABC, abstractmethod
|
|
14
14
|
from collections.abc import Sequence
|
|
15
|
-
from typing import TYPE_CHECKING, ClassVar
|
|
15
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
16
16
|
|
|
17
17
|
from application_sdk._context_errors import AppContextError
|
|
18
18
|
from application_sdk.errors import HANDLER_ERROR, ErrorCode
|
|
@@ -22,6 +22,7 @@ from application_sdk.handler.contracts import (
|
|
|
22
22
|
AuthInput,
|
|
23
23
|
AuthOutput,
|
|
24
24
|
AuthStatus,
|
|
25
|
+
ManifestInput,
|
|
25
26
|
MetadataInput,
|
|
26
27
|
MetadataOutput,
|
|
27
28
|
PreflightInput,
|
|
@@ -126,7 +127,8 @@ class Handler(ABC):
|
|
|
126
127
|
raise AppContextError(
|
|
127
128
|
"Handler context is not set. "
|
|
128
129
|
"Access self.context only inside a handler method "
|
|
129
|
-
"(test_auth, preflight_check, fetch_metadata, warmup
|
|
130
|
+
"(test_auth, preflight_check, fetch_metadata, warmup, "
|
|
131
|
+
"compute_manifest)."
|
|
130
132
|
)
|
|
131
133
|
return ctx
|
|
132
134
|
|
|
@@ -256,6 +258,30 @@ class Handler(ABC):
|
|
|
256
258
|
"""
|
|
257
259
|
return WarmupObservation(state=WarmupState.READY)
|
|
258
260
|
|
|
261
|
+
async def compute_manifest(self, input: ManifestInput) -> dict[str, Any] | None:
|
|
262
|
+
"""The manifest to serve for this request, when it cannot be static.
|
|
263
|
+
|
|
264
|
+
Optional. The default returns ``None``, which serves the generated
|
|
265
|
+
manifest unchanged. Override it for a manifest that depends on the
|
|
266
|
+
request's ``fe_inputs`` or on the tenant; return ``None`` for an entry
|
|
267
|
+
point whose generated manifest is right as it is.
|
|
268
|
+
|
|
269
|
+
The worker's handler service and the consolidated API host both call
|
|
270
|
+
it for a request that names an entry point. Task-queue templates in the
|
|
271
|
+
result (``atlan-<app>-{deployment_name}``) are stamped with the queue
|
|
272
|
+
the app's worker polls, as in a generated manifest: build them from
|
|
273
|
+
``self.context.app_name``. A request carries no credentials here. On
|
|
274
|
+
the worker an ``app.<segment>.core.compute_manifest`` hook, where one
|
|
275
|
+
exists, wins; the host never runs one.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
input: The entry point, its generated manifest and the form values.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The manifest to serve, or ``None`` for the generated one.
|
|
282
|
+
"""
|
|
283
|
+
return None
|
|
284
|
+
|
|
259
285
|
|
|
260
286
|
class DefaultHandler(Handler):
|
|
261
287
|
"""Pass-through handler that always returns SUCCESS/READY/empty.
|
|
@@ -21,6 +21,7 @@ from application_sdk._context_errors import ( # noqa: F401 — AppContextError
|
|
|
21
21
|
)
|
|
22
22
|
from application_sdk._install import worker_only_missing
|
|
23
23
|
from application_sdk._logging import get_logger
|
|
24
|
+
from application_sdk._runtime.offload import offload_owner
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from application_sdk.handler.contracts import HandlerCredential
|
|
@@ -172,10 +173,15 @@ def bind_handler_context(ctx: HandlerContext) -> Iterator[HandlerContext]:
|
|
|
172
173
|
(FastAPI requests, Temporal SDR activities) cannot overwrite each other's
|
|
173
174
|
context. Each asyncio Task gets its own copy of the ContextVar namespace,
|
|
174
175
|
so token-based reset is both safe and strictly scoped to the current task.
|
|
176
|
+
|
|
177
|
+
Blocking calls offloaded inside the block are charged to ``ctx.app_name``
|
|
178
|
+
(FND-2973), so on a pod serving several apps each app's share of the
|
|
179
|
+
blocking pool is counted and capped separately.
|
|
175
180
|
"""
|
|
176
181
|
token = _current_handler_context.set(ctx)
|
|
177
182
|
try:
|
|
178
|
-
|
|
183
|
+
with offload_owner(ctx.app_name):
|
|
184
|
+
yield ctx
|
|
179
185
|
finally:
|
|
180
186
|
_current_handler_context.reset(token)
|
|
181
187
|
|
|
@@ -768,6 +768,20 @@ class WarmupInput(_SourceRequestInput):
|
|
|
768
768
|
one that does not, and reads the overrun as ``WARMING``."""
|
|
769
769
|
|
|
770
770
|
|
|
771
|
+
class ManifestInput(BaseModel):
|
|
772
|
+
"""Input for the optional ``Handler.compute_manifest`` operation."""
|
|
773
|
+
|
|
774
|
+
entrypoint: str
|
|
775
|
+
"""The entry point whose manifest is requested."""
|
|
776
|
+
|
|
777
|
+
manifest: dict[str, Any]
|
|
778
|
+
"""Its generated manifest, tokens already resolved. Open on purpose: the
|
|
779
|
+
DAG is a passthrough contract with Automation Engine."""
|
|
780
|
+
|
|
781
|
+
fe_inputs: dict[str, Any] = Field(default_factory=dict)
|
|
782
|
+
"""The decoded setup-form values; empty when the request sends none."""
|
|
783
|
+
|
|
784
|
+
|
|
771
785
|
class PreflightOutput(BaseModel):
|
|
772
786
|
"""Output from the preflight_check handler operation."""
|
|
773
787
|
|
|
@@ -12,11 +12,15 @@ The two surfaces differ only in what a :class:`MetadataSource` resolves:
|
|
|
12
12
|
|
|
13
13
|
* the worker resolves entry points from its app registry, stamps the manifest
|
|
14
14
|
with the task queue its own worker polls, and runs an app's
|
|
15
|
-
``compute_manifest`` hooks;
|
|
15
|
+
``app.<segment>.core.compute_manifest`` hooks;
|
|
16
16
|
* the host has no app registry. It resolves entry points from the generated
|
|
17
|
-
tree
|
|
18
|
-
worker does
|
|
19
|
-
refuses an app with
|
|
17
|
+
tree and derives the task queue from the app's name and deployment exactly as
|
|
18
|
+
the worker does. It never runs a ``core.py`` hook (``gen_app_api.py check``
|
|
19
|
+
refuses an app with one).
|
|
20
|
+
|
|
21
|
+
Both run :meth:`~application_sdk.handler.base.Handler.compute_manifest` when the
|
|
22
|
+
handler overrides it (:func:`handler_compute_manifest`), so a manifest that must
|
|
23
|
+
be computed per request is served the same from either place.
|
|
20
24
|
"""
|
|
21
25
|
|
|
22
26
|
from __future__ import annotations
|
|
@@ -28,6 +32,7 @@ from collections.abc import Awaitable, Callable
|
|
|
28
32
|
from dataclasses import dataclass, field
|
|
29
33
|
from pathlib import Path
|
|
30
34
|
from typing import Any, cast
|
|
35
|
+
from uuid import uuid4
|
|
31
36
|
|
|
32
37
|
import orjson
|
|
33
38
|
from fastapi import FastAPI, HTTPException, Request
|
|
@@ -45,8 +50,17 @@ from application_sdk.common.build_identity import (
|
|
|
45
50
|
build_identity,
|
|
46
51
|
)
|
|
47
52
|
from application_sdk.common.task_queue import resolve_manifest_tokens
|
|
53
|
+
from application_sdk.errors.base import redact_secrets
|
|
54
|
+
from application_sdk.handler.base import Handler
|
|
55
|
+
from application_sdk.handler.context import (
|
|
56
|
+
HandlerContext,
|
|
57
|
+
SecretStore,
|
|
58
|
+
bind_handler_context,
|
|
59
|
+
)
|
|
60
|
+
from application_sdk.handler.contracts import ManifestInput
|
|
48
61
|
from application_sdk.handler.routes import (
|
|
49
62
|
_ENTRYPOINT_NAME_RE,
|
|
63
|
+
_http_error_boundary,
|
|
50
64
|
_wrap_response,
|
|
51
65
|
entrypoint_module_segment,
|
|
52
66
|
)
|
|
@@ -207,7 +221,7 @@ class MetadataSource:
|
|
|
207
221
|
input_contract: Resolves ``?entrypoint=`` to its input model, raising
|
|
208
222
|
``HTTPException``; ``None`` resolves from the generated tree.
|
|
209
223
|
compute_manifest: The entry point's ``compute_manifest`` hook, or
|
|
210
|
-
``None
|
|
224
|
+
``None`` (see :func:`handler_compute_manifest`).
|
|
211
225
|
programmatic_manifest: An ``AppManifest`` built in Python, served when
|
|
212
226
|
no entry point is named.
|
|
213
227
|
log: The logger the routes report through.
|
|
@@ -235,6 +249,60 @@ class MetadataSource:
|
|
|
235
249
|
from_process_env: bool = True
|
|
236
250
|
|
|
237
251
|
|
|
252
|
+
def handler_compute_manifest(
|
|
253
|
+
handler: Handler,
|
|
254
|
+
app_name: Callable[[], str],
|
|
255
|
+
*,
|
|
256
|
+
secret_store: SecretStore | None = None,
|
|
257
|
+
) -> Callable[[str], ComputeManifest | None]:
|
|
258
|
+
"""A ``MetadataSource.compute_manifest`` that runs ``handler.compute_manifest``.
|
|
259
|
+
|
|
260
|
+
Resolves to ``None`` for every entry point when the handler does not
|
|
261
|
+
override it, so such an app is served exactly as before. Otherwise the
|
|
262
|
+
method runs with a bound :class:`HandlerContext` (no credentials), and a
|
|
263
|
+
``None`` result serves the generated manifest.
|
|
264
|
+
"""
|
|
265
|
+
if type(handler).compute_manifest is Handler.compute_manifest:
|
|
266
|
+
return lambda _entrypoint: None
|
|
267
|
+
|
|
268
|
+
def resolve(entrypoint: str) -> ComputeManifest:
|
|
269
|
+
async def hook(manifest: dict[str, Any], fe_inputs: dict[str, Any]) -> Any:
|
|
270
|
+
context = HandlerContext(app_name=app_name(), _secret_store=secret_store)
|
|
271
|
+
with bind_handler_context(context):
|
|
272
|
+
try:
|
|
273
|
+
computed = await handler.compute_manifest(
|
|
274
|
+
ManifestInput(
|
|
275
|
+
entrypoint=entrypoint,
|
|
276
|
+
manifest=manifest,
|
|
277
|
+
fe_inputs=fe_inputs,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
except HTTPException as exc:
|
|
281
|
+
raise _redacted(exc) from None
|
|
282
|
+
return manifest if computed is None else computed
|
|
283
|
+
|
|
284
|
+
return hook
|
|
285
|
+
|
|
286
|
+
return resolve
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _redacted(exc: HTTPException) -> HTTPException:
|
|
290
|
+
"""*exc* with its detail passed through :func:`redact_secrets`.
|
|
291
|
+
|
|
292
|
+
A handler's ``HTTPException`` is its answer to the caller and keeps its
|
|
293
|
+
status and message; its detail is scrubbed as an ``AppError`` message is on
|
|
294
|
+
the other handler routes. A non-string detail is scrubbed as JSON text.
|
|
295
|
+
"""
|
|
296
|
+
detail = exc.detail if isinstance(exc.detail, str) else orjson.dumps(exc.detail)
|
|
297
|
+
if isinstance(detail, bytes):
|
|
298
|
+
detail = detail.decode()
|
|
299
|
+
return HTTPException(
|
|
300
|
+
status_code=exc.status_code,
|
|
301
|
+
detail=redact_secrets(detail),
|
|
302
|
+
headers=exc.headers,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
238
306
|
def register_metadata_routes(app: FastAPI, source: MetadataSource) -> None:
|
|
239
307
|
"""Register the configmap, manifest and input-contract routes on *app*."""
|
|
240
308
|
|
|
@@ -508,22 +576,16 @@ def register_metadata_routes(app: FastAPI, source: MetadataSource) -> None:
|
|
|
508
576
|
else decode_fe_inputs(fe_inputs, source.max_fe_inputs_bytes())
|
|
509
577
|
)
|
|
510
578
|
manifest_dict = orjson.loads(raw)
|
|
511
|
-
|
|
579
|
+
# The other handler routes' boundary: an HTTPException is the
|
|
580
|
+
# hook's own answer, a typed AppError answers with its category's
|
|
581
|
+
# status and message, and anything else is a generic 500 whose
|
|
582
|
+
# internals (stack/SQL/paths) stay in the log.
|
|
583
|
+
with _http_error_boundary(
|
|
584
|
+
f"compute_manifest for entrypoint {entrypoint_name!r}",
|
|
585
|
+
source.app_name(),
|
|
586
|
+
str(uuid4()),
|
|
587
|
+
):
|
|
512
588
|
computed = await compute(manifest_dict, fe_inputs_decoded)
|
|
513
|
-
except HTTPException:
|
|
514
|
-
raise
|
|
515
|
-
except Exception:
|
|
516
|
-
# Don't leak the hook's internals (stack/SQL/paths) to the
|
|
517
|
-
# caller — mirror the other handler routes' generic 500.
|
|
518
|
-
# conformance: ignore[L009] boundary handler logs the real exception (exc_info) then raises a sanitized HTTPException `from None`; the log is the only server-side record of the actual failure.
|
|
519
|
-
log.error(
|
|
520
|
-
"compute_manifest hook failed for entrypoint %r",
|
|
521
|
-
entrypoint_name,
|
|
522
|
-
exc_info=True,
|
|
523
|
-
)
|
|
524
|
-
raise HTTPException(
|
|
525
|
-
status_code=500, detail="Internal server error"
|
|
526
|
-
) from None
|
|
527
589
|
if not isinstance(computed, dict):
|
|
528
590
|
# conformance: ignore[L009] logs caller-invisible context (the actual returned type) that the generic 500 HTTPException detail omits.
|
|
529
591
|
log.error(
|
|
@@ -12,8 +12,9 @@ consolidated API host register the same routes through
|
|
|
12
12
|
* the worker uses its Dapr-configured ``application_sdk.storage`` store;
|
|
13
13
|
* the host has no Dapr, so it uses :class:`S3ObjectStore`, configured from the
|
|
14
14
|
chart-injected ``ATLAN_S3_BUCKET`` / ``ATLAN_S3_REGION`` with the pod's ambient AWS
|
|
15
|
-
identity (the ``aws`` extra)
|
|
16
|
-
|
|
15
|
+
identity (the ``aws`` extra), or on GCP from ``ATLAN_GCP_BUCKET`` (aliasing the
|
|
16
|
+
chart's ``GCP_BUCKET``) through GCS's S3-compatible API. Without a bucket the routes answer 503, exactly as the
|
|
17
|
+
worker does without a store.
|
|
17
18
|
|
|
18
19
|
Both write a config to ``persistent-artifacts/apps/{app_name}/{type}/{id}/config.json``.
|
|
19
20
|
On the host ``app_name`` is the hosted app's name, never the host's own, so the
|
|
@@ -315,14 +316,30 @@ class S3Client(Protocol):
|
|
|
315
316
|
def upload_file(self, filename: str, bucket: str, key: str, /) -> None: ...
|
|
316
317
|
|
|
317
318
|
|
|
319
|
+
#: GCS's S3-compatible (XML API) endpoint.
|
|
320
|
+
_GCS_ENDPOINT = "https://storage.googleapis.com"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _resolved_env(name: str) -> str:
|
|
324
|
+
"""``name``'s value, or ``""`` when unset or an unresolved Kubernetes alias.
|
|
325
|
+
|
|
326
|
+
The host's manifest aliases chart names (``ATLAN_S3_BUCKET: "$(S3_BUCKET)"``);
|
|
327
|
+
where the chart does not inject the source, Kubernetes leaves the
|
|
328
|
+
``$(...)`` text in place, which is no value, not a bucket or region.
|
|
329
|
+
"""
|
|
330
|
+
value = os.environ.get(name, "").strip()
|
|
331
|
+
return "" if value.startswith("$(") else value
|
|
332
|
+
|
|
333
|
+
|
|
318
334
|
class S3ObjectStore:
|
|
319
335
|
"""One S3 bucket through a boto3 client: the host's store.
|
|
320
336
|
|
|
321
|
-
|
|
337
|
+
On AWS auth is ambient (node role / IRSA / env), exactly as the fleet's Dapr
|
|
322
338
|
``bindings.aws.s3`` components authenticate: their specs carry only a
|
|
323
|
-
bucket and a region.
|
|
324
|
-
|
|
325
|
-
|
|
339
|
+
bucket and a region. On GCP the bucket is GCS, reached with the chart's HMAC
|
|
340
|
+
keys (:meth:`from_env`); they go to boto3 and are never logged. boto3 calls
|
|
341
|
+
run on the app's own ``run_in_thread`` pool, so S3 latency never blocks the
|
|
342
|
+
host's event loop.
|
|
326
343
|
"""
|
|
327
344
|
|
|
328
345
|
def __init__(
|
|
@@ -337,12 +354,19 @@ class S3ObjectStore:
|
|
|
337
354
|
|
|
338
355
|
@classmethod
|
|
339
356
|
def from_env(cls) -> S3ObjectStore | None:
|
|
340
|
-
"""From ``ATLAN_S3_BUCKET`` / ``ATLAN_S3_REGION
|
|
341
|
-
bucket
|
|
357
|
+
"""From ``ATLAN_S3_BUCKET`` / ``ATLAN_S3_REGION``, or on GCP from
|
|
358
|
+
``ATLAN_GCP_BUCKET`` (:meth:`_gcs_from_env`); ``None`` when no bucket is set."""
|
|
359
|
+
gcs_bucket = _resolved_env("ATLAN_GCP_BUCKET")
|
|
360
|
+
if gcs_bucket:
|
|
361
|
+
# First: the chart injects no S3_* on GCP, so the host's ATLAN_S3_*
|
|
362
|
+
# aliases are unresolved there.
|
|
363
|
+
return cls._gcs_from_env(gcs_bucket)
|
|
364
|
+
# Azure Blob tenants get no S3_* either: no bucket (Blob is not served yet).
|
|
365
|
+
bucket = _resolved_env("ATLAN_S3_BUCKET")
|
|
342
366
|
if not bucket:
|
|
343
367
|
return None
|
|
344
368
|
try:
|
|
345
|
-
return cls(bucket,
|
|
369
|
+
return cls(bucket, _resolved_env("ATLAN_S3_REGION") or None)
|
|
346
370
|
except ModuleNotFoundError:
|
|
347
371
|
logger.warning(
|
|
348
372
|
"ATLAN_S3_BUCKET is set but boto3 is not installed, so the config and "
|
|
@@ -351,6 +375,46 @@ class S3ObjectStore:
|
|
|
351
375
|
)
|
|
352
376
|
return None
|
|
353
377
|
|
|
378
|
+
@classmethod
|
|
379
|
+
def _gcs_from_env(cls, bucket: str) -> S3ObjectStore | None:
|
|
380
|
+
"""GCS through its S3-compatible API, with the tenant's HMAC key pair.
|
|
381
|
+
|
|
382
|
+
The host's manifest aliases the atlan-app chart's GCP-only ``GCP_*``
|
|
383
|
+
names as ``ATLAN_GCP_*``; elsewhere they are unresolved, so unset.
|
|
384
|
+
"""
|
|
385
|
+
access_key = _resolved_env("ATLAN_GCP_HMAC_ACCESS_KEY")
|
|
386
|
+
secret = _resolved_env("ATLAN_GCP_HMAC_SECRET")
|
|
387
|
+
if not (access_key and secret):
|
|
388
|
+
logger.warning(
|
|
389
|
+
"ATLAN_GCP_BUCKET is set without ATLAN_GCP_HMAC_ACCESS_KEY / "
|
|
390
|
+
"ATLAN_GCP_HMAC_SECRET, so the config and file routes answer 503."
|
|
391
|
+
)
|
|
392
|
+
return None
|
|
393
|
+
try:
|
|
394
|
+
import boto3 # noqa: PLC0415 — the aws extra; loaded only when GCS is configured
|
|
395
|
+
from botocore.config import Config # noqa: PLC0415 — ships with boto3
|
|
396
|
+
except ModuleNotFoundError:
|
|
397
|
+
logger.warning(
|
|
398
|
+
"ATLAN_GCP_BUCKET is set but boto3 is not installed, so the config and "
|
|
399
|
+
"file routes answer 503. Install atlan-application-sdk-api[aws].",
|
|
400
|
+
exc_info=True,
|
|
401
|
+
)
|
|
402
|
+
return None
|
|
403
|
+
client = boto3.client(
|
|
404
|
+
"s3",
|
|
405
|
+
endpoint_url=_GCS_ENDPOINT,
|
|
406
|
+
region_name=_resolved_env("ATLAN_GCP_REGION") or None,
|
|
407
|
+
aws_access_key_id=access_key,
|
|
408
|
+
aws_secret_access_key=secret,
|
|
409
|
+
# boto3 >= 1.36 sends CRC32 checksums (aws-chunked uploads, checksum
|
|
410
|
+
# mode on reads) that GCS rejects: boto/boto3#4400.
|
|
411
|
+
config=Config(
|
|
412
|
+
request_checksum_calculation="when_required",
|
|
413
|
+
response_checksum_validation="when_required",
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
return cls(bucket, client=cast("S3Client", client))
|
|
417
|
+
|
|
354
418
|
async def get_json(self, key: str) -> dict[str, Any] | None:
|
|
355
419
|
"""The object at ``key``, or ``None`` when it is missing.
|
|
356
420
|
|
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/__init__.py
RENAMED
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_build_info.py
RENAMED
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_install.py
RENAMED
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/clients/sql.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/base.py
RENAMED
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/http.py
RENAMED
|
File without changes
|
|
File without changes
|
{atlan_application_sdk_api-3.51.2 → atlan_application_sdk_api-3.52.0}/application_sdk/errors/wire.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|