svc-infra 0.1.595__py3-none-any.whl → 1.1.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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/cache/__init__.py
CHANGED
|
@@ -5,17 +5,29 @@ This module offers high-level decorators for read/write caching, cache invalidat
|
|
|
5
5
|
and resource-based cache management.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from .add import add_cache
|
|
9
|
+
|
|
10
|
+
# Cache instance access for object-oriented usage
|
|
11
|
+
from .backend import get_cache
|
|
12
|
+
|
|
8
13
|
# Core decorators - main public API
|
|
9
|
-
from .decorators import
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
from .decorators import (
|
|
15
|
+
cache_read,
|
|
16
|
+
cache_write,
|
|
17
|
+
cached, # alias for cache_read
|
|
18
|
+
init_cache,
|
|
19
|
+
init_cache_async,
|
|
20
|
+
mutates, # alias for cache_write
|
|
21
|
+
)
|
|
12
22
|
|
|
13
23
|
# Recaching functionality for advanced use cases
|
|
14
24
|
from .recache import RecachePlan, recache
|
|
15
25
|
|
|
16
26
|
# Resource management for entity-based caching
|
|
17
|
-
from .resources import
|
|
18
|
-
|
|
27
|
+
from .resources import (
|
|
28
|
+
entity, # legacy alias
|
|
29
|
+
resource,
|
|
30
|
+
)
|
|
19
31
|
|
|
20
32
|
__all__ = [
|
|
21
33
|
# Primary decorators developers use
|
|
@@ -32,4 +44,8 @@ __all__ = [
|
|
|
32
44
|
# Resource-based caching
|
|
33
45
|
"resource",
|
|
34
46
|
"entity",
|
|
47
|
+
# Easy integration helper
|
|
48
|
+
"add_cache",
|
|
49
|
+
# Cache instance access
|
|
50
|
+
"get_cache",
|
|
35
51
|
]
|
svc_infra/cache/add.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Easy integration helper to wire the cache backend into an ASGI app lifecycle.
|
|
2
|
+
|
|
3
|
+
Contract:
|
|
4
|
+
- Idempotent: multiple calls are safe; startup/shutdown handlers are registered once.
|
|
5
|
+
- Env-driven defaults: respects CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION, APP_ENV.
|
|
6
|
+
- Lifecycle: registers startup (init + readiness probe) and shutdown (graceful close).
|
|
7
|
+
- Ergonomics: exposes the underlying cache instance at app.state.cache by default.
|
|
8
|
+
|
|
9
|
+
This does not replace the per-function decorators (`cache_read`, `cache_write`) and
|
|
10
|
+
does not alter existing direct APIs; it simply standardizes initialization and wiring.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
|
|
21
|
+
from svc_infra.cache.backend import get_cache as _get_cache
|
|
22
|
+
from svc_infra.cache.backend import setup_cache as _setup_cache
|
|
23
|
+
from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
|
|
24
|
+
from svc_infra.cache.backend import wait_ready as _wait_ready
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _instance() -> Any:
|
|
30
|
+
"""Return the current cache instance.
|
|
31
|
+
|
|
32
|
+
This is a thin compatibility shim used by tests and older callers.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
return _get_cache()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _derive_settings(
|
|
39
|
+
url: str | None, prefix: str | None, version: str | None
|
|
40
|
+
) -> tuple[str, str, str]:
|
|
41
|
+
"""Derive cache settings from parameters or environment variables.
|
|
42
|
+
|
|
43
|
+
Precedence:
|
|
44
|
+
- explicit function arguments
|
|
45
|
+
- environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
|
|
46
|
+
- sensible defaults (mem://, "svc", "v1")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
|
|
50
|
+
derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
|
|
51
|
+
derived_version = version or os.getenv("CACHE_VERSION") or "v1"
|
|
52
|
+
return derived_url, derived_prefix, derived_version
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_cache(
|
|
56
|
+
app: Any | None = None,
|
|
57
|
+
*,
|
|
58
|
+
url: str | None = None,
|
|
59
|
+
prefix: str | None = None,
|
|
60
|
+
version: str | None = None,
|
|
61
|
+
readiness_timeout: float | None = None,
|
|
62
|
+
expose_state: bool = True,
|
|
63
|
+
state_key: str = "cache",
|
|
64
|
+
) -> Callable[[], None]:
|
|
65
|
+
"""Wire cache initialization and lifecycle into the ASGI app.
|
|
66
|
+
|
|
67
|
+
If an app is provided, registers startup/shutdown handlers. Otherwise performs
|
|
68
|
+
immediate initialization (best-effort) without awaiting readiness.
|
|
69
|
+
|
|
70
|
+
Returns a no-op shutdown callable for API symmetry with other helpers.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Compute effective settings
|
|
74
|
+
eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
|
|
75
|
+
|
|
76
|
+
# If no app provided, do a simple init and return
|
|
77
|
+
if app is None:
|
|
78
|
+
try:
|
|
79
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
80
|
+
logger.info(
|
|
81
|
+
"Cache initialized (no app wiring): backend=%s namespace=%s",
|
|
82
|
+
eff_url,
|
|
83
|
+
f"{eff_prefix}:{eff_version}",
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Cache initialization failed (no app wiring)")
|
|
87
|
+
return lambda: None
|
|
88
|
+
|
|
89
|
+
# Idempotence: avoid duplicate wiring
|
|
90
|
+
try:
|
|
91
|
+
state = getattr(app, "state", None)
|
|
92
|
+
already = bool(getattr(state, "_svc_cache_wired", False))
|
|
93
|
+
except Exception:
|
|
94
|
+
state = None
|
|
95
|
+
already = False
|
|
96
|
+
|
|
97
|
+
if already:
|
|
98
|
+
logger.debug("add_cache: app already wired; skipping re-registration")
|
|
99
|
+
return lambda: None
|
|
100
|
+
|
|
101
|
+
# Define lifecycle handlers
|
|
102
|
+
async def _startup():
|
|
103
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
104
|
+
try:
|
|
105
|
+
await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
|
|
106
|
+
except Exception:
|
|
107
|
+
# Bubble up to fail fast on startup; tests and prod prefer visibility
|
|
108
|
+
logger.exception("Cache readiness probe failed during startup")
|
|
109
|
+
raise
|
|
110
|
+
# Expose cache instance for convenience
|
|
111
|
+
if expose_state and hasattr(app, "state"):
|
|
112
|
+
try:
|
|
113
|
+
setattr(app.state, state_key, _instance())
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.debug("Unable to expose cache instance on app.state", exc_info=True)
|
|
116
|
+
|
|
117
|
+
async def _shutdown():
|
|
118
|
+
try:
|
|
119
|
+
await _shutdown_cache()
|
|
120
|
+
except Exception:
|
|
121
|
+
# Best-effort; shutdown should not crash the app
|
|
122
|
+
logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
|
|
123
|
+
|
|
124
|
+
# Register event handlers when supported
|
|
125
|
+
register_ok = False
|
|
126
|
+
try:
|
|
127
|
+
if hasattr(app, "add_event_handler"):
|
|
128
|
+
app.add_event_handler("startup", _startup)
|
|
129
|
+
app.add_event_handler("shutdown", _shutdown)
|
|
130
|
+
register_ok = True
|
|
131
|
+
except Exception:
|
|
132
|
+
register_ok = False
|
|
133
|
+
|
|
134
|
+
if not register_ok:
|
|
135
|
+
# Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
|
|
136
|
+
try:
|
|
137
|
+
on_event = getattr(app, "on_event", None)
|
|
138
|
+
if callable(on_event):
|
|
139
|
+
on_event("startup")(_startup)
|
|
140
|
+
on_event("shutdown")(_shutdown)
|
|
141
|
+
register_ok = True
|
|
142
|
+
except Exception:
|
|
143
|
+
register_ok = False
|
|
144
|
+
|
|
145
|
+
# Mark wired and expose state immediately if desired
|
|
146
|
+
if hasattr(app, "state"):
|
|
147
|
+
try:
|
|
148
|
+
app.state._svc_cache_wired = True
|
|
149
|
+
if expose_state and not hasattr(app.state, state_key):
|
|
150
|
+
setattr(app.state, state_key, _instance())
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
if register_ok:
|
|
155
|
+
logger.info("Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}")
|
|
156
|
+
else:
|
|
157
|
+
# If we cannot register handlers, at least initialize now
|
|
158
|
+
try:
|
|
159
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
160
|
+
except Exception:
|
|
161
|
+
logger.exception("Cache initialization failed (no event registration)")
|
|
162
|
+
|
|
163
|
+
# Return a simple shutdown handle for symmetry with other add_* helpers
|
|
164
|
+
return lambda: None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = ["add_cache"]
|
svc_infra/cache/backend.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Optional
|
|
5
4
|
|
|
6
5
|
from cashews import cache as _cache
|
|
7
6
|
|
|
@@ -42,10 +41,10 @@ def _full_prefix() -> str:
|
|
|
42
41
|
|
|
43
42
|
|
|
44
43
|
def setup_cache(
|
|
45
|
-
url:
|
|
44
|
+
url: str | None = None,
|
|
46
45
|
*,
|
|
47
|
-
prefix:
|
|
48
|
-
version:
|
|
46
|
+
prefix: str | None = None,
|
|
47
|
+
version: str | None = None,
|
|
49
48
|
):
|
|
50
49
|
"""
|
|
51
50
|
Configure Cashews and set a global key prefix for namespacing.
|
|
@@ -80,9 +79,12 @@ def setup_cache(
|
|
|
80
79
|
logger.info(f"Cache version updated to: {_current_version}")
|
|
81
80
|
|
|
82
81
|
# Setup backend connection
|
|
82
|
+
# Newer cashews versions require an explicit settings_url; default to in-memory
|
|
83
|
+
# backend when no URL is provided so acceptance/unit tests work out of the box.
|
|
83
84
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
settings_url = url or "mem://"
|
|
86
|
+
setup_awaitable = _cache.setup(settings_url)
|
|
87
|
+
logger.info(f"Cache backend setup initiated with URL: {settings_url}")
|
|
86
88
|
except Exception as e:
|
|
87
89
|
logger.error(f"Failed to setup cache backend: {e}")
|
|
88
90
|
raise
|
|
@@ -144,7 +146,7 @@ async def shutdown_cache() -> None:
|
|
|
144
146
|
logger.warning(f"Error during cache shutdown (ignored): {e}")
|
|
145
147
|
|
|
146
148
|
|
|
147
|
-
def
|
|
149
|
+
def get_cache():
|
|
148
150
|
"""
|
|
149
151
|
Get the underlying cashews cache instance.
|
|
150
152
|
|
svc_infra/cache/decorators.py
CHANGED
|
@@ -7,8 +7,10 @@ invalidating cache on write operations, and managing cache recaching strategies.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import inspect
|
|
10
11
|
import logging
|
|
11
|
-
from
|
|
12
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
13
|
+
from typing import Any
|
|
12
14
|
|
|
13
15
|
from cashews import cache as _cache
|
|
14
16
|
|
|
@@ -16,16 +18,12 @@ from svc_infra.cache.backend import alias as _alias
|
|
|
16
18
|
from svc_infra.cache.backend import setup_cache as _setup_cache
|
|
17
19
|
from svc_infra.cache.backend import wait_ready as _wait_ready
|
|
18
20
|
|
|
19
|
-
from .keys import
|
|
20
|
-
build_key_template,
|
|
21
|
-
build_key_variants_renderer,
|
|
22
|
-
create_tags_function,
|
|
23
|
-
resolve_tags,
|
|
24
|
-
)
|
|
21
|
+
from .keys import build_key_template, build_key_variants_renderer, resolve_tags
|
|
25
22
|
from .recache import RecachePlan, RecacheSpec, execute_recache, recache
|
|
26
23
|
from .resources import Resource, entity, resource
|
|
27
24
|
from .tags import invalidate_tags
|
|
28
25
|
from .ttl import validate_ttl
|
|
26
|
+
from .utils import validate_cache_key
|
|
29
27
|
|
|
30
28
|
logger = logging.getLogger(__name__)
|
|
31
29
|
|
|
@@ -67,11 +65,11 @@ async def init_cache_async(
|
|
|
67
65
|
|
|
68
66
|
def cache_read(
|
|
69
67
|
*,
|
|
70
|
-
key:
|
|
71
|
-
ttl:
|
|
72
|
-
tags:
|
|
73
|
-
early_ttl:
|
|
74
|
-
refresh:
|
|
68
|
+
key: str | tuple[str, ...],
|
|
69
|
+
ttl: int | None = None,
|
|
70
|
+
tags: Iterable[str] | Callable[..., Iterable[str]] | None = None,
|
|
71
|
+
early_ttl: int | None = None,
|
|
72
|
+
refresh: bool | None = None,
|
|
75
73
|
):
|
|
76
74
|
"""
|
|
77
75
|
Cache decorator for read operations with version-resilient key handling.
|
|
@@ -98,18 +96,33 @@ def cache_read(
|
|
|
98
96
|
ttl_val = validate_ttl(ttl)
|
|
99
97
|
template = build_key_template(key)
|
|
100
98
|
namespace = _alias() or ""
|
|
101
|
-
|
|
99
|
+
# Cashews expects `tags` to be an iterable of (template) strings.
|
|
100
|
+
# If no explicit tags are provided, default to tagging by the key template.
|
|
101
|
+
# This enables the common pattern:
|
|
102
|
+
# @cache_read(key="thing:{id}")
|
|
103
|
+
# @cache_write(tags=["thing:{id}"])
|
|
104
|
+
# where writes invalidate reads without requiring tags on every read.
|
|
105
|
+
dynamic_tags_func: Callable[..., Iterable[str]] | None = None
|
|
106
|
+
if tags is None:
|
|
107
|
+
tags_param: Iterable[str] = (template,)
|
|
108
|
+
elif callable(tags):
|
|
109
|
+
# Preserve API surface area, but cashews doesn't accept callables here.
|
|
110
|
+
# We'll attach tag mappings manually after each call.
|
|
111
|
+
dynamic_tags_func = tags
|
|
112
|
+
tags_param = ()
|
|
113
|
+
else:
|
|
114
|
+
tags_param = tags
|
|
102
115
|
|
|
103
116
|
def _decorator(func: Callable[..., Awaitable[Any]]):
|
|
104
117
|
# Try different cashews cache decorator signatures for compatibility
|
|
105
|
-
cache_kwargs = {"tags":
|
|
118
|
+
cache_kwargs: dict[str, Any] = {"tags": tuple(tags_param)}
|
|
106
119
|
if early_ttl is not None:
|
|
107
120
|
cache_kwargs["early_ttl"] = early_ttl
|
|
108
121
|
if refresh is not None:
|
|
109
122
|
cache_kwargs["refresh"] = refresh
|
|
110
123
|
|
|
111
124
|
wrapped = None
|
|
112
|
-
error_msgs = []
|
|
125
|
+
error_msgs: list[str] = []
|
|
113
126
|
|
|
114
127
|
# Attempt 1: With prefix parameter (preferred)
|
|
115
128
|
if namespace:
|
|
@@ -141,8 +154,47 @@ def cache_read(
|
|
|
141
154
|
raise RuntimeError(f"Failed to apply cache decorator: {error_msgs[-1]}") from e
|
|
142
155
|
|
|
143
156
|
# Attach key variants renderer for cache writers
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
wrapped.__svc_key_variants__ = build_key_variants_renderer(template) # type: ignore[attr-defined]
|
|
158
|
+
|
|
159
|
+
# If tags were provided as a callable, populate cashews tag sets manually.
|
|
160
|
+
# This is best-effort and only affects invalidation-by-tag behavior.
|
|
161
|
+
if dynamic_tags_func is None:
|
|
162
|
+
return wrapped
|
|
163
|
+
|
|
164
|
+
sig = inspect.signature(func)
|
|
165
|
+
tag_key_prefix = getattr(_cache, "_tags_key_prefix", "_tag:")
|
|
166
|
+
|
|
167
|
+
async def _wrapped_with_dynamic_tags(*args, **kwargs):
|
|
168
|
+
result = await wrapped(*args, **kwargs)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
172
|
+
bound.apply_defaults()
|
|
173
|
+
ctx = dict(bound.arguments)
|
|
174
|
+
|
|
175
|
+
rendered_key = validate_cache_key(template.format(**ctx))
|
|
176
|
+
full_key = f"{namespace}:{rendered_key}" if namespace else rendered_key
|
|
177
|
+
|
|
178
|
+
raw_tags = dynamic_tags_func(*args, **kwargs)
|
|
179
|
+
for t in list(raw_tags) if raw_tags is not None else []:
|
|
180
|
+
tag_val = str(t)
|
|
181
|
+
if "{" in tag_val and "}" in tag_val:
|
|
182
|
+
try:
|
|
183
|
+
tag_val = tag_val.format(**ctx)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
if tag_val:
|
|
187
|
+
await _cache.set_add(tag_key_prefix + tag_val, full_key, expire=ttl_val)
|
|
188
|
+
except Exception:
|
|
189
|
+
# Don't let best-effort tag mapping break cache reads.
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
_wrapped_with_dynamic_tags.__svc_key_variants__ = getattr( # type: ignore[attr-defined]
|
|
195
|
+
wrapped, "__svc_key_variants__", None
|
|
196
|
+
)
|
|
197
|
+
return _wrapped_with_dynamic_tags
|
|
146
198
|
|
|
147
199
|
return _decorator
|
|
148
200
|
|
|
@@ -156,8 +208,8 @@ cached = cache_read
|
|
|
156
208
|
|
|
157
209
|
def cache_write(
|
|
158
210
|
*,
|
|
159
|
-
tags:
|
|
160
|
-
recache:
|
|
211
|
+
tags: Iterable[str] | Callable[..., Iterable[str]],
|
|
212
|
+
recache: Iterable[RecacheSpec] | None = None,
|
|
161
213
|
recache_max_concurrency: int = 5,
|
|
162
214
|
):
|
|
163
215
|
"""
|
|
@@ -203,7 +255,10 @@ def cache_write(
|
|
|
203
255
|
if recache:
|
|
204
256
|
try:
|
|
205
257
|
await execute_recache(
|
|
206
|
-
recache,
|
|
258
|
+
recache,
|
|
259
|
+
*args,
|
|
260
|
+
max_concurrency=recache_max_concurrency,
|
|
261
|
+
**kwargs,
|
|
207
262
|
)
|
|
208
263
|
except Exception as e:
|
|
209
264
|
logger.error(f"Cache recaching failed: {e}")
|
svc_infra/cache/demo.py
CHANGED
|
@@ -83,10 +83,10 @@ async def main():
|
|
|
83
83
|
# Try to fetch deleted user - should hit DB and get KeyError
|
|
84
84
|
try:
|
|
85
85
|
p4 = await get_user_profile(user_id=uid)
|
|
86
|
-
print("
|
|
86
|
+
print("[ERROR] Fetched profile after delete:", p4)
|
|
87
87
|
print("This shouldn't happen - user should be deleted!")
|
|
88
88
|
except KeyError as e:
|
|
89
|
-
print(f"
|
|
89
|
+
print(f"[OK] User successfully deleted - {e}")
|
|
90
90
|
print("Cache invalidation and deletion worked perfectly!")
|
|
91
91
|
|
|
92
92
|
|
svc_infra/cache/keys.py
CHANGED
|
@@ -6,7 +6,7 @@ with version-resilient handling and namespace support.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
-
from
|
|
9
|
+
from collections.abc import Callable
|
|
10
10
|
|
|
11
11
|
from svc_infra.cache.backend import alias as _alias
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@ from .utils import validate_cache_key
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def build_key_template(key:
|
|
18
|
+
def build_key_template(key: str | tuple[str, ...]) -> str:
|
|
19
19
|
"""Convert key to template string."""
|
|
20
20
|
if isinstance(key, tuple):
|
|
21
21
|
parts = [part for part in key if part]
|
|
@@ -88,12 +88,32 @@ def build_key_variants_renderer(template: str) -> Callable[..., list[str]]:
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def resolve_tags(tags, *args, **kwargs) -> list[str]:
|
|
91
|
-
"""Resolve tags from static list or callable.
|
|
91
|
+
"""Resolve tags from static list or callable and render templates with kwargs.
|
|
92
|
+
|
|
93
|
+
Supports entries like "thing:{id}" which will be formatted using provided kwargs.
|
|
94
|
+
Non-string items are passed through as str(). Missing keys are skipped with a warning.
|
|
95
|
+
"""
|
|
92
96
|
try:
|
|
97
|
+
# 1) Obtain raw tags list
|
|
93
98
|
if callable(tags):
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
raw = tags(*args, **kwargs)
|
|
100
|
+
raw_list = list(raw) if raw is not None else []
|
|
101
|
+
else:
|
|
102
|
+
raw_list = list(tags)
|
|
103
|
+
|
|
104
|
+
# 2) Render any templates using kwargs
|
|
105
|
+
rendered: list[str] = []
|
|
106
|
+
for t in raw_list:
|
|
107
|
+
try:
|
|
108
|
+
if isinstance(t, str) and ("{" in t and "}" in t):
|
|
109
|
+
rendered.append(t.format(**kwargs))
|
|
110
|
+
else:
|
|
111
|
+
rendered.append(str(t))
|
|
112
|
+
except KeyError as e:
|
|
113
|
+
logger.warning(f"Tag template missing key {e} in '{t}'")
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning(f"Failed to render tag '{t}': {e}")
|
|
116
|
+
return [r for r in rendered if r]
|
|
97
117
|
except Exception as e:
|
|
98
118
|
logger.error(f"Failed to resolve cache tags: {e}")
|
|
99
119
|
return []
|
svc_infra/cache/recache.py
CHANGED
|
@@ -7,9 +7,10 @@ including recache plans and execution strategies.
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from inspect import Parameter, signature
|
|
12
|
-
from typing import Any
|
|
13
|
+
from typing import Any
|
|
13
14
|
|
|
14
15
|
from cashews import cache as _cache
|
|
15
16
|
|
|
@@ -34,19 +35,19 @@ class RecachePlan:
|
|
|
34
35
|
"""
|
|
35
36
|
|
|
36
37
|
getter: Callable[..., Awaitable[Any]]
|
|
37
|
-
include:
|
|
38
|
-
rename:
|
|
39
|
-
extra:
|
|
40
|
-
key:
|
|
38
|
+
include: Iterable[str] | None = None
|
|
39
|
+
rename: dict[str, str] | None = None
|
|
40
|
+
extra: dict[str, Any] | None = None
|
|
41
|
+
key: str | tuple[str, ...] | None = None
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def recache(
|
|
44
45
|
getter: Callable[..., Awaitable[Any]],
|
|
45
46
|
*,
|
|
46
|
-
include:
|
|
47
|
-
rename:
|
|
48
|
-
extra:
|
|
49
|
-
key:
|
|
47
|
+
include: Iterable[str] | None = None,
|
|
48
|
+
rename: dict[str, str] | None = None,
|
|
49
|
+
extra: dict[str, Any] | None = None,
|
|
50
|
+
key: str | tuple[str, ...] | None = None,
|
|
50
51
|
) -> RecachePlan:
|
|
51
52
|
"""
|
|
52
53
|
Create a recache plan for cache warming after invalidation.
|
|
@@ -64,16 +65,14 @@ def recache(
|
|
|
64
65
|
return RecachePlan(getter=getter, include=include, rename=rename, extra=extra, key=key)
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
RecacheSpec =
|
|
68
|
-
Callable[..., Awaitable[Any]]
|
|
69
|
-
RecachePlan
|
|
70
|
-
tuple[Callable[..., Awaitable[Any]], Any]
|
|
71
|
-
|
|
68
|
+
RecacheSpec = (
|
|
69
|
+
Callable[..., Awaitable[Any]]
|
|
70
|
+
| RecachePlan
|
|
71
|
+
| tuple[Callable[..., Awaitable[Any]], Any] # Legacy format
|
|
72
|
+
)
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
def generate_key_variants(
|
|
75
|
-
template: Union[str, tuple[str, ...]], params: dict[str, Any]
|
|
76
|
-
) -> list[str]:
|
|
75
|
+
def generate_key_variants(template: str | tuple[str, ...], params: dict[str, Any]) -> list[str]:
|
|
77
76
|
"""
|
|
78
77
|
Generate all possible cache key variants for deletion.
|
|
79
78
|
|
|
@@ -165,7 +164,7 @@ def build_getter_kwargs(
|
|
|
165
164
|
if isinstance(spec, tuple):
|
|
166
165
|
getter, mapping_or_builder = spec
|
|
167
166
|
getter_params = signature(getter).parameters
|
|
168
|
-
|
|
167
|
+
legacy_call_kwargs: dict[str, Any] = {}
|
|
169
168
|
|
|
170
169
|
if callable(mapping_or_builder):
|
|
171
170
|
try:
|
|
@@ -173,7 +172,7 @@ def build_getter_kwargs(
|
|
|
173
172
|
if isinstance(produced, dict):
|
|
174
173
|
for param_name, value in produced.items():
|
|
175
174
|
if param_name in getter_params:
|
|
176
|
-
|
|
175
|
+
legacy_call_kwargs[param_name] = value
|
|
177
176
|
except Exception as e:
|
|
178
177
|
logger.warning(f"Recache mapping function failed: {e}")
|
|
179
178
|
elif isinstance(mapping_or_builder, dict):
|
|
@@ -182,25 +181,25 @@ def build_getter_kwargs(
|
|
|
182
181
|
continue
|
|
183
182
|
try:
|
|
184
183
|
if callable(source):
|
|
185
|
-
|
|
184
|
+
legacy_call_kwargs[getter_param] = source(*mut_args, **mut_kwargs)
|
|
186
185
|
elif isinstance(source, str) and source in mut_kwargs:
|
|
187
|
-
|
|
186
|
+
legacy_call_kwargs[getter_param] = mut_kwargs[source]
|
|
188
187
|
except Exception as e:
|
|
189
188
|
logger.warning(f"Recache parameter mapping failed for {getter_param}: {e}")
|
|
190
189
|
|
|
191
190
|
# Add direct parameter matches
|
|
192
191
|
for param_name in getter_params.keys():
|
|
193
|
-
if param_name not in
|
|
194
|
-
|
|
192
|
+
if param_name not in legacy_call_kwargs and param_name in mut_kwargs:
|
|
193
|
+
legacy_call_kwargs[param_name] = mut_kwargs[param_name]
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
return getter,
|
|
195
|
+
legacy_call_kwargs = {k: v for k, v in legacy_call_kwargs.items() if k in getter_params}
|
|
196
|
+
return getter, legacy_call_kwargs
|
|
198
197
|
|
|
199
198
|
# Handle simple getter function
|
|
200
199
|
getter = spec
|
|
201
200
|
getter_params = signature(getter).parameters
|
|
202
|
-
|
|
203
|
-
return getter,
|
|
201
|
+
simple_call_kwargs = {k: v for k, v in mut_kwargs.items() if k in getter_params}
|
|
202
|
+
return getter, simple_call_kwargs
|
|
204
203
|
|
|
205
204
|
|
|
206
205
|
async def execute_recache(
|
svc_infra/cache/resources.py
CHANGED
|
@@ -8,7 +8,7 @@ with standardized key patterns and tag management.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import inspect
|
|
10
10
|
import logging
|
|
11
|
-
from
|
|
11
|
+
from collections.abc import Callable
|
|
12
12
|
|
|
13
13
|
from cashews import cache as _cache
|
|
14
14
|
|
|
@@ -41,8 +41,8 @@ class Resource:
|
|
|
41
41
|
*,
|
|
42
42
|
suffix: str,
|
|
43
43
|
ttl: int,
|
|
44
|
-
key_template:
|
|
45
|
-
tags_template:
|
|
44
|
+
key_template: str | None = None,
|
|
45
|
+
tags_template: tuple[str, ...] | None = None,
|
|
46
46
|
lock: bool = True,
|
|
47
47
|
):
|
|
48
48
|
"""
|
|
@@ -73,7 +73,7 @@ class Resource:
|
|
|
73
73
|
def cache_write(
|
|
74
74
|
self,
|
|
75
75
|
*,
|
|
76
|
-
recache:
|
|
76
|
+
recache: list[tuple[Callable, Callable]] | None = None,
|
|
77
77
|
recache_max_concurrency: int = 5,
|
|
78
78
|
):
|
|
79
79
|
"""
|
|
@@ -157,7 +157,8 @@ class Resource:
|
|
|
157
157
|
logger.error(f"Resource recache failed: {e}")
|
|
158
158
|
|
|
159
159
|
await asyncio.gather(
|
|
160
|
-
*[_run_single_resource_recache(spec) for spec in specs],
|
|
160
|
+
*[_run_single_resource_recache(spec) for spec in specs],
|
|
161
|
+
return_exceptions=True,
|
|
161
162
|
)
|
|
162
163
|
|
|
163
164
|
def _decorator(mutator: Callable):
|