svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -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 +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -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 +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -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 +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -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 +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -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 +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import JSON, DateTime, Index, Numeric, String, UniqueConstraint, text
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from svc_infra.db.sql.base import ModelBase
|
|
10
|
+
|
|
11
|
+
TENANT_ID_LEN = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UsageEvent(ModelBase):
|
|
15
|
+
__tablename__ = "billing_usage_events"
|
|
16
|
+
|
|
17
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
18
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
19
|
+
String(TENANT_ID_LEN), index=True, nullable=False
|
|
20
|
+
)
|
|
21
|
+
metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
22
|
+
amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
23
|
+
at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
24
|
+
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
25
|
+
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
26
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
27
|
+
DateTime(timezone=True),
|
|
28
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
29
|
+
nullable=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__table_args__ = (
|
|
33
|
+
UniqueConstraint(
|
|
34
|
+
"tenant_id", "metric", "idempotency_key", name="uq_usage_idem"
|
|
35
|
+
),
|
|
36
|
+
Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UsageAggregate(ModelBase):
|
|
41
|
+
__tablename__ = "billing_usage_aggregates"
|
|
42
|
+
|
|
43
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
44
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
45
|
+
String(TENANT_ID_LEN), index=True, nullable=False
|
|
46
|
+
)
|
|
47
|
+
metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
48
|
+
period_start: Mapped[datetime] = mapped_column(
|
|
49
|
+
DateTime(timezone=True), nullable=False
|
|
50
|
+
)
|
|
51
|
+
granularity: Mapped[str] = mapped_column(
|
|
52
|
+
String(8), nullable=False
|
|
53
|
+
) # hour|day|month
|
|
54
|
+
total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
55
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
56
|
+
DateTime(timezone=True),
|
|
57
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
58
|
+
nullable=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__table_args__ = (
|
|
62
|
+
UniqueConstraint(
|
|
63
|
+
"tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Plan(ModelBase):
|
|
69
|
+
__tablename__ = "billing_plans"
|
|
70
|
+
|
|
71
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
72
|
+
key: Mapped[str] = mapped_column(
|
|
73
|
+
String(64), unique=True, index=True, nullable=False
|
|
74
|
+
)
|
|
75
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
76
|
+
description: Mapped[Optional[str]] = mapped_column(String(255))
|
|
77
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
78
|
+
DateTime(timezone=True),
|
|
79
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
80
|
+
nullable=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PlanEntitlement(ModelBase):
|
|
85
|
+
__tablename__ = "billing_plan_entitlements"
|
|
86
|
+
|
|
87
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
88
|
+
plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
89
|
+
key: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
90
|
+
limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
91
|
+
window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
|
|
92
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
93
|
+
DateTime(timezone=True),
|
|
94
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
95
|
+
nullable=False,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Subscription(ModelBase):
|
|
100
|
+
__tablename__ = "billing_subscriptions"
|
|
101
|
+
|
|
102
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
103
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
104
|
+
String(TENANT_ID_LEN), index=True, nullable=False
|
|
105
|
+
)
|
|
106
|
+
plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
107
|
+
effective_at: Mapped[datetime] = mapped_column(
|
|
108
|
+
DateTime(timezone=True), nullable=False
|
|
109
|
+
)
|
|
110
|
+
ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
111
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
112
|
+
DateTime(timezone=True),
|
|
113
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
114
|
+
nullable=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Price(ModelBase):
|
|
119
|
+
__tablename__ = "billing_prices"
|
|
120
|
+
|
|
121
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
122
|
+
key: Mapped[str] = mapped_column(
|
|
123
|
+
String(64), unique=True, index=True, nullable=False
|
|
124
|
+
)
|
|
125
|
+
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
126
|
+
unit_amount: Mapped[int] = mapped_column(
|
|
127
|
+
Numeric(18, 0), nullable=False
|
|
128
|
+
) # minor units
|
|
129
|
+
metric: Mapped[Optional[str]] = mapped_column(
|
|
130
|
+
String(64)
|
|
131
|
+
) # null for fixed recurring
|
|
132
|
+
recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
|
|
133
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
134
|
+
DateTime(timezone=True),
|
|
135
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
136
|
+
nullable=False,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Invoice(ModelBase):
|
|
141
|
+
__tablename__ = "billing_invoices"
|
|
142
|
+
|
|
143
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
144
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
145
|
+
String(TENANT_ID_LEN), index=True, nullable=False
|
|
146
|
+
)
|
|
147
|
+
period_start: Mapped[datetime] = mapped_column(
|
|
148
|
+
DateTime(timezone=True), nullable=False
|
|
149
|
+
)
|
|
150
|
+
period_end: Mapped[datetime] = mapped_column(
|
|
151
|
+
DateTime(timezone=True), nullable=False
|
|
152
|
+
)
|
|
153
|
+
status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
|
|
154
|
+
total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
155
|
+
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
156
|
+
provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
|
|
157
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
158
|
+
DateTime(timezone=True),
|
|
159
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
160
|
+
nullable=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class InvoiceLine(ModelBase):
|
|
165
|
+
__tablename__ = "billing_invoice_lines"
|
|
166
|
+
|
|
167
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
168
|
+
invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
169
|
+
price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
170
|
+
metric: Mapped[Optional[str]] = mapped_column(String(64))
|
|
171
|
+
quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
172
|
+
amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
|
|
173
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
174
|
+
DateTime(timezone=True),
|
|
175
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
176
|
+
nullable=False,
|
|
177
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, status
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
11
|
+
from svc_infra.api.fastapi.tenancy.context import TenantId
|
|
12
|
+
|
|
13
|
+
from .models import PlanEntitlement, Subscription, UsageAggregate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _current_subscription(
|
|
17
|
+
session: AsyncSession, tenant_id: str
|
|
18
|
+
) -> Optional[Subscription]:
|
|
19
|
+
now = datetime.now(tz=timezone.utc)
|
|
20
|
+
row = (
|
|
21
|
+
(
|
|
22
|
+
await session.execute(
|
|
23
|
+
select(Subscription)
|
|
24
|
+
.where(Subscription.tenant_id == tenant_id)
|
|
25
|
+
.order_by(Subscription.effective_at.desc())
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
.scalars()
|
|
29
|
+
.first()
|
|
30
|
+
)
|
|
31
|
+
if row is None:
|
|
32
|
+
return None
|
|
33
|
+
# basic check: if ended_at is set and in the past, treat as inactive
|
|
34
|
+
if row.ended_at is not None and row.ended_at <= now:
|
|
35
|
+
return None
|
|
36
|
+
return row
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_quota(metric: str, *, window: str = "day", soft: bool = True):
|
|
40
|
+
async def _dep(tenant_id: TenantId, session: SqlSessionDep) -> None:
|
|
41
|
+
sub = await _current_subscription(session, tenant_id)
|
|
42
|
+
if sub is None:
|
|
43
|
+
# no subscription → allow (unlimited) by default
|
|
44
|
+
return
|
|
45
|
+
ent = (
|
|
46
|
+
(
|
|
47
|
+
await session.execute(
|
|
48
|
+
select(PlanEntitlement).where(
|
|
49
|
+
PlanEntitlement.plan_id == sub.plan_id,
|
|
50
|
+
PlanEntitlement.key == metric,
|
|
51
|
+
PlanEntitlement.window == window,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
.scalars()
|
|
56
|
+
.first()
|
|
57
|
+
)
|
|
58
|
+
if ent is None:
|
|
59
|
+
# no entitlement → unlimited
|
|
60
|
+
return
|
|
61
|
+
# compute current window start
|
|
62
|
+
now = datetime.now(tz=timezone.utc)
|
|
63
|
+
if window == "day":
|
|
64
|
+
period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
65
|
+
granularity = "day"
|
|
66
|
+
elif window == "month":
|
|
67
|
+
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
68
|
+
granularity = "month" # we only aggregate per day, but future-proof
|
|
69
|
+
else:
|
|
70
|
+
period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
71
|
+
granularity = "day"
|
|
72
|
+
|
|
73
|
+
used_row = (
|
|
74
|
+
(
|
|
75
|
+
await session.execute(
|
|
76
|
+
select(UsageAggregate).where(
|
|
77
|
+
UsageAggregate.tenant_id == tenant_id,
|
|
78
|
+
UsageAggregate.metric == metric,
|
|
79
|
+
UsageAggregate.granularity == granularity, # v1 daily baseline
|
|
80
|
+
UsageAggregate.period_start == period_start,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
.scalars()
|
|
85
|
+
.first()
|
|
86
|
+
)
|
|
87
|
+
used = int(used_row.total) if used_row else 0
|
|
88
|
+
limit_ = int(ent.limit_per_window)
|
|
89
|
+
if used >= limit_:
|
|
90
|
+
if soft:
|
|
91
|
+
# allow but signal overage via header later (TODO: add header hook)
|
|
92
|
+
return
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
95
|
+
detail=f"Quota exceeded for {metric} in {window} window",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return _dep
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
QuotaDep = Annotated[None, Depends(require_quota)]
|
|
102
|
+
|
|
103
|
+
__all__ = ["require_quota"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UsageIn(BaseModel):
|
|
10
|
+
metric: str = Field(..., min_length=1, max_length=64)
|
|
11
|
+
amount: Annotated[
|
|
12
|
+
int, Field(ge=0, description="Non-negative amount for the metric")
|
|
13
|
+
]
|
|
14
|
+
at: Optional[datetime] = Field(
|
|
15
|
+
default=None,
|
|
16
|
+
description="Event timestamp (UTC). Defaults to server time if omitted.",
|
|
17
|
+
)
|
|
18
|
+
idempotency_key: str = Field(..., min_length=1, max_length=128)
|
|
19
|
+
metadata: dict = Field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UsageAckOut(BaseModel):
|
|
23
|
+
id: str
|
|
24
|
+
accepted: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsageAggregateRow(BaseModel):
|
|
28
|
+
period_start: datetime
|
|
29
|
+
granularity: str
|
|
30
|
+
metric: str
|
|
31
|
+
total: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UsageAggregatesOut(BaseModel):
|
|
35
|
+
items: list[UsageAggregateRow] = Field(default_factory=list)
|
|
36
|
+
next_cursor: Optional[str] = None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
|
|
12
|
+
|
|
13
|
+
ProviderSyncHook = Callable[[Invoice, list[InvoiceLine]], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BillingService:
|
|
18
|
+
session: Session
|
|
19
|
+
tenant_id: str
|
|
20
|
+
provider_sync: Optional[ProviderSyncHook] = None
|
|
21
|
+
|
|
22
|
+
def record_usage(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
metric: str,
|
|
26
|
+
amount: int,
|
|
27
|
+
at: datetime,
|
|
28
|
+
idempotency_key: str,
|
|
29
|
+
metadata: dict | None,
|
|
30
|
+
) -> str:
|
|
31
|
+
# Ensure UTC
|
|
32
|
+
if at.tzinfo is None:
|
|
33
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
34
|
+
evt = UsageEvent(
|
|
35
|
+
id=str(uuid.uuid4()),
|
|
36
|
+
tenant_id=self.tenant_id,
|
|
37
|
+
metric=metric,
|
|
38
|
+
amount=amount,
|
|
39
|
+
at_ts=at,
|
|
40
|
+
idempotency_key=idempotency_key,
|
|
41
|
+
metadata_json=metadata or {},
|
|
42
|
+
)
|
|
43
|
+
self.session.add(evt)
|
|
44
|
+
self.session.flush()
|
|
45
|
+
return evt.id
|
|
46
|
+
|
|
47
|
+
def aggregate_daily(self, *, metric: str, day_start: datetime) -> None:
|
|
48
|
+
# Compute [day_start, day_start+1d)
|
|
49
|
+
next_day = day_start.replace(
|
|
50
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
51
|
+
) + timedelta(days=1)
|
|
52
|
+
total = 0
|
|
53
|
+
rows = self.session.execute(
|
|
54
|
+
select(UsageEvent).where(
|
|
55
|
+
UsageEvent.tenant_id == self.tenant_id,
|
|
56
|
+
UsageEvent.metric == metric,
|
|
57
|
+
UsageEvent.at_ts >= day_start,
|
|
58
|
+
UsageEvent.at_ts < next_day,
|
|
59
|
+
)
|
|
60
|
+
).scalars()
|
|
61
|
+
for r in rows:
|
|
62
|
+
total += int(r.amount)
|
|
63
|
+
# upsert aggregate
|
|
64
|
+
agg = self.session.execute(
|
|
65
|
+
select(UsageAggregate).where(
|
|
66
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
67
|
+
UsageAggregate.metric == metric,
|
|
68
|
+
UsageAggregate.period_start == day_start,
|
|
69
|
+
UsageAggregate.granularity == "day",
|
|
70
|
+
)
|
|
71
|
+
).scalar_one_or_none()
|
|
72
|
+
if agg:
|
|
73
|
+
agg.total = total
|
|
74
|
+
else:
|
|
75
|
+
self.session.add(
|
|
76
|
+
UsageAggregate(
|
|
77
|
+
id=str(uuid.uuid4()),
|
|
78
|
+
tenant_id=self.tenant_id,
|
|
79
|
+
metric=metric,
|
|
80
|
+
period_start=day_start,
|
|
81
|
+
granularity="day",
|
|
82
|
+
total=total,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def generate_monthly_invoice(
|
|
87
|
+
self, *, period_start: datetime, period_end: datetime, currency: str
|
|
88
|
+
) -> str:
|
|
89
|
+
# Minimal: sum all daily aggregates and produce one line
|
|
90
|
+
total = 0
|
|
91
|
+
rows = self.session.execute(
|
|
92
|
+
select(UsageAggregate).where(
|
|
93
|
+
UsageAggregate.tenant_id == self.tenant_id,
|
|
94
|
+
UsageAggregate.period_start >= period_start,
|
|
95
|
+
UsageAggregate.period_start < period_end,
|
|
96
|
+
UsageAggregate.granularity == "day",
|
|
97
|
+
)
|
|
98
|
+
).scalars()
|
|
99
|
+
for r in rows:
|
|
100
|
+
total += int(r.total)
|
|
101
|
+
inv = Invoice(
|
|
102
|
+
id=str(uuid.uuid4()),
|
|
103
|
+
tenant_id=self.tenant_id,
|
|
104
|
+
period_start=period_start,
|
|
105
|
+
period_end=period_end,
|
|
106
|
+
status="created",
|
|
107
|
+
total_amount=total,
|
|
108
|
+
currency=currency,
|
|
109
|
+
)
|
|
110
|
+
self.session.add(inv)
|
|
111
|
+
self.session.flush()
|
|
112
|
+
line = InvoiceLine(
|
|
113
|
+
id=str(uuid.uuid4()),
|
|
114
|
+
invoice_id=inv.id,
|
|
115
|
+
price_id=None,
|
|
116
|
+
metric=None,
|
|
117
|
+
quantity=1,
|
|
118
|
+
amount=total,
|
|
119
|
+
)
|
|
120
|
+
self.session.add(line)
|
|
121
|
+
if self.provider_sync:
|
|
122
|
+
self.provider_sync(inv, [line])
|
|
123
|
+
return inv.id
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Bundled Docs
|
|
2
|
+
|
|
3
|
+
This directory contains a minimal set of Markdown files that the `svc-infra docs` CLI can fall back to when the project running the CLI doesn't have a local `docs/` directory.
|
|
4
|
+
|
|
5
|
+
You can add more topics here as needed; each `*.md` file becomes a topic named after its stem (e.g., `getting-started.md` -> `getting-started`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Bundled docs package for zip-safe importlib.resources access
|
svc_infra/cache/__init__.py
CHANGED
|
@@ -5,6 +5,11 @@ 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
14
|
from .decorators import cached # alias for cache_read
|
|
10
15
|
from .decorators import mutates # alias for cache_write
|
|
@@ -32,4 +37,8 @@ __all__ = [
|
|
|
32
37
|
# Resource-based caching
|
|
33
38
|
"resource",
|
|
34
39
|
"entity",
|
|
40
|
+
# Easy integration helper
|
|
41
|
+
"add_cache",
|
|
42
|
+
# Cache instance access
|
|
43
|
+
"get_cache",
|
|
35
44
|
]
|
svc_infra/cache/add.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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 typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
|
|
20
|
+
from svc_infra.cache.backend import get_cache as _get_cache
|
|
21
|
+
from svc_infra.cache.backend import setup_cache as _setup_cache
|
|
22
|
+
from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
|
|
23
|
+
from svc_infra.cache.backend import wait_ready as _wait_ready
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _instance() -> Any:
|
|
29
|
+
"""Return the current cache instance.
|
|
30
|
+
|
|
31
|
+
This is a thin compatibility shim used by tests and older callers.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
return _get_cache()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _derive_settings(
|
|
38
|
+
url: Optional[str], prefix: Optional[str], version: Optional[str]
|
|
39
|
+
) -> tuple[str, str, str]:
|
|
40
|
+
"""Derive cache settings from parameters or environment variables.
|
|
41
|
+
|
|
42
|
+
Precedence:
|
|
43
|
+
- explicit function arguments
|
|
44
|
+
- environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
|
|
45
|
+
- sensible defaults (mem://, "svc", "v1")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
|
|
49
|
+
derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
|
|
50
|
+
derived_version = version or os.getenv("CACHE_VERSION") or "v1"
|
|
51
|
+
return derived_url, derived_prefix, derived_version
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_cache(
|
|
55
|
+
app: Any | None = None,
|
|
56
|
+
*,
|
|
57
|
+
url: str | None = None,
|
|
58
|
+
prefix: str | None = None,
|
|
59
|
+
version: str | None = None,
|
|
60
|
+
readiness_timeout: float | None = None,
|
|
61
|
+
expose_state: bool = True,
|
|
62
|
+
state_key: str = "cache",
|
|
63
|
+
) -> Callable[[], None]:
|
|
64
|
+
"""Wire cache initialization and lifecycle into the ASGI app.
|
|
65
|
+
|
|
66
|
+
If an app is provided, registers startup/shutdown handlers. Otherwise performs
|
|
67
|
+
immediate initialization (best-effort) without awaiting readiness.
|
|
68
|
+
|
|
69
|
+
Returns a no-op shutdown callable for API symmetry with other helpers.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# Compute effective settings
|
|
73
|
+
eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
|
|
74
|
+
|
|
75
|
+
# If no app provided, do a simple init and return
|
|
76
|
+
if app is None:
|
|
77
|
+
try:
|
|
78
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
79
|
+
logger.info(
|
|
80
|
+
"Cache initialized (no app wiring): backend=%s namespace=%s",
|
|
81
|
+
eff_url,
|
|
82
|
+
f"{eff_prefix}:{eff_version}",
|
|
83
|
+
)
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.exception("Cache initialization failed (no app wiring)")
|
|
86
|
+
return lambda: None
|
|
87
|
+
|
|
88
|
+
# Idempotence: avoid duplicate wiring
|
|
89
|
+
try:
|
|
90
|
+
state = getattr(app, "state", None)
|
|
91
|
+
already = bool(getattr(state, "_svc_cache_wired", False))
|
|
92
|
+
except Exception:
|
|
93
|
+
state = None
|
|
94
|
+
already = False
|
|
95
|
+
|
|
96
|
+
if already:
|
|
97
|
+
logger.debug("add_cache: app already wired; skipping re-registration")
|
|
98
|
+
return lambda: None
|
|
99
|
+
|
|
100
|
+
# Define lifecycle handlers
|
|
101
|
+
async def _startup():
|
|
102
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
103
|
+
try:
|
|
104
|
+
await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
|
|
105
|
+
except Exception:
|
|
106
|
+
# Bubble up to fail fast on startup; tests and prod prefer visibility
|
|
107
|
+
logger.exception("Cache readiness probe failed during startup")
|
|
108
|
+
raise
|
|
109
|
+
# Expose cache instance for convenience
|
|
110
|
+
if expose_state and hasattr(app, "state"):
|
|
111
|
+
try:
|
|
112
|
+
setattr(app.state, state_key, _instance())
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.debug(
|
|
115
|
+
"Unable to expose cache instance on app.state", exc_info=True
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def _shutdown():
|
|
119
|
+
try:
|
|
120
|
+
await _shutdown_cache()
|
|
121
|
+
except Exception:
|
|
122
|
+
# Best-effort; shutdown should not crash the app
|
|
123
|
+
logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
|
|
124
|
+
|
|
125
|
+
# Register event handlers when supported
|
|
126
|
+
register_ok = False
|
|
127
|
+
try:
|
|
128
|
+
if hasattr(app, "add_event_handler"):
|
|
129
|
+
app.add_event_handler("startup", _startup)
|
|
130
|
+
app.add_event_handler("shutdown", _shutdown)
|
|
131
|
+
register_ok = True
|
|
132
|
+
except Exception:
|
|
133
|
+
register_ok = False
|
|
134
|
+
|
|
135
|
+
if not register_ok:
|
|
136
|
+
# Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
|
|
137
|
+
try:
|
|
138
|
+
on_event = getattr(app, "on_event", None)
|
|
139
|
+
if callable(on_event):
|
|
140
|
+
on_event("startup")(_startup)
|
|
141
|
+
on_event("shutdown")(_shutdown)
|
|
142
|
+
register_ok = True
|
|
143
|
+
except Exception:
|
|
144
|
+
register_ok = False
|
|
145
|
+
|
|
146
|
+
# Mark wired and expose state immediately if desired
|
|
147
|
+
if hasattr(app, "state"):
|
|
148
|
+
try:
|
|
149
|
+
setattr(app.state, "_svc_cache_wired", True)
|
|
150
|
+
if expose_state and not hasattr(app.state, state_key):
|
|
151
|
+
setattr(app.state, state_key, _instance())
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
if register_ok:
|
|
156
|
+
logger.info(
|
|
157
|
+
"Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}"
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
# If we cannot register handlers, at least initialize now
|
|
161
|
+
try:
|
|
162
|
+
_setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.exception("Cache initialization failed (no event registration)")
|
|
165
|
+
|
|
166
|
+
# Return a simple shutdown handle for symmetry with other add_* helpers
|
|
167
|
+
return lambda: None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["add_cache"]
|
svc_infra/cache/backend.py
CHANGED
|
@@ -80,9 +80,12 @@ def setup_cache(
|
|
|
80
80
|
logger.info(f"Cache version updated to: {_current_version}")
|
|
81
81
|
|
|
82
82
|
# Setup backend connection
|
|
83
|
+
# Newer cashews versions require an explicit settings_url; default to in-memory
|
|
84
|
+
# backend when no URL is provided so acceptance/unit tests work out of the box.
|
|
83
85
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
settings_url = url or "mem://"
|
|
87
|
+
setup_awaitable = _cache.setup(settings_url)
|
|
88
|
+
logger.info(f"Cache backend setup initiated with URL: {settings_url}")
|
|
86
89
|
except Exception as e:
|
|
87
90
|
logger.error(f"Failed to setup cache backend: {e}")
|
|
88
91
|
raise
|
|
@@ -115,9 +118,7 @@ async def wait_ready(timeout: float = DEFAULT_READINESS_TIMEOUT) -> None:
|
|
|
115
118
|
retrieved_value = await _cache.get(probe_key)
|
|
116
119
|
|
|
117
120
|
if retrieved_value != PROBE_VALUE:
|
|
118
|
-
error_msg =
|
|
119
|
-
f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
|
|
120
|
-
)
|
|
121
|
+
error_msg = f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
|
|
121
122
|
logger.error(error_msg)
|
|
122
123
|
raise RuntimeError(error_msg)
|
|
123
124
|
|
|
@@ -144,7 +145,7 @@ async def shutdown_cache() -> None:
|
|
|
144
145
|
logger.warning(f"Error during cache shutdown (ignored): {e}")
|
|
145
146
|
|
|
146
147
|
|
|
147
|
-
def
|
|
148
|
+
def get_cache():
|
|
148
149
|
"""
|
|
149
150
|
Get the underlying cashews cache instance.
|
|
150
151
|
|