omnibase_infra 0.3.1__py3-none-any.whl → 0.4.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.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
- omnibase_infra/enums/enum_postgres_error_code.py +188 -0
- omnibase_infra/errors/__init__.py +4 -0
- omnibase_infra/errors/error_infra.py +60 -0
- omnibase_infra/handlers/__init__.py +3 -0
- omnibase_infra/handlers/handler_slack_webhook.py +426 -0
- omnibase_infra/handlers/models/__init__.py +14 -0
- omnibase_infra/handlers/models/enum_alert_severity.py +36 -0
- omnibase_infra/handlers/models/model_slack_alert.py +24 -0
- omnibase_infra/handlers/models/model_slack_alert_payload.py +77 -0
- omnibase_infra/handlers/models/model_slack_alert_result.py +73 -0
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
- omnibase_infra/mixins/__init__.py +14 -0
- omnibase_infra/mixins/mixin_node_introspection.py +42 -20
- omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
- omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
- omnibase_infra/models/__init__.py +3 -0
- omnibase_infra/models/discovery/model_dependency_spec.py +1 -0
- omnibase_infra/models/discovery/model_discovered_capabilities.py +1 -1
- omnibase_infra/models/discovery/model_introspection_config.py +28 -1
- omnibase_infra/models/discovery/model_introspection_performance_metrics.py +1 -0
- omnibase_infra/models/discovery/model_introspection_task_config.py +1 -0
- omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
- omnibase_infra/models/projection/__init__.py +11 -0
- omnibase_infra/models/projection/model_contract_projection.py +170 -0
- omnibase_infra/models/projection/model_topic_projection.py +148 -0
- omnibase_infra/models/runtime/__init__.py +4 -0
- omnibase_infra/models/runtime/model_resolved_dependencies.py +116 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +6 -5
- omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +9 -26
- omnibase_infra/nodes/effects/__init__.py +1 -1
- omnibase_infra/nodes/effects/models/__init__.py +6 -4
- omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
- omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
- omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
- omnibase_infra/nodes/effects/registry_effect.py +1 -1
- omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
- omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
- omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
- omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
- omnibase_infra/nodes/node_contract_persistence_effect/node.py +131 -0
- omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
- omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +251 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +8 -12
- omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
- omnibase_infra/nodes/node_slack_alerter_effect/__init__.py +33 -0
- omnibase_infra/nodes/node_slack_alerter_effect/contract.yaml +291 -0
- omnibase_infra/nodes/node_slack_alerter_effect/node.py +106 -0
- omnibase_infra/projectors/__init__.py +6 -0
- omnibase_infra/projectors/projection_reader_contract.py +1301 -0
- omnibase_infra/runtime/__init__.py +12 -0
- omnibase_infra/runtime/baseline_subscriptions.py +13 -6
- omnibase_infra/runtime/contract_dependency_resolver.py +455 -0
- omnibase_infra/runtime/contract_registration_event_router.py +500 -0
- omnibase_infra/runtime/db/__init__.py +4 -0
- omnibase_infra/runtime/db/models/__init__.py +15 -10
- omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
- omnibase_infra/runtime/db/models/model_db_param.py +24 -0
- omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
- omnibase_infra/runtime/db/models/model_db_return.py +26 -0
- omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
- omnibase_infra/runtime/emit_daemon/event_registry.py +34 -22
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +63 -23
- omnibase_infra/runtime/intent_execution_router.py +430 -0
- omnibase_infra/runtime/models/__init__.py +6 -0
- omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
- omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
- omnibase_infra/runtime/models/model_runtime_config.py +8 -0
- omnibase_infra/runtime/protocols/__init__.py +16 -0
- omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
- omnibase_infra/runtime/publisher_topic_scoped.py +16 -11
- omnibase_infra/runtime/registry_policy.py +29 -15
- omnibase_infra/runtime/request_response_wiring.py +793 -0
- omnibase_infra/runtime/service_kernel.py +295 -8
- omnibase_infra/runtime/service_runtime_host_process.py +149 -5
- omnibase_infra/runtime/util_version.py +5 -1
- omnibase_infra/schemas/schema_latency_baseline.sql +135 -0
- omnibase_infra/services/contract_publisher/config.py +4 -4
- omnibase_infra/services/contract_publisher/service.py +8 -5
- omnibase_infra/services/observability/injection_effectiveness/__init__.py +67 -0
- omnibase_infra/services/observability/injection_effectiveness/config.py +295 -0
- omnibase_infra/services/observability/injection_effectiveness/consumer.py +1461 -0
- omnibase_infra/services/observability/injection_effectiveness/models/__init__.py +32 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_agent_match.py +79 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_context_utilization.py +118 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_latency_breakdown.py +107 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_pattern_utilization.py +46 -0
- omnibase_infra/services/observability/injection_effectiveness/writer_postgres.py +596 -0
- omnibase_infra/services/registry_api/models/__init__.py +25 -0
- omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
- omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
- omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
- omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
- omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
- omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
- omnibase_infra/services/registry_api/routes.py +205 -6
- omnibase_infra/services/registry_api/service.py +528 -1
- omnibase_infra/utils/__init__.py +7 -0
- omnibase_infra/utils/util_db_error_context.py +292 -0
- omnibase_infra/validation/infra_validators.py +3 -1
- omnibase_infra/validation/validation_exemptions.yaml +65 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/METADATA +3 -3
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/RECORD +117 -58
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Contract registration event router.
|
|
4
|
+
|
|
5
|
+
Routes Kafka messages to ContractRegistryReducer and executes resulting intents.
|
|
6
|
+
|
|
7
|
+
This module provides an extracted event router for routing contract lifecycle
|
|
8
|
+
events (registration, deregistration, heartbeat) to the ContractRegistryReducer.
|
|
9
|
+
The router also runs an internal tick timer for periodic staleness computation.
|
|
10
|
+
|
|
11
|
+
Design:
|
|
12
|
+
This class encapsulates the message routing logic for contract registry
|
|
13
|
+
projection. By extracting it, we enable:
|
|
14
|
+
- Unit testing without full kernel bootstrap
|
|
15
|
+
- Mocking of dependencies for isolation
|
|
16
|
+
- Clearer separation between bootstrap and event routing
|
|
17
|
+
|
|
18
|
+
The router uses ProtocolEventBusLike for event publishing, enabling
|
|
19
|
+
duck typing with any event bus implementation (Kafka, InMemory, etc.).
|
|
20
|
+
|
|
21
|
+
Message Flow:
|
|
22
|
+
1. Parse message as ModelEventEnvelope[dict]
|
|
23
|
+
2. Validate payload as event type (ModelContractRegisteredEvent,
|
|
24
|
+
ModelContractDeregisteredEvent, ModelNodeHeartbeatEvent)
|
|
25
|
+
3. Call reducer.reduce(state, event, metadata)
|
|
26
|
+
4. Execute returned intents via _execute_intents()
|
|
27
|
+
5. Log errors (no exceptions raised to consumer)
|
|
28
|
+
|
|
29
|
+
Related:
|
|
30
|
+
- OMN-1869: Wire ServiceKernel to Kafka event bus
|
|
31
|
+
- IntrospectionEventRouter: Reference implementation for event routing
|
|
32
|
+
- ContractRegistryReducer: Pure reducer handling contract events
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
__all__ = ["ContractRegistrationEventRouter"]
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import time
|
|
43
|
+
from datetime import UTC, datetime
|
|
44
|
+
from typing import TYPE_CHECKING, Protocol
|
|
45
|
+
from uuid import UUID, uuid4
|
|
46
|
+
|
|
47
|
+
from pydantic import ValidationError
|
|
48
|
+
|
|
49
|
+
from omnibase_core.models.events import (
|
|
50
|
+
ModelContractDeregisteredEvent,
|
|
51
|
+
ModelContractRegisteredEvent,
|
|
52
|
+
ModelNodeHeartbeatEvent,
|
|
53
|
+
)
|
|
54
|
+
from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope
|
|
55
|
+
from omnibase_core.types import JsonType
|
|
56
|
+
from omnibase_infra.event_bus.models.model_event_message import ModelEventMessage
|
|
57
|
+
from omnibase_infra.nodes.contract_registry_reducer.models.model_contract_registry_state import (
|
|
58
|
+
ModelContractRegistryState,
|
|
59
|
+
)
|
|
60
|
+
from omnibase_infra.nodes.contract_registry_reducer.reducer import (
|
|
61
|
+
ContractRegistryEvent,
|
|
62
|
+
ContractRegistryReducer,
|
|
63
|
+
)
|
|
64
|
+
from omnibase_infra.runtime.models.model_runtime_tick import ModelRuntimeTick
|
|
65
|
+
from omnibase_infra.utils import sanitize_error_message
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from omnibase_core.container import ModelONEXContainer
|
|
69
|
+
from omnibase_core.models.reducer.model_intent import ModelIntent
|
|
70
|
+
from omnibase_infra.protocols import ProtocolEventBusLike
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
# Minimum tick interval to prevent excessive CPU usage
|
|
75
|
+
MIN_TICK_INTERVAL_SECONDS = 5
|
|
76
|
+
|
|
77
|
+
# Scheduler ID for tick events from this router
|
|
78
|
+
ROUTER_SCHEDULER_ID = "contract-registration-event-router"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProtocolIntentEffect(Protocol):
|
|
82
|
+
"""Protocol for intent effect executors.
|
|
83
|
+
|
|
84
|
+
Intent effects are responsible for executing side effects (e.g., PostgreSQL
|
|
85
|
+
writes) based on intents emitted by the reducer. Each effect executor is keyed
|
|
86
|
+
by the payload's intent_type field (e.g., "postgres.upsert_contract").
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
async def handle(self, payload: object, correlation_id: UUID) -> object:
|
|
90
|
+
"""Execute the intent.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
payload: The typed payload model (e.g., ModelPayloadUpsertContract).
|
|
94
|
+
correlation_id: Correlation ID for distributed tracing.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Result from the effect executor (typically ModelBackendResult).
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ContractRegistrationEventRouter:
|
|
103
|
+
"""Routes contract lifecycle events to reducer and executes intents.
|
|
104
|
+
|
|
105
|
+
This router handles incoming event messages from Kafka, parses them as
|
|
106
|
+
contract lifecycle events, and routes them to the ContractRegistryReducer.
|
|
107
|
+
It also maintains an internal tick timer for periodic staleness computation.
|
|
108
|
+
|
|
109
|
+
The router propagates correlation IDs from incoming messages for
|
|
110
|
+
distributed tracing. If no correlation ID is present, it generates
|
|
111
|
+
a new one to ensure all operations can be traced.
|
|
112
|
+
|
|
113
|
+
This class follows the container-based dependency injection pattern,
|
|
114
|
+
receiving a ModelONEXContainer for service resolution while also
|
|
115
|
+
accepting explicit dependencies for router-specific configuration.
|
|
116
|
+
|
|
117
|
+
Message Flow:
|
|
118
|
+
1. Parse message as ModelEventEnvelope[dict]
|
|
119
|
+
2. Validate payload as event type (ModelContractRegisteredEvent, etc.)
|
|
120
|
+
3. Call reducer.reduce(state, event, metadata)
|
|
121
|
+
4. Execute returned intents via _execute_intents()
|
|
122
|
+
5. Log errors (no exceptions raised to consumer)
|
|
123
|
+
|
|
124
|
+
Tick Timer:
|
|
125
|
+
The router runs an internal tick timer at configurable intervals
|
|
126
|
+
(default 60s, minimum 5s). Each tick emits a ModelRuntimeTick event
|
|
127
|
+
to the reducer for staleness computation.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
_container: ONEX service container for dependency resolution.
|
|
131
|
+
_reducer: The ContractRegistryReducer to route events to.
|
|
132
|
+
_effect_handlers: Dict mapping intent_type to handler instances.
|
|
133
|
+
_event_bus: Event bus implementing ProtocolEventBusLike (optional).
|
|
134
|
+
_tick_interval_seconds: Interval between staleness ticks.
|
|
135
|
+
_state: Current reducer state (mutable, updated after each reduction).
|
|
136
|
+
_shutdown_event: Event for graceful shutdown of tick loop.
|
|
137
|
+
_tick_task: Background task for tick loop.
|
|
138
|
+
_tick_sequence: Monotonically increasing counter for tick events.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
>>> from omnibase_core.container import ModelONEXContainer
|
|
142
|
+
>>> container = ModelONEXContainer()
|
|
143
|
+
>>> router = ContractRegistrationEventRouter(
|
|
144
|
+
... container=container,
|
|
145
|
+
... reducer=reducer,
|
|
146
|
+
... effect_handlers={"postgres.upsert_contract": upsert_handler},
|
|
147
|
+
... event_bus=event_bus,
|
|
148
|
+
... tick_interval_seconds=60,
|
|
149
|
+
... )
|
|
150
|
+
>>> await router.start()
|
|
151
|
+
>>> # Use as callback for event bus subscription
|
|
152
|
+
>>> await event_bus.subscribe(
|
|
153
|
+
... topic="contract-registered",
|
|
154
|
+
... group_id="contract-registry",
|
|
155
|
+
... on_message=router.handle_message,
|
|
156
|
+
... )
|
|
157
|
+
|
|
158
|
+
See Also:
|
|
159
|
+
- IntrospectionEventRouter: Reference implementation for event routing
|
|
160
|
+
- ContractRegistryReducer: Pure reducer for contract events
|
|
161
|
+
- docs/patterns/container_dependency_injection.md for DI patterns
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
container: ModelONEXContainer,
|
|
167
|
+
reducer: ContractRegistryReducer,
|
|
168
|
+
effect_handlers: dict[str, ProtocolIntentEffect],
|
|
169
|
+
event_bus: ProtocolEventBusLike | None = None,
|
|
170
|
+
tick_interval_seconds: int = 60,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Initialize ContractRegistrationEventRouter with container-based DI.
|
|
173
|
+
|
|
174
|
+
Follows the ONEX container-based DI pattern where the container is passed
|
|
175
|
+
as the first parameter for service resolution, with additional explicit
|
|
176
|
+
parameters for router-specific configuration.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
container: ONEX service container for dependency resolution. Provides
|
|
180
|
+
access to service_registry for resolving shared services.
|
|
181
|
+
reducer: The ContractRegistryReducer to route events to.
|
|
182
|
+
effect_handlers: Dict mapping intent_type (e.g., "postgres.upsert_contract")
|
|
183
|
+
to handler instances that implement ProtocolIntentEffect.
|
|
184
|
+
event_bus: Event bus implementing ProtocolEventBusLike for publishing
|
|
185
|
+
(optional, only needed if router publishes output events).
|
|
186
|
+
tick_interval_seconds: Interval between staleness tick events.
|
|
187
|
+
Clamped to minimum of 5 seconds to prevent excessive CPU usage.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
>>> from omnibase_core.container import ModelONEXContainer
|
|
191
|
+
>>> container = ModelONEXContainer()
|
|
192
|
+
>>> router = ContractRegistrationEventRouter(
|
|
193
|
+
... container=container,
|
|
194
|
+
... reducer=reducer,
|
|
195
|
+
... effect_handlers={"postgres.upsert_contract": handler},
|
|
196
|
+
... tick_interval_seconds=60,
|
|
197
|
+
... )
|
|
198
|
+
|
|
199
|
+
See Also:
|
|
200
|
+
- docs/patterns/container_dependency_injection.md for DI patterns.
|
|
201
|
+
"""
|
|
202
|
+
self._container = container
|
|
203
|
+
self._reducer = reducer
|
|
204
|
+
self._effect_handlers = effect_handlers
|
|
205
|
+
self._event_bus = event_bus
|
|
206
|
+
# Clamp tick interval to minimum of 5 seconds
|
|
207
|
+
self._tick_interval_seconds = max(
|
|
208
|
+
MIN_TICK_INTERVAL_SECONDS, tick_interval_seconds
|
|
209
|
+
)
|
|
210
|
+
self._state: ModelContractRegistryState = ModelContractRegistryState()
|
|
211
|
+
self._shutdown_event = asyncio.Event()
|
|
212
|
+
self._tick_task: asyncio.Task[None] | None = None
|
|
213
|
+
self._tick_sequence: int = 0
|
|
214
|
+
|
|
215
|
+
logger.debug(
|
|
216
|
+
"ContractRegistrationEventRouter initialized",
|
|
217
|
+
extra={
|
|
218
|
+
"tick_interval_seconds": self._tick_interval_seconds,
|
|
219
|
+
"handler_count": len(self._effect_handlers),
|
|
220
|
+
"handlers": list(self._effect_handlers.keys()),
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def container(self) -> ModelONEXContainer:
|
|
226
|
+
"""Return the ONEX service container.
|
|
227
|
+
|
|
228
|
+
The ModelONEXContainer provides protocol-based service resolution.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The ModelONEXContainer instance passed during initialization.
|
|
232
|
+
"""
|
|
233
|
+
return self._container
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def state(self) -> ModelContractRegistryState:
|
|
237
|
+
"""Return the current reducer state.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Current immutable state of the contract registry reducer.
|
|
241
|
+
"""
|
|
242
|
+
return self._state
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def tick_interval_seconds(self) -> int:
|
|
246
|
+
"""Return the configured tick interval.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Tick interval in seconds (clamped to minimum 5s).
|
|
250
|
+
"""
|
|
251
|
+
return self._tick_interval_seconds
|
|
252
|
+
|
|
253
|
+
async def start(self) -> None:
|
|
254
|
+
"""Start internal tick timer.
|
|
255
|
+
|
|
256
|
+
Starts a background task that periodically emits ModelRuntimeTick
|
|
257
|
+
events to the reducer for staleness computation.
|
|
258
|
+
"""
|
|
259
|
+
self._shutdown_event.clear()
|
|
260
|
+
self._tick_task = asyncio.create_task(self._tick_loop())
|
|
261
|
+
logger.info(
|
|
262
|
+
"ContractRegistrationEventRouter started with tick_interval=%ds",
|
|
263
|
+
self._tick_interval_seconds,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def stop(self) -> None:
|
|
267
|
+
"""Stop tick timer.
|
|
268
|
+
|
|
269
|
+
Signals the tick loop to stop and waits for it to complete.
|
|
270
|
+
"""
|
|
271
|
+
self._shutdown_event.set()
|
|
272
|
+
if self._tick_task:
|
|
273
|
+
self._tick_task.cancel()
|
|
274
|
+
try:
|
|
275
|
+
await self._tick_task
|
|
276
|
+
except asyncio.CancelledError:
|
|
277
|
+
pass
|
|
278
|
+
self._tick_task = None
|
|
279
|
+
logger.info("ContractRegistrationEventRouter stopped")
|
|
280
|
+
|
|
281
|
+
def _extract_correlation_id_from_message(self, msg: ModelEventMessage) -> UUID:
|
|
282
|
+
"""Extract correlation ID from message headers or generate new one.
|
|
283
|
+
|
|
284
|
+
Attempts to extract the correlation_id from message headers to ensure
|
|
285
|
+
proper propagation for distributed tracing. Falls back to generating
|
|
286
|
+
a new UUID if no correlation ID is found.
|
|
287
|
+
|
|
288
|
+
Uses duck-typing patterns for type detection instead of isinstance checks
|
|
289
|
+
to align with protocol-based design principles.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
msg: The incoming event message.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
UUID: The extracted or generated correlation ID.
|
|
296
|
+
"""
|
|
297
|
+
# Try to extract from message headers if available
|
|
298
|
+
if hasattr(msg, "headers") and msg.headers is not None:
|
|
299
|
+
headers = msg.headers
|
|
300
|
+
if (
|
|
301
|
+
hasattr(headers, "correlation_id")
|
|
302
|
+
and headers.correlation_id is not None
|
|
303
|
+
):
|
|
304
|
+
try:
|
|
305
|
+
correlation_id = headers.correlation_id
|
|
306
|
+
# Check for bytes-like (has decode method) - duck typing
|
|
307
|
+
if hasattr(correlation_id, "decode"):
|
|
308
|
+
correlation_id = correlation_id.decode("utf-8")
|
|
309
|
+
return UUID(str(correlation_id))
|
|
310
|
+
except (ValueError, TypeError, UnicodeDecodeError, AttributeError):
|
|
311
|
+
pass # Fall through to try payload extraction
|
|
312
|
+
|
|
313
|
+
# If we can peek at the payload, try to extract correlation_id
|
|
314
|
+
try:
|
|
315
|
+
if msg.value is not None:
|
|
316
|
+
# Duck-type: check for decode method (bytes-like) first
|
|
317
|
+
if hasattr(msg.value, "decode"):
|
|
318
|
+
payload_dict = json.loads(msg.value.decode("utf-8"))
|
|
319
|
+
else:
|
|
320
|
+
try:
|
|
321
|
+
payload_dict = json.loads(msg.value)
|
|
322
|
+
except TypeError:
|
|
323
|
+
payload_dict = msg.value
|
|
324
|
+
|
|
325
|
+
if payload_dict:
|
|
326
|
+
# Check envelope-level correlation_id first
|
|
327
|
+
if "correlation_id" in payload_dict:
|
|
328
|
+
return UUID(str(payload_dict["correlation_id"]))
|
|
329
|
+
# Check payload-level correlation_id
|
|
330
|
+
payload_content = payload_dict.get("payload")
|
|
331
|
+
if payload_content and hasattr(payload_content, "get"):
|
|
332
|
+
nested_corr_id = payload_content.get("correlation_id")
|
|
333
|
+
if nested_corr_id is not None:
|
|
334
|
+
return UUID(str(nested_corr_id))
|
|
335
|
+
except (json.JSONDecodeError, ValueError, TypeError, KeyError, AttributeError):
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
# Generate new correlation ID as last resort
|
|
339
|
+
return uuid4()
|
|
340
|
+
|
|
341
|
+
async def handle_message(self, msg: ModelEventMessage) -> None:
|
|
342
|
+
"""Route Kafka message to reducer, execute intents.
|
|
343
|
+
|
|
344
|
+
This callback is invoked for each message received on contract topics.
|
|
345
|
+
It parses the raw JSON payload as a contract lifecycle event and routes
|
|
346
|
+
it to the ContractRegistryReducer for processing.
|
|
347
|
+
|
|
348
|
+
The method propagates the correlation_id from the incoming message
|
|
349
|
+
for distributed tracing. If no correlation_id is present in the message,
|
|
350
|
+
a new one is generated.
|
|
351
|
+
|
|
352
|
+
Error Handling:
|
|
353
|
+
Errors are logged but not raised to the consumer. This ensures
|
|
354
|
+
message processing continues even if individual messages fail.
|
|
355
|
+
Failed messages should be handled via dead-letter queue (DLQ)
|
|
356
|
+
at the event bus level.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
msg: ModelEventMessage from Kafka consumer containing the serialized
|
|
360
|
+
event envelope in .value field.
|
|
361
|
+
"""
|
|
362
|
+
callback_correlation_id = self._extract_correlation_id_from_message(msg)
|
|
363
|
+
callback_start_time = time.time()
|
|
364
|
+
|
|
365
|
+
logger.debug(
|
|
366
|
+
"Contract event message callback invoked (correlation_id=%s)",
|
|
367
|
+
callback_correlation_id,
|
|
368
|
+
extra={
|
|
369
|
+
"message_offset": getattr(msg, "offset", None),
|
|
370
|
+
"message_partition": getattr(msg, "partition", None),
|
|
371
|
+
"message_topic": getattr(msg, "topic", None),
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
# ModelEventMessage has .value as bytes
|
|
377
|
+
if msg.value is None:
|
|
378
|
+
logger.debug(
|
|
379
|
+
"Message value is None, skipping (correlation_id=%s)",
|
|
380
|
+
callback_correlation_id,
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Parse message value using duck-typing patterns
|
|
385
|
+
if hasattr(msg.value, "decode"):
|
|
386
|
+
payload_dict = json.loads(msg.value.decode("utf-8"))
|
|
387
|
+
else:
|
|
388
|
+
try:
|
|
389
|
+
payload_dict = json.loads(msg.value)
|
|
390
|
+
except TypeError:
|
|
391
|
+
if hasattr(msg.value, "keys"):
|
|
392
|
+
payload_dict = msg.value
|
|
393
|
+
else:
|
|
394
|
+
logger.debug(
|
|
395
|
+
"Unexpected message value type: %s (correlation_id=%s)",
|
|
396
|
+
type(msg.value).__name__,
|
|
397
|
+
callback_correlation_id,
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
# Parse as ModelEventEnvelope containing contract event
|
|
402
|
+
raw_envelope = ModelEventEnvelope[dict].model_validate(payload_dict)
|
|
403
|
+
|
|
404
|
+
# Try to validate payload as one of the contract event types
|
|
405
|
+
event: ContractRegistryEvent | None = None
|
|
406
|
+
event_type_name: str = ""
|
|
407
|
+
|
|
408
|
+
# Try ModelContractRegisteredEvent first
|
|
409
|
+
try:
|
|
410
|
+
event = ModelContractRegisteredEvent.model_validate(
|
|
411
|
+
raw_envelope.payload
|
|
412
|
+
)
|
|
413
|
+
event_type_name = "ContractRegisteredEvent"
|
|
414
|
+
except ValidationError:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# Try ModelContractDeregisteredEvent
|
|
418
|
+
if event is None:
|
|
419
|
+
try:
|
|
420
|
+
event = ModelContractDeregisteredEvent.model_validate(
|
|
421
|
+
raw_envelope.payload
|
|
422
|
+
)
|
|
423
|
+
event_type_name = "ContractDeregisteredEvent"
|
|
424
|
+
except ValidationError:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
# Try ModelNodeHeartbeatEvent
|
|
428
|
+
if event is None:
|
|
429
|
+
try:
|
|
430
|
+
event = ModelNodeHeartbeatEvent.model_validate(raw_envelope.payload)
|
|
431
|
+
event_type_name = "NodeHeartbeatEvent"
|
|
432
|
+
except ValidationError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
if event is None:
|
|
436
|
+
# Not a recognized contract event - skip silently
|
|
437
|
+
logger.debug(
|
|
438
|
+
"Message is not a valid contract event, skipping (correlation_id=%s)",
|
|
439
|
+
callback_correlation_id,
|
|
440
|
+
)
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
logger.info(
|
|
444
|
+
"Parsed %s (correlation_id=%s)",
|
|
445
|
+
event_type_name,
|
|
446
|
+
callback_correlation_id,
|
|
447
|
+
extra={
|
|
448
|
+
"envelope_id": str(raw_envelope.envelope_id),
|
|
449
|
+
"event_type": event_type_name,
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Build event metadata from Kafka message
|
|
454
|
+
event_metadata: dict[str, JsonType] = {
|
|
455
|
+
"topic": msg.topic,
|
|
456
|
+
"partition": msg.partition or 0,
|
|
457
|
+
"offset": int(msg.offset) if msg.offset else 0,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Call reducer
|
|
461
|
+
reducer_start_time = time.time()
|
|
462
|
+
output = self._reducer.reduce(self._state, event, event_metadata)
|
|
463
|
+
reducer_duration = time.time() - reducer_start_time
|
|
464
|
+
|
|
465
|
+
# Update state
|
|
466
|
+
self._state = output.result
|
|
467
|
+
|
|
468
|
+
logger.info(
|
|
469
|
+
"Reducer processed %s in %.3fs (correlation_id=%s)",
|
|
470
|
+
event_type_name,
|
|
471
|
+
reducer_duration,
|
|
472
|
+
callback_correlation_id,
|
|
473
|
+
extra={
|
|
474
|
+
"intents_count": len(output.intents),
|
|
475
|
+
"processing_time_ms": output.processing_time_ms,
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Execute intents
|
|
480
|
+
if output.intents:
|
|
481
|
+
await self._execute_intents(output.intents, callback_correlation_id)
|
|
482
|
+
|
|
483
|
+
except ValidationError as validation_error:
|
|
484
|
+
logger.debug(
|
|
485
|
+
"Message validation failed, skipping (correlation_id=%s)",
|
|
486
|
+
callback_correlation_id,
|
|
487
|
+
extra={
|
|
488
|
+
"validation_error_count": validation_error.error_count(),
|
|
489
|
+
},
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
except json.JSONDecodeError as json_error:
|
|
493
|
+
logger.warning(
|
|
494
|
+
"Failed to decode JSON from message: %s (correlation_id=%s)",
|
|
495
|
+
sanitize_error_message(json_error),
|
|
496
|
+
callback_correlation_id,
|
|
497
|
+
extra={
|
|
498
|
+
"error_type": type(json_error).__name__,
|
|
499
|
+
"error_position": getattr(json_error, "pos", None),
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
except Exception as msg_error:
|
|
504
|
+
# Use warning instead of exception to avoid credential exposure
|
|
505
|
+
logger.warning(
|
|
506
|
+
"Failed to process contract message: %s (correlation_id=%s)",
|
|
507
|
+
sanitize_error_message(msg_error),
|
|
508
|
+
callback_correlation_id,
|
|
509
|
+
extra={
|
|
510
|
+
"error_type": type(msg_error).__name__,
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
finally:
|
|
515
|
+
callback_duration = time.time() - callback_start_time
|
|
516
|
+
logger.debug(
|
|
517
|
+
"Contract message callback completed in %.3fs (correlation_id=%s)",
|
|
518
|
+
callback_duration,
|
|
519
|
+
callback_correlation_id,
|
|
520
|
+
extra={
|
|
521
|
+
"callback_duration_seconds": callback_duration,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def _execute_intents(
|
|
526
|
+
self,
|
|
527
|
+
intents: tuple[ModelIntent, ...],
|
|
528
|
+
correlation_id: UUID,
|
|
529
|
+
) -> None:
|
|
530
|
+
"""Dispatch intents to effect handlers by payload.intent_type.
|
|
531
|
+
|
|
532
|
+
Each intent has a payload with an intent_type field (e.g.,
|
|
533
|
+
"postgres.upsert_contract"). This method looks up the appropriate
|
|
534
|
+
handler and executes it.
|
|
535
|
+
|
|
536
|
+
Error Handling:
|
|
537
|
+
Errors are logged but not raised. If a handler fails, we continue
|
|
538
|
+
processing remaining intents.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
intents: Tuple of ModelIntent objects from the reducer.
|
|
542
|
+
correlation_id: Correlation ID for distributed tracing.
|
|
543
|
+
"""
|
|
544
|
+
for intent in intents:
|
|
545
|
+
try:
|
|
546
|
+
# Extract intent_type from payload
|
|
547
|
+
payload = intent.payload
|
|
548
|
+
intent_type = getattr(payload, "intent_type", None)
|
|
549
|
+
|
|
550
|
+
if intent_type is None:
|
|
551
|
+
logger.warning(
|
|
552
|
+
"Intent payload missing intent_type field (correlation_id=%s)",
|
|
553
|
+
correlation_id,
|
|
554
|
+
extra={"intent_target": intent.target},
|
|
555
|
+
)
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
if intent_type not in self._effect_handlers:
|
|
559
|
+
logger.warning(
|
|
560
|
+
"No handler for intent_type: %s (correlation_id=%s)",
|
|
561
|
+
intent_type,
|
|
562
|
+
correlation_id,
|
|
563
|
+
extra={
|
|
564
|
+
"available_handlers": list(self._effect_handlers.keys()),
|
|
565
|
+
"intent_target": intent.target,
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
handler = self._effect_handlers[intent_type]
|
|
571
|
+
handler_start_time = time.time()
|
|
572
|
+
|
|
573
|
+
# Extract correlation_id from payload if available
|
|
574
|
+
payload_correlation_id = getattr(
|
|
575
|
+
payload, "correlation_id", correlation_id
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
result = await handler.handle(payload, payload_correlation_id)
|
|
579
|
+
handler_duration = time.time() - handler_start_time
|
|
580
|
+
|
|
581
|
+
# Check if handler returned a result with success field
|
|
582
|
+
success = getattr(result, "success", True) if result else True
|
|
583
|
+
|
|
584
|
+
if success:
|
|
585
|
+
logger.debug(
|
|
586
|
+
"Intent %s executed successfully in %.3fs (correlation_id=%s)",
|
|
587
|
+
intent_type,
|
|
588
|
+
handler_duration,
|
|
589
|
+
correlation_id,
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
error_msg = getattr(result, "error", "Unknown error")
|
|
593
|
+
logger.warning(
|
|
594
|
+
"Intent %s failed: %s (correlation_id=%s)",
|
|
595
|
+
intent_type,
|
|
596
|
+
error_msg,
|
|
597
|
+
correlation_id,
|
|
598
|
+
extra={
|
|
599
|
+
"handler_duration_seconds": handler_duration,
|
|
600
|
+
"intent_target": intent.target,
|
|
601
|
+
},
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
except Exception as e:
|
|
605
|
+
logger.warning(
|
|
606
|
+
"Error executing intent: %s (correlation_id=%s)",
|
|
607
|
+
sanitize_error_message(e),
|
|
608
|
+
correlation_id,
|
|
609
|
+
extra={
|
|
610
|
+
"error_type": type(e).__name__,
|
|
611
|
+
"intent_type": getattr(
|
|
612
|
+
intent.payload, "intent_type", "unknown"
|
|
613
|
+
),
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
async def _tick_loop(self) -> None:
|
|
618
|
+
"""Periodic tick for staleness computation.
|
|
619
|
+
|
|
620
|
+
Runs continuously until shutdown is signaled. Each tick:
|
|
621
|
+
1. Creates a ModelRuntimeTick event
|
|
622
|
+
2. Calls reducer.reduce(state, tick, metadata)
|
|
623
|
+
3. Executes resulting intents (typically postgres.mark_stale)
|
|
624
|
+
|
|
625
|
+
The tick interval is configurable (default 60s, minimum 5s).
|
|
626
|
+
"""
|
|
627
|
+
while not self._shutdown_event.is_set():
|
|
628
|
+
try:
|
|
629
|
+
await asyncio.sleep(self._tick_interval_seconds)
|
|
630
|
+
|
|
631
|
+
if self._shutdown_event.is_set():
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
# Increment sequence for each tick
|
|
635
|
+
self._tick_sequence += 1
|
|
636
|
+
tick_correlation_id = uuid4()
|
|
637
|
+
now = datetime.now(UTC)
|
|
638
|
+
|
|
639
|
+
tick = ModelRuntimeTick(
|
|
640
|
+
tick_id=uuid4(),
|
|
641
|
+
now=now,
|
|
642
|
+
sequence_number=self._tick_sequence,
|
|
643
|
+
scheduled_at=now,
|
|
644
|
+
correlation_id=tick_correlation_id,
|
|
645
|
+
scheduler_id=ROUTER_SCHEDULER_ID,
|
|
646
|
+
tick_interval_ms=self._tick_interval_seconds * 1000,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Internal tick metadata - not from Kafka
|
|
650
|
+
metadata: dict[str, JsonType] = {
|
|
651
|
+
"topic": "__internal_tick__",
|
|
652
|
+
"partition": 0,
|
|
653
|
+
"offset": self._tick_sequence,
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
tick_start_time = time.time()
|
|
657
|
+
output = self._reducer.reduce(self._state, tick, metadata)
|
|
658
|
+
tick_duration = time.time() - tick_start_time
|
|
659
|
+
|
|
660
|
+
# Update state
|
|
661
|
+
self._state = output.result
|
|
662
|
+
|
|
663
|
+
logger.debug(
|
|
664
|
+
"Tick processed in %.3fs, emitted %d intents (correlation_id=%s)",
|
|
665
|
+
tick_duration,
|
|
666
|
+
len(output.intents),
|
|
667
|
+
tick_correlation_id,
|
|
668
|
+
extra={
|
|
669
|
+
"tick_sequence": self._tick_sequence,
|
|
670
|
+
"processing_time_ms": output.processing_time_ms,
|
|
671
|
+
},
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Execute intents (typically postgres.mark_stale)
|
|
675
|
+
if output.intents:
|
|
676
|
+
await self._execute_intents(output.intents, tick_correlation_id)
|
|
677
|
+
|
|
678
|
+
except asyncio.CancelledError:
|
|
679
|
+
break
|
|
680
|
+
|
|
681
|
+
except Exception as e:
|
|
682
|
+
logger.warning(
|
|
683
|
+
"Error in tick loop: %s",
|
|
684
|
+
sanitize_error_message(e),
|
|
685
|
+
extra={
|
|
686
|
+
"error_type": type(e).__name__,
|
|
687
|
+
"tick_sequence": self._tick_sequence,
|
|
688
|
+
},
|
|
689
|
+
)
|