python-platform 0.1.1__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.
- python_platform/__init__.py +445 -0
- python_platform/application/__init__.py +6 -0
- python_platform/application/build_spec.py +37 -0
- python_platform/application/builder.py +260 -0
- python_platform/application/composition.py +219 -0
- python_platform/application/runtime.py +546 -0
- python_platform/application/runtime_hooks.py +327 -0
- python_platform/application/state.py +216 -0
- python_platform/application_services/__init__.py +54 -0
- python_platform/application_services/catalog.py +500 -0
- python_platform/application_services/contracts.py +188 -0
- python_platform/application_services/dispatcher.py +423 -0
- python_platform/application_services/errors.py +89 -0
- python_platform/application_services/execution.py +199 -0
- python_platform/application_services/interceptors.py +44 -0
- python_platform/application_services/invocation.py +427 -0
- python_platform/application_services/policies.py +46 -0
- python_platform/application_services/seeding.py +72 -0
- python_platform/application_services/signature.py +49 -0
- python_platform/application_services/validation.py +84 -0
- python_platform/auditing/__init__.py +12 -0
- python_platform/auditing/contracts.py +39 -0
- python_platform/auditing/control.py +48 -0
- python_platform/auditing/sqlalchemy/__init__.py +6 -0
- python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
- python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/auditing/sqlalchemy/models.py +31 -0
- python_platform/auditing/sqlalchemy/module.py +32 -0
- python_platform/auditing/sqlalchemy/store.py +63 -0
- python_platform/authorization/__init__.py +38 -0
- python_platform/authorization/catalog.py +63 -0
- python_platform/authorization/contracts.py +241 -0
- python_platform/authorization/definition_discovery.py +81 -0
- python_platform/authorization/definitions.py +43 -0
- python_platform/authorization/errors.py +40 -0
- python_platform/background_execution/__init__.py +15 -0
- python_platform/background_execution/application.py +41 -0
- python_platform/background_execution/child.py +123 -0
- python_platform/background_execution/contracts.py +39 -0
- python_platform/background_execution/lifecycle.py +22 -0
- python_platform/background_execution/local.py +127 -0
- python_platform/background_execution/locks.py +31 -0
- python_platform/background_execution/management.py +17 -0
- python_platform/background_execution/module.py +10 -0
- python_platform/background_execution/permissions.py +14 -0
- python_platform/background_execution/processes.py +331 -0
- python_platform/background_jobs/__init__.py +31 -0
- python_platform/background_jobs/catalog.py +287 -0
- python_platform/background_jobs/contracts.py +202 -0
- python_platform/background_jobs/errors.py +28 -0
- python_platform/background_jobs/execution.py +201 -0
- python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_platform/background_jobs/pgqueuer/__init__.py +6 -0
- python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
- python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
- python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_platform/background_jobs/pgqueuer/module.py +102 -0
- python_platform/background_jobs/pgqueuer/options.py +70 -0
- python_platform/background_jobs/pgqueuer/runtime.py +479 -0
- python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_platform/background_jobs/pgqueuer/supervision.py +201 -0
- python_platform/background_workers/__init__.py +22 -0
- python_platform/background_workers/catalog.py +96 -0
- python_platform/background_workers/contracts.py +124 -0
- python_platform/background_workers/errors.py +20 -0
- python_platform/background_workers/execution.py +133 -0
- python_platform/background_workers/runtime.py +203 -0
- python_platform/caching/__init__.py +11 -0
- python_platform/caching/catalog.py +73 -0
- python_platform/caching/contracts.py +73 -0
- python_platform/caching/errors.py +20 -0
- python_platform/cli/__init__.py +87 -0
- python_platform/cli/errors.py +5 -0
- python_platform/cli/project.py +69 -0
- python_platform/cli/runtime.py +68 -0
- python_platform/configuration/__init__.py +21 -0
- python_platform/configuration/composition.py +65 -0
- python_platform/configuration/contracts.py +68 -0
- python_platform/configuration/dotenv_source.py +51 -0
- python_platform/configuration/environment_source.py +38 -0
- python_platform/configuration/immutability.py +124 -0
- python_platform/configuration/input_shape.py +58 -0
- python_platform/configuration/merge.py +103 -0
- python_platform/configuration/root.py +215 -0
- python_platform/configuration/sources.py +519 -0
- python_platform/configuration/values.py +427 -0
- python_platform/configuration/yaml_source.py +51 -0
- python_platform/developer_kit/__init__.py +1 -0
- python_platform/developer_kit/generation.py +182 -0
- python_platform/developer_kit/project_metadata.py +45 -0
- python_platform/developer_kit/source.py +53 -0
- python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
- python_platform/developer_kit/wiring.py +85 -0
- python_platform/diagnostics/__init__.py +23 -0
- python_platform/diagnostics/journal.py +174 -0
- python_platform/diagnostics/model.py +83 -0
- python_platform/diagnostics/source.py +25 -0
- python_platform/distributed_lock/__init__.py +6 -0
- python_platform/distributed_lock/contracts.py +20 -0
- python_platform/distributed_lock/options.py +19 -0
- python_platform/domain/__init__.py +13 -0
- python_platform/domain/aggregates.py +64 -0
- python_platform/domain/entities.py +33 -0
- python_platform/domain/errors.py +12 -0
- python_platform/domain/value_objects.py +31 -0
- python_platform/errors/__init__.py +75 -0
- python_platform/errors/base.py +13 -0
- python_platform/errors/business.py +53 -0
- python_platform/errors/configuration.py +119 -0
- python_platform/errors/diagnostics.py +12 -0
- python_platform/errors/lifecycle.py +164 -0
- python_platform/errors/modularity.py +100 -0
- python_platform/errors/services.py +100 -0
- python_platform/events/__init__.py +25 -0
- python_platform/events/aggregate.py +39 -0
- python_platform/events/catalog.py +136 -0
- python_platform/events/contracts.py +39 -0
- python_platform/events/contribution.py +128 -0
- python_platform/events/discovery.py +105 -0
- python_platform/events/errors.py +48 -0
- python_platform/events/runtime.py +192 -0
- python_platform/fastapi/__init__.py +49 -0
- python_platform/fastapi/action.py +175 -0
- python_platform/fastapi/adapter.py +381 -0
- python_platform/fastapi/application_services.py +647 -0
- python_platform/fastapi/background.py +42 -0
- python_platform/fastapi/contracts.py +278 -0
- python_platform/fastapi/errors.py +32 -0
- python_platform/fastapi/filters.py +111 -0
- python_platform/fastapi/health.py +44 -0
- python_platform/fastapi/http.py +55 -0
- python_platform/fastapi/http_router.py +212 -0
- python_platform/fastapi/manual_action.py +105 -0
- python_platform/fastapi/middleware.py +93 -0
- python_platform/fastapi/parameters.py +97 -0
- python_platform/fastapi/realtime/__init__.py +13 -0
- python_platform/fastapi/realtime/authentication.py +181 -0
- python_platform/fastapi/realtime/connection.py +166 -0
- python_platform/fastapi/realtime/module.py +41 -0
- python_platform/fastapi/realtime/options.py +50 -0
- python_platform/fastapi/realtime/runtime.py +423 -0
- python_platform/fastapi/request_context.py +244 -0
- python_platform/fastapi/route_integrity.py +88 -0
- python_platform/fastapi/routing.py +410 -0
- python_platform/fastapi/server.py +181 -0
- python_platform/fastapi/settings.py +40 -0
- python_platform/fastapi/tracing.py +62 -0
- python_platform/fastapi/transfer.py +128 -0
- python_platform/fastapi/upload_limits.py +63 -0
- python_platform/fastapi/uploads.py +94 -0
- python_platform/hosted_services/__init__.py +29 -0
- python_platform/hosted_services/bridge.py +420 -0
- python_platform/hosted_services/catalog.py +259 -0
- python_platform/hosted_services/contracts.py +55 -0
- python_platform/hosted_services/errors.py +53 -0
- python_platform/hosted_services/options.py +17 -0
- python_platform/hosted_services/runtime.py +267 -0
- python_platform/hosted_services/state.py +40 -0
- python_platform/hosting/__init__.py +4 -0
- python_platform/hosting/instance.py +40 -0
- python_platform/identity/__init__.py +73 -0
- python_platform/identity/application.py +287 -0
- python_platform/identity/contracts.py +283 -0
- python_platform/identity/errors.py +28 -0
- python_platform/identity/http_api.py +90 -0
- python_platform/identity/module.py +46 -0
- python_platform/identity/passwords.py +34 -0
- python_platform/identity/permissions.py +14 -0
- python_platform/identity/services.py +108 -0
- python_platform/identity/sqlalchemy/__init__.py +5 -0
- python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
- python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/identity/sqlalchemy/models.py +85 -0
- python_platform/identity/sqlalchemy/module.py +62 -0
- python_platform/identity/sqlalchemy/stores.py +526 -0
- python_platform/identity/tokens.py +106 -0
- python_platform/invocation/__init__.py +3 -0
- python_platform/invocation/callables.py +171 -0
- python_platform/invocation/contracts.py +33 -0
- python_platform/invocation/entries.py +54 -0
- python_platform/invocation/function_runtime.py +48 -0
- python_platform/invocation/interception.py +184 -0
- python_platform/lifecycle/__init__.py +25 -0
- python_platform/lifecycle/composition.py +35 -0
- python_platform/lifecycle/context.py +31 -0
- python_platform/lifecycle/runtime.py +43 -0
- python_platform/lifecycle/state.py +20 -0
- python_platform/modularity/__init__.py +15 -0
- python_platform/modularity/contracts.py +90 -0
- python_platform/modularity/discovery.py +143 -0
- python_platform/modularity/graph.py +334 -0
- python_platform/modularity/registry.py +100 -0
- python_platform/modularity/selection.py +16 -0
- python_platform/notifications/__init__.py +19 -0
- python_platform/notifications/catalog.py +46 -0
- python_platform/notifications/contracts.py +107 -0
- python_platform/observability/__init__.py +5 -0
- python_platform/observability/context.py +43 -0
- python_platform/observability/export.py +73 -0
- python_platform/observability/formatting.py +89 -0
- python_platform/observability/logging.py +118 -0
- python_platform/observability/options.py +45 -0
- python_platform/observability/tracing.py +80 -0
- python_platform/options/__init__.py +19 -0
- python_platform/options/aliases.py +331 -0
- python_platform/options/contribution.py +62 -0
- python_platform/options/immutability.py +48 -0
- python_platform/options/input_keys.py +214 -0
- python_platform/options/issues.py +335 -0
- python_platform/options/models.py +114 -0
- python_platform/options/registry.py +280 -0
- python_platform/options/schema.py +488 -0
- python_platform/options/validation.py +120 -0
- python_platform/py.typed +0 -0
- python_platform/realtime/__init__.py +12 -0
- python_platform/realtime/contracts.py +28 -0
- python_platform/realtime/diagnostics.py +37 -0
- python_platform/realtime/messages.py +113 -0
- python_platform/redis/__init__.py +6 -0
- python_platform/redis/distributed_lock.py +212 -0
- python_platform/redis/lease_lock.py +36 -0
- python_platform/redis/module.py +50 -0
- python_platform/redis/notification_runtime.py +182 -0
- python_platform/redis/notifications.py +25 -0
- python_platform/redis/options.py +35 -0
- python_platform/redis/runtime.py +116 -0
- python_platform/services/__init__.py +39 -0
- python_platform/services/application_bindings.py +221 -0
- python_platform/services/arbitration.py +381 -0
- python_platform/services/binding.py +102 -0
- python_platform/services/contribution.py +438 -0
- python_platform/services/convention.py +292 -0
- python_platform/services/convention_contracts.py +94 -0
- python_platform/services/exposure.py +23 -0
- python_platform/services/framework_provider.py +178 -0
- python_platform/services/native_graph.py +172 -0
- python_platform/services/provider.py +204 -0
- python_platform/services/registration.py +101 -0
- python_platform/services/runtime.py +298 -0
- python_platform/settings/__init__.py +30 -0
- python_platform/settings/application.py +95 -0
- python_platform/settings/binding.py +33 -0
- python_platform/settings/catalog.py +121 -0
- python_platform/settings/changes.py +14 -0
- python_platform/settings/contracts.py +115 -0
- python_platform/settings/definition_discovery.py +71 -0
- python_platform/settings/definitions.py +41 -0
- python_platform/settings/errors.py +22 -0
- python_platform/settings/handlers.py +43 -0
- python_platform/settings/management.py +57 -0
- python_platform/settings/manager.py +89 -0
- python_platform/settings/module.py +7 -0
- python_platform/settings/notifications.py +39 -0
- python_platform/settings/permissions.py +14 -0
- python_platform/settings/provider.py +74 -0
- python_platform/settings/refresh.py +140 -0
- python_platform/settings/refresh_module.py +45 -0
- python_platform/settings/sqlalchemy/__init__.py +5 -0
- python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
- python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/settings/sqlalchemy/models.py +36 -0
- python_platform/settings/sqlalchemy/module.py +36 -0
- python_platform/settings/sqlalchemy/store.py +86 -0
- python_platform/settings/store.py +16 -0
- python_platform/settings/values.py +39 -0
- python_platform/sqlalchemy/__init__.py +51 -0
- python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
- python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_platform/sqlalchemy/auditing.py +48 -0
- python_platform/sqlalchemy/errors.py +85 -0
- python_platform/sqlalchemy/metadata.py +538 -0
- python_platform/sqlalchemy/migration.py +346 -0
- python_platform/sqlalchemy/module.py +48 -0
- python_platform/sqlalchemy/module_migration.py +148 -0
- python_platform/sqlalchemy/options.py +66 -0
- python_platform/sqlalchemy/repository.py +33 -0
- python_platform/sqlalchemy/runtime.py +85 -0
- python_platform/sqlalchemy/session_provider.py +45 -0
- python_platform/sqlalchemy/unit_of_work.py +97 -0
- python_platform/testing/__init__.py +5 -0
- python_platform/testing/runtime.py +112 -0
- python_platform/unit_of_work/__init__.py +14 -0
- python_platform/unit_of_work/contracts.py +223 -0
- python_platform/unit_of_work/errors.py +27 -0
- python_platform/unit_of_work/manager.py +210 -0
- python_platform/unit_of_work/options.py +92 -0
- python_platform-0.1.1.dist-info/METADATA +122 -0
- python_platform-0.1.1.dist-info/RECORD +344 -0
- python_platform-0.1.1.dist-info/WHEEL +4 -0
- python_platform-0.1.1.dist-info/entry_points.txt +9 -0
- python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
- python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Provider-neutral realtime wire envelope 与 JSON boundary。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Final, Literal
|
|
13
|
+
from uuid import UUID, uuid4
|
|
14
|
+
|
|
15
|
+
from ..invocation.contracts import _is_safe_correlation_id
|
|
16
|
+
|
|
17
|
+
_MESSAGE_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*\.v[1-9][0-9]*$")
|
|
18
|
+
_REALTIME_SCHEMA_VERSION: Final = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class RealtimeEnvelope:
|
|
23
|
+
"""不可变、可序列化且不携带 transport/provider object 的消息。"""
|
|
24
|
+
|
|
25
|
+
message_type: str
|
|
26
|
+
correlation_id: str
|
|
27
|
+
payload: Mapping[str, object]
|
|
28
|
+
schema_version: Literal[1] = _REALTIME_SCHEMA_VERSION
|
|
29
|
+
message_id: UUID = field(default_factory=uuid4)
|
|
30
|
+
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.schema_version != _REALTIME_SCHEMA_VERSION:
|
|
34
|
+
raise ValueError(f"realtime schema_version must be {_REALTIME_SCHEMA_VERSION}")
|
|
35
|
+
if not isinstance(self.message_id, UUID):
|
|
36
|
+
raise TypeError("realtime message_id must be UUID")
|
|
37
|
+
if (
|
|
38
|
+
not isinstance(self.message_type, str)
|
|
39
|
+
or _MESSAGE_TYPE_PATTERN.fullmatch(self.message_type) is None
|
|
40
|
+
):
|
|
41
|
+
raise ValueError("realtime message_type must be a versioned dotted name")
|
|
42
|
+
if not _is_safe_correlation_id(self.correlation_id):
|
|
43
|
+
raise ValueError("realtime correlation_id must be a safe non-empty identifier")
|
|
44
|
+
if not isinstance(self.occurred_at, datetime) or self.occurred_at.utcoffset() is None:
|
|
45
|
+
raise ValueError("realtime occurred_at must be timezone-aware")
|
|
46
|
+
if self.occurred_at.utcoffset() != UTC.utcoffset(self.occurred_at):
|
|
47
|
+
raise ValueError("realtime occurred_at must be UTC")
|
|
48
|
+
if not isinstance(self.payload, Mapping):
|
|
49
|
+
raise TypeError("realtime payload must be a JSON object")
|
|
50
|
+
frozen = _freeze_json_mapping(self.payload, active=set())
|
|
51
|
+
object.__setattr__(self, "payload", frozen)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _encode_envelope(envelope: RealtimeEnvelope) -> str:
|
|
55
|
+
occurred_at = envelope.occurred_at.isoformat().replace("+00:00", "Z")
|
|
56
|
+
wire = {
|
|
57
|
+
"schema_version": envelope.schema_version,
|
|
58
|
+
"message_id": str(envelope.message_id),
|
|
59
|
+
"message_type": envelope.message_type,
|
|
60
|
+
"occurred_at": occurred_at,
|
|
61
|
+
"correlation_id": envelope.correlation_id,
|
|
62
|
+
"payload": _thaw_json_value(envelope.payload),
|
|
63
|
+
}
|
|
64
|
+
return json.dumps(wire, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _freeze_json_mapping(
|
|
68
|
+
value: Mapping[str, object],
|
|
69
|
+
*,
|
|
70
|
+
active: set[int],
|
|
71
|
+
) -> Mapping[str, object]:
|
|
72
|
+
identity = id(value)
|
|
73
|
+
if identity in active:
|
|
74
|
+
raise ValueError("realtime payload cannot contain cycles")
|
|
75
|
+
active.add(identity)
|
|
76
|
+
try:
|
|
77
|
+
frozen: dict[str, object] = {}
|
|
78
|
+
for key, item in value.items():
|
|
79
|
+
if not isinstance(key, str):
|
|
80
|
+
raise TypeError("realtime payload object keys must be strings")
|
|
81
|
+
frozen[key] = _freeze_json_value(item, active=active)
|
|
82
|
+
return MappingProxyType(frozen)
|
|
83
|
+
finally:
|
|
84
|
+
active.remove(identity)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _freeze_json_value(value: object, *, active: set[int]) -> object:
|
|
88
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
89
|
+
return value
|
|
90
|
+
if isinstance(value, float):
|
|
91
|
+
if not math.isfinite(value):
|
|
92
|
+
raise ValueError("realtime payload numbers must be finite")
|
|
93
|
+
return value
|
|
94
|
+
if isinstance(value, Mapping):
|
|
95
|
+
return _freeze_json_mapping(value, active=active)
|
|
96
|
+
if isinstance(value, (list, tuple)):
|
|
97
|
+
identity = id(value)
|
|
98
|
+
if identity in active:
|
|
99
|
+
raise ValueError("realtime payload cannot contain cycles")
|
|
100
|
+
active.add(identity)
|
|
101
|
+
try:
|
|
102
|
+
return tuple(_freeze_json_value(item, active=active) for item in value)
|
|
103
|
+
finally:
|
|
104
|
+
active.remove(identity)
|
|
105
|
+
raise TypeError("realtime payload contains a non-JSON value")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _thaw_json_value(value: object) -> object:
|
|
109
|
+
if isinstance(value, Mapping):
|
|
110
|
+
return {key: _thaw_json_value(item) for key, item in value.items()}
|
|
111
|
+
if isinstance(value, tuple):
|
|
112
|
+
return [_thaw_json_value(item) for item in value]
|
|
113
|
+
return value
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Optional Redis distributed cache provider。"""
|
|
2
|
+
|
|
3
|
+
from .module import RedisConnectionModule, RedisDistributedLockModule, RedisModule
|
|
4
|
+
from .options import RedisOptions
|
|
5
|
+
|
|
6
|
+
__all__ = ("RedisConnectionModule", "RedisDistributedLockModule", "RedisModule", "RedisOptions")
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""原生 Redis Lock 的接线与每次获取独立的业务取消/资源 owner。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
8
|
+
from typing import TypeVar, cast
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from anyio import CancelScope
|
|
12
|
+
from redis.exceptions import LockNotOwnedError
|
|
13
|
+
|
|
14
|
+
from ..distributed_lock import DistributedLockError, DistributedLockOptions
|
|
15
|
+
from ..options import Options
|
|
16
|
+
from .lease_lock import _LeaseLock
|
|
17
|
+
from .options import RedisOptions
|
|
18
|
+
from .runtime import _RedisClientOwner
|
|
19
|
+
|
|
20
|
+
_T = TypeVar("_T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RedisDistributedLock:
|
|
24
|
+
"""APP 服务只持有连接和冻结配置;可变租约不在不同获取之间共享。"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
client: _RedisClientOwner,
|
|
29
|
+
options: Options[DistributedLockOptions],
|
|
30
|
+
redis_options: Options[RedisOptions],
|
|
31
|
+
) -> None:
|
|
32
|
+
self._client = client.client
|
|
33
|
+
self._options = options.value
|
|
34
|
+
self._prefix = redis_options.value.key_prefix
|
|
35
|
+
|
|
36
|
+
def acquire(self, key: str, /) -> AbstractAsyncContextManager[bool]:
|
|
37
|
+
if not isinstance(key, str) or not key or key != key.strip():
|
|
38
|
+
raise ValueError("Distributed lock key must be a nonempty trimmed string")
|
|
39
|
+
native = self._client.lock(
|
|
40
|
+
f"{self._prefix}:locks:{key}",
|
|
41
|
+
timeout=self._options.lease_timeout.total_seconds(),
|
|
42
|
+
blocking_timeout=self._options.wait_timeout.total_seconds(),
|
|
43
|
+
thread_local=False,
|
|
44
|
+
lock_class=_LeaseLock,
|
|
45
|
+
)
|
|
46
|
+
return _RedisLockLease(key, cast(_LeaseLock, native), self._options).hold()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _RedisLockLease:
|
|
50
|
+
def __init__(self, key: str, native: _LeaseLock, options: DistributedLockOptions) -> None:
|
|
51
|
+
self._key = key
|
|
52
|
+
self._lock = native
|
|
53
|
+
self._options = options
|
|
54
|
+
self._stop = asyncio.Event()
|
|
55
|
+
self._active = False
|
|
56
|
+
self._lease_errors: list[BaseException] = []
|
|
57
|
+
self._cancellation: asyncio.CancelledError | None = None
|
|
58
|
+
|
|
59
|
+
@asynccontextmanager
|
|
60
|
+
async def hold(self) -> AsyncIterator[bool]:
|
|
61
|
+
# 显式 token 仍交给原生 Lua 仲裁;响应丢失时也只能条件释放本次 token,不能 DEL key。
|
|
62
|
+
token = uuid4().hex.encode()
|
|
63
|
+
try:
|
|
64
|
+
acquired = await self._wait_owned(
|
|
65
|
+
asyncio.ensure_future(cast(Awaitable[bool], self._lock.acquire(token=token)))
|
|
66
|
+
)
|
|
67
|
+
if acquired and asyncio.get_running_loop().time() >= self._lock.deadline:
|
|
68
|
+
raise TimeoutError("Acquire response arrived after the conservative lease deadline")
|
|
69
|
+
except BaseException as error:
|
|
70
|
+
failures = [self._native_error("acquire", error)]
|
|
71
|
+
try:
|
|
72
|
+
await self._wait_owned(asyncio.create_task(self._lock.do_release(token)))
|
|
73
|
+
except LockNotOwnedError:
|
|
74
|
+
# 获取未成功或响应不确定时,无本次 token 是合法结果;原始失败仍然传播。
|
|
75
|
+
pass
|
|
76
|
+
except BaseException as cleanup_error:
|
|
77
|
+
failures.append(self._native_error("release", cleanup_error))
|
|
78
|
+
failure = _lock_failure(failures, self._cancellation)
|
|
79
|
+
raise failure from failure.__cause__
|
|
80
|
+
|
|
81
|
+
if not acquired:
|
|
82
|
+
self._raise_failures(None, [])
|
|
83
|
+
yield False
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
body_error: BaseException | None = None
|
|
87
|
+
failures = []
|
|
88
|
+
renewal: asyncio.Task[None] | None = None
|
|
89
|
+
try:
|
|
90
|
+
self._raise_failures(None, [])
|
|
91
|
+
# 上下文进入、业务与退出属于同一 task;业务自身的 shield / 线程等待保持原生语义。
|
|
92
|
+
with CancelScope() as scope:
|
|
93
|
+
self._active = True
|
|
94
|
+
renewal = asyncio.create_task(
|
|
95
|
+
self._renew(scope), name="python-platform-lock-renewal"
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
yield True
|
|
99
|
+
except BaseException as error:
|
|
100
|
+
body_error = error
|
|
101
|
+
raise
|
|
102
|
+
except BaseException as error:
|
|
103
|
+
body_error = error
|
|
104
|
+
finally:
|
|
105
|
+
# body 的 finally 已退出,才停续租并释放锁。I/O 尚未结束时不与 release 并发。
|
|
106
|
+
self._active = False
|
|
107
|
+
self._stop.set()
|
|
108
|
+
if renewal is not None:
|
|
109
|
+
await self._wait_owned(renewal)
|
|
110
|
+
failures.extend(self._lease_errors)
|
|
111
|
+
try:
|
|
112
|
+
await self._wait_owned(asyncio.create_task(self._lock.release()))
|
|
113
|
+
except BaseException as cleanup_error:
|
|
114
|
+
failures.append(self._native_error("release", cleanup_error))
|
|
115
|
+
self._raise_failures(body_error, failures)
|
|
116
|
+
|
|
117
|
+
async def _renew(self, scope: CancelScope) -> None:
|
|
118
|
+
interval = self._options.renewal_interval.total_seconds()
|
|
119
|
+
loop = asyncio.get_running_loop()
|
|
120
|
+
try:
|
|
121
|
+
while True:
|
|
122
|
+
deadline = self._lock.deadline
|
|
123
|
+
delay = max(0, min(self._lock.lease_started + interval, deadline) - loop.time())
|
|
124
|
+
if self._stop.is_set():
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.wait_for(self._stop.wait(), delay)
|
|
128
|
+
return
|
|
129
|
+
except TimeoutError:
|
|
130
|
+
pass
|
|
131
|
+
remaining = deadline - loop.time()
|
|
132
|
+
if remaining <= 0:
|
|
133
|
+
raise TimeoutError("Lease expired before renewal")
|
|
134
|
+
request = asyncio.ensure_future(self._lock.reacquire())
|
|
135
|
+
done, _ = await asyncio.wait((request,), timeout=remaining)
|
|
136
|
+
if not done or loop.time() >= deadline:
|
|
137
|
+
# 旧截止时间不能被迟到的成功响应延长。先撤销业务许可,再等 I/O 收口;
|
|
138
|
+
# 不 cancel 请求后立即 release,避免旧续租与 Token 释放并发。
|
|
139
|
+
self._fail_lease(scope, TimeoutError("Renewal exceeded the lease deadline"))
|
|
140
|
+
try:
|
|
141
|
+
await request
|
|
142
|
+
except BaseException as error:
|
|
143
|
+
self._fail_lease(scope, error)
|
|
144
|
+
return
|
|
145
|
+
request.result()
|
|
146
|
+
except BaseException as error:
|
|
147
|
+
self._fail_lease(scope, error)
|
|
148
|
+
|
|
149
|
+
def _fail_lease(self, scope: CancelScope, error: BaseException) -> None:
|
|
150
|
+
self._lease_errors.append(self._native_error("renew", error))
|
|
151
|
+
if self._active:
|
|
152
|
+
scope.cancel()
|
|
153
|
+
|
|
154
|
+
async def _wait_owned(self, future: asyncio.Future[_T]) -> _T:
|
|
155
|
+
# caller 取消不能遗弃正在获取/释放的原生 I/O。记录取消,确定 Token 结果并清理后再传播。
|
|
156
|
+
try:
|
|
157
|
+
return await asyncio.shield(future)
|
|
158
|
+
except asyncio.CancelledError as cancellation:
|
|
159
|
+
if self._cancellation is None:
|
|
160
|
+
self._cancellation = cancellation
|
|
161
|
+
# 外层 AnyIO level cancellation 会在每次 await 重发;捕获后必须 shield 收尾,
|
|
162
|
+
# 否则 asyncio.shield 的反复等待会形成忙循环。raw task.cancel 仍被记录并等待。
|
|
163
|
+
with CancelScope(shield=True):
|
|
164
|
+
while not future.done():
|
|
165
|
+
try:
|
|
166
|
+
await asyncio.shield(future)
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
# 首次取消已保存;再次 raw cancel 也不能遗弃这个 I/O owner。
|
|
169
|
+
continue
|
|
170
|
+
return future.result()
|
|
171
|
+
|
|
172
|
+
def _native_error(self, operation: str, error: BaseException) -> BaseException:
|
|
173
|
+
if not isinstance(error, Exception):
|
|
174
|
+
return error
|
|
175
|
+
failure = DistributedLockError(
|
|
176
|
+
key=self._key, operation=operation, error_type=type(error).__name__
|
|
177
|
+
)
|
|
178
|
+
failure.__cause__ = error
|
|
179
|
+
return failure
|
|
180
|
+
|
|
181
|
+
def _raise_failures(
|
|
182
|
+
self, body_error: BaseException | None, failures: list[BaseException]
|
|
183
|
+
) -> None:
|
|
184
|
+
cancellation = self._cancellation
|
|
185
|
+
if isinstance(body_error, asyncio.CancelledError):
|
|
186
|
+
cancellation = body_error
|
|
187
|
+
elif body_error is not None:
|
|
188
|
+
if not failures:
|
|
189
|
+
if cancellation is None:
|
|
190
|
+
# 成功清理时保持业务异常原有 cause,不用 from None 覆盖调用者的诊断链。
|
|
191
|
+
raise body_error
|
|
192
|
+
raise _lock_failure([body_error, cancellation])
|
|
193
|
+
failures.insert(0, body_error)
|
|
194
|
+
if failures:
|
|
195
|
+
raise _lock_failure(failures, cancellation)
|
|
196
|
+
if cancellation is not None:
|
|
197
|
+
raise cancellation
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _lock_failure(
|
|
201
|
+
failures: list[BaseException], cancellation: asyncio.CancelledError | None = None
|
|
202
|
+
) -> BaseException:
|
|
203
|
+
if cancellation is not None:
|
|
204
|
+
# 取消放在聚合 cause,原生 I/O 的 cause 留在各失败上,避免相互覆盖。
|
|
205
|
+
group = BaseExceptionGroup("Distributed lock operation and cleanup failed", tuple(failures))
|
|
206
|
+
group.__cause__ = cancellation
|
|
207
|
+
return group
|
|
208
|
+
return (
|
|
209
|
+
failures[0]
|
|
210
|
+
if len(failures) == 1
|
|
211
|
+
else BaseExceptionGroup("Distributed lock operation and cleanup failed", tuple(failures))
|
|
212
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""redis-py 原生扩展点:记录成功请求的起点,不接管重试或 Token Lua。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from redis.asyncio.lock import Lock
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _LeaseLock(Lock):
|
|
12
|
+
_lease_started: float
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def lease_started(self) -> float:
|
|
16
|
+
return self._lease_started
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def deadline(self) -> float:
|
|
20
|
+
# 与原生 SET PX / reacquire Lua 使用的毫秒截断保持一致。
|
|
21
|
+
assert self.timeout is not None
|
|
22
|
+
return self._lease_started + int(self.timeout * 1000) / 1000
|
|
23
|
+
|
|
24
|
+
async def do_acquire(self, token: str | bytes) -> bool:
|
|
25
|
+
started = asyncio.get_running_loop().time()
|
|
26
|
+
acquired = await super().do_acquire(token)
|
|
27
|
+
if acquired:
|
|
28
|
+
# 竞争失败及原生 retry sleep 不消耗下一次成功获取的租期。
|
|
29
|
+
self._lease_started = started
|
|
30
|
+
return acquired
|
|
31
|
+
|
|
32
|
+
async def do_reacquire(self) -> Literal[True]:
|
|
33
|
+
started = asyncio.get_running_loop().time()
|
|
34
|
+
result = await super().do_reacquire()
|
|
35
|
+
self._lease_started = started
|
|
36
|
+
return result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Host 显式选择的 Redis Module。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dishka import Provider, Scope
|
|
6
|
+
|
|
7
|
+
from ..caching import DistributedCache
|
|
8
|
+
from ..distributed_lock import DistributedLock, DistributedLockOptions
|
|
9
|
+
from ..lifecycle import ConfigureContext, InitializeContext
|
|
10
|
+
from ..modularity import AppModule
|
|
11
|
+
from .distributed_lock import RedisDistributedLock
|
|
12
|
+
from .options import RedisOptions
|
|
13
|
+
from .runtime import RedisDistributedCache, _redis_client, _RedisClientOwner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisConnectionModule(AppModule):
|
|
17
|
+
"""共享原生连接生命周期;缓存和通知自行决定连接故障的业务语义。"""
|
|
18
|
+
|
|
19
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
20
|
+
context.configure(RedisOptions, section="redis", secret_paths=frozenset({"url"}))
|
|
21
|
+
provider = Provider()
|
|
22
|
+
provider.provide(_redis_client, provides=_RedisClientOwner, scope=Scope.APP)
|
|
23
|
+
context.services.contribute(provider, reason="Application Redis connection")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RedisModule(AppModule):
|
|
27
|
+
dependencies = (RedisConnectionModule,)
|
|
28
|
+
|
|
29
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
30
|
+
provider = Provider()
|
|
31
|
+
provider.provide(
|
|
32
|
+
RedisDistributedCache,
|
|
33
|
+
provides=DistributedCache,
|
|
34
|
+
scope=Scope.APP,
|
|
35
|
+
)
|
|
36
|
+
context.services.contribute(provider, reason="Redis typed distributed cache")
|
|
37
|
+
|
|
38
|
+
async def initialize(self, context: InitializeContext) -> None:
|
|
39
|
+
client = await context.container.get(_RedisClientOwner)
|
|
40
|
+
await client.ping()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RedisDistributedLockModule(AppModule):
|
|
44
|
+
dependencies = (RedisConnectionModule,)
|
|
45
|
+
|
|
46
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
47
|
+
context.configure(DistributedLockOptions, section="distributed_lock")
|
|
48
|
+
provider = Provider()
|
|
49
|
+
provider.provide(RedisDistributedLock, provides=DistributedLock, scope=Scope.APP)
|
|
50
|
+
context.services.contribute(provider, reason="Redis renewable distributed lock")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""原生 Redis Pub/Sub 的 typed 边界与 Application-owned 接收生命周期。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
8
|
+
from contextlib import asynccontextmanager, suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, ClassVar, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from pydantic import ConfigDict, Field
|
|
13
|
+
from redis.exceptions import RedisError
|
|
14
|
+
|
|
15
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
16
|
+
from ..hosted_services import HostedService, HostedServiceContext
|
|
17
|
+
from ..lifecycle import ShutdownReason
|
|
18
|
+
from ..notifications import (
|
|
19
|
+
NotificationCatalog,
|
|
20
|
+
NotificationDefinition,
|
|
21
|
+
NotificationDefinitionError,
|
|
22
|
+
NotificationPayloadError,
|
|
23
|
+
NotificationPublishStatus,
|
|
24
|
+
)
|
|
25
|
+
from ..observability.context import capture_trace_context, invocation_span
|
|
26
|
+
from ..options import BaseOptions, Options
|
|
27
|
+
from .options import RedisOptions
|
|
28
|
+
from .runtime import _RedisClientOwner
|
|
29
|
+
|
|
30
|
+
_T = TypeVar("_T")
|
|
31
|
+
_LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotificationOptions(BaseOptions):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
max_message_bytes: int = Field(default=65536, ge=1)
|
|
38
|
+
reconnect_interval_seconds: float = Field(default=1.0, gt=0)
|
|
39
|
+
publish_timeout_seconds: float = Field(default=5.0, gt=0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class _Subscriber:
|
|
44
|
+
handler: Callable[[Any], Awaitable[None]]
|
|
45
|
+
on_reconnect: Callable[[], None] | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RedisNotifications(HostedService):
|
|
49
|
+
run_in_background_processes: ClassVar[bool] = True
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
client: _RedisClientOwner,
|
|
54
|
+
catalog: NotificationCatalog,
|
|
55
|
+
options: Options[NotificationOptions],
|
|
56
|
+
redis_options: Options[RedisOptions],
|
|
57
|
+
runtime: _InvocationRuntime,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._client = client.client
|
|
60
|
+
self._catalog = catalog
|
|
61
|
+
self._options = options.value
|
|
62
|
+
self._runtime = runtime
|
|
63
|
+
self._channels = {
|
|
64
|
+
f"{redis_options.value.key_prefix}:notifications:{definition.name}": definition
|
|
65
|
+
for definition in catalog.definitions
|
|
66
|
+
}
|
|
67
|
+
self._prefix = f"{redis_options.value.key_prefix}:notifications:"
|
|
68
|
+
self._subscribers: dict[str, dict[object, _Subscriber]] = {
|
|
69
|
+
channel: {} for channel in self._channels
|
|
70
|
+
}
|
|
71
|
+
self._connected: set[str] = set()
|
|
72
|
+
self._outage_reported = False
|
|
73
|
+
self._task: asyncio.Task[None] | None = None
|
|
74
|
+
|
|
75
|
+
async def start(self, context: HostedServiceContext) -> None:
|
|
76
|
+
self._task = asyncio.create_task(self._listen(), name=context.name)
|
|
77
|
+
# 接收/重连由组件拥有;启动完成不等同于 Redis 订阅已确认。
|
|
78
|
+
|
|
79
|
+
async def stop(self, reason: ShutdownReason) -> None:
|
|
80
|
+
task = self._task
|
|
81
|
+
if task is not None:
|
|
82
|
+
task.cancel()
|
|
83
|
+
with suppress(asyncio.CancelledError):
|
|
84
|
+
await task
|
|
85
|
+
self._connected.clear()
|
|
86
|
+
|
|
87
|
+
async def publish(
|
|
88
|
+
self, definition: NotificationDefinition[_T], payload: _T
|
|
89
|
+
) -> NotificationPublishStatus:
|
|
90
|
+
self._catalog.require_owned(definition)
|
|
91
|
+
with invocation_span(self._runtime.tracer, f"publish {definition.name}"):
|
|
92
|
+
encoded = definition._encode(payload, capture_trace_context(self._runtime.tracer))
|
|
93
|
+
if len(encoded) > self._options.max_message_bytes:
|
|
94
|
+
raise NotificationPayloadError("Notification exceeds configured message size")
|
|
95
|
+
try:
|
|
96
|
+
await asyncio.wait_for(
|
|
97
|
+
self._client.publish(self._prefix + definition.name, encoded),
|
|
98
|
+
timeout=self._options.publish_timeout_seconds,
|
|
99
|
+
)
|
|
100
|
+
except (TimeoutError, RedisError):
|
|
101
|
+
self._report_outage()
|
|
102
|
+
return NotificationPublishStatus.UNAVAILABLE
|
|
103
|
+
return NotificationPublishStatus.PUBLISHED
|
|
104
|
+
|
|
105
|
+
@asynccontextmanager
|
|
106
|
+
async def subscribe(
|
|
107
|
+
self,
|
|
108
|
+
definition: NotificationDefinition[_T],
|
|
109
|
+
handler: Callable[[_T], Awaitable[None]],
|
|
110
|
+
*,
|
|
111
|
+
on_reconnect: Callable[[], None] | None = None,
|
|
112
|
+
) -> AsyncIterator[None]:
|
|
113
|
+
self._catalog.require_owned(definition)
|
|
114
|
+
if not callable(handler) or (on_reconnect is not None and not callable(on_reconnect)):
|
|
115
|
+
raise NotificationDefinitionError("Notification callbacks must be callable")
|
|
116
|
+
channel = self._prefix + definition.name
|
|
117
|
+
token = object()
|
|
118
|
+
subscriber = _Subscriber(cast(Callable[[Any], Awaitable[None]], handler), on_reconnect)
|
|
119
|
+
self._subscribers[channel][token] = subscriber
|
|
120
|
+
try:
|
|
121
|
+
if channel in self._connected and on_reconnect is not None:
|
|
122
|
+
on_reconnect()
|
|
123
|
+
yield
|
|
124
|
+
finally:
|
|
125
|
+
self._subscribers[channel].pop(token)
|
|
126
|
+
|
|
127
|
+
async def _listen(self) -> None:
|
|
128
|
+
if not self._channels:
|
|
129
|
+
await asyncio.Future[None]()
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
async with self._client.pubsub() as pubsub:
|
|
133
|
+
await pubsub.subscribe(*self._channels)
|
|
134
|
+
while True:
|
|
135
|
+
message = await pubsub.get_message(
|
|
136
|
+
ignore_subscribe_messages=False, timeout=1
|
|
137
|
+
)
|
|
138
|
+
if message is not None:
|
|
139
|
+
await self._receive(message)
|
|
140
|
+
except RedisError:
|
|
141
|
+
self._connected.clear()
|
|
142
|
+
self._report_outage()
|
|
143
|
+
await asyncio.sleep(self._options.reconnect_interval_seconds)
|
|
144
|
+
|
|
145
|
+
async def _receive(self, message: dict[str, Any]) -> None:
|
|
146
|
+
channel = message["channel"].decode("utf-8")
|
|
147
|
+
if message["type"] == "subscribe":
|
|
148
|
+
self._connected.add(channel)
|
|
149
|
+
self._outage_reported = False
|
|
150
|
+
for subscriber in tuple(self._subscribers[channel].values()):
|
|
151
|
+
if subscriber.on_reconnect is not None:
|
|
152
|
+
try:
|
|
153
|
+
subscriber.on_reconnect()
|
|
154
|
+
except Exception as error:
|
|
155
|
+
_LOGGER.error(
|
|
156
|
+
"Notification reconnect handler failed: %s", type(error).__name__
|
|
157
|
+
)
|
|
158
|
+
elif message["type"] == "message":
|
|
159
|
+
await self._deliver(channel, message["data"])
|
|
160
|
+
|
|
161
|
+
async def _deliver(self, channel: str, encoded: bytes) -> None:
|
|
162
|
+
definition = self._channels[channel]
|
|
163
|
+
try:
|
|
164
|
+
if len(encoded) > self._options.max_message_bytes:
|
|
165
|
+
raise NotificationPayloadError("Notification exceeds configured message size")
|
|
166
|
+
payload, carrier = definition._decode(encoded)
|
|
167
|
+
except NotificationPayloadError:
|
|
168
|
+
_LOGGER.warning("Invalid notification envelope ignored")
|
|
169
|
+
return
|
|
170
|
+
with invocation_span(self._runtime.tracer, f"receive {definition.name}", carrier=carrier):
|
|
171
|
+
for token, subscriber in tuple(self._subscribers[channel].items()):
|
|
172
|
+
if token not in self._subscribers[channel]:
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
await subscriber.handler(payload)
|
|
176
|
+
except Exception as error:
|
|
177
|
+
_LOGGER.error("Notification handler failed: %s", type(error).__name__)
|
|
178
|
+
|
|
179
|
+
def _report_outage(self) -> None:
|
|
180
|
+
if not self._outage_reported:
|
|
181
|
+
_LOGGER.warning("Redis notifications temporarily unavailable")
|
|
182
|
+
self._outage_reported = True
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Host 按需启用 Redis 通知;复用原生 Hosted Service 监督。"""
|
|
2
|
+
|
|
3
|
+
from dishka import Provider, Scope
|
|
4
|
+
|
|
5
|
+
from ..lifecycle import ConfigureContext
|
|
6
|
+
from ..modularity import AppModule
|
|
7
|
+
from ..notifications import Notifications
|
|
8
|
+
from .module import RedisConnectionModule
|
|
9
|
+
from .notification_runtime import NotificationOptions, RedisNotifications
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisNotificationsModule(AppModule):
|
|
13
|
+
dependencies = (RedisConnectionModule,)
|
|
14
|
+
hosted_services = (RedisNotifications,)
|
|
15
|
+
|
|
16
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
17
|
+
context.configure(NotificationOptions, section="notifications")
|
|
18
|
+
provider = Provider()
|
|
19
|
+
provider.provide(_notifications, provides=Notifications, scope=Scope.APP)
|
|
20
|
+
context.services.contribute(provider, reason="Redis typed Pub/Sub")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _notifications(runtime: RedisNotifications) -> Notifications:
|
|
24
|
+
# 正式 catalog 只接纳 provide;同一实例承担通知契约与 Hosted Service 生命周期。
|
|
25
|
+
return runtime
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Redis connection 与统一 key prefix Options。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import SecretStr, field_validator
|
|
6
|
+
|
|
7
|
+
from ..options import BaseOptions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisOptions(BaseOptions):
|
|
11
|
+
url: SecretStr
|
|
12
|
+
key_prefix: str = "python-platform"
|
|
13
|
+
|
|
14
|
+
@field_validator("url")
|
|
15
|
+
@classmethod
|
|
16
|
+
def _validate_url(cls, value: SecretStr) -> SecretStr:
|
|
17
|
+
raw = value.get_secret_value()
|
|
18
|
+
scheme, separator, remainder = raw.partition("://")
|
|
19
|
+
if scheme not in {"redis", "rediss"} or not separator or not remainder:
|
|
20
|
+
raise ValueError("Redis URL must use redis:// or rediss://")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
@field_validator("key_prefix")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _validate_key_prefix(cls, value: str) -> str:
|
|
26
|
+
if (
|
|
27
|
+
not value
|
|
28
|
+
or value != value.strip()
|
|
29
|
+
or value.startswith(":")
|
|
30
|
+
or value.endswith(":")
|
|
31
|
+
or "::" in value
|
|
32
|
+
or len(value) > 128
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("Redis key_prefix must be a trimmed identifier")
|
|
35
|
+
return value
|