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.
Files changed (117) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
  4. omnibase_infra/enums/enum_postgres_error_code.py +188 -0
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_infra.py +60 -0
  7. omnibase_infra/handlers/__init__.py +3 -0
  8. omnibase_infra/handlers/handler_slack_webhook.py +426 -0
  9. omnibase_infra/handlers/models/__init__.py +14 -0
  10. omnibase_infra/handlers/models/enum_alert_severity.py +36 -0
  11. omnibase_infra/handlers/models/model_slack_alert.py +24 -0
  12. omnibase_infra/handlers/models/model_slack_alert_payload.py +77 -0
  13. omnibase_infra/handlers/models/model_slack_alert_result.py +73 -0
  14. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
  15. omnibase_infra/mixins/__init__.py +14 -0
  16. omnibase_infra/mixins/mixin_node_introspection.py +42 -20
  17. omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
  18. omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
  19. omnibase_infra/models/__init__.py +3 -0
  20. omnibase_infra/models/discovery/model_dependency_spec.py +1 -0
  21. omnibase_infra/models/discovery/model_discovered_capabilities.py +1 -1
  22. omnibase_infra/models/discovery/model_introspection_config.py +28 -1
  23. omnibase_infra/models/discovery/model_introspection_performance_metrics.py +1 -0
  24. omnibase_infra/models/discovery/model_introspection_task_config.py +1 -0
  25. omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
  26. omnibase_infra/models/projection/__init__.py +11 -0
  27. omnibase_infra/models/projection/model_contract_projection.py +170 -0
  28. omnibase_infra/models/projection/model_topic_projection.py +148 -0
  29. omnibase_infra/models/runtime/__init__.py +4 -0
  30. omnibase_infra/models/runtime/model_resolved_dependencies.py +116 -0
  31. omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
  32. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +6 -5
  33. omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
  34. omnibase_infra/nodes/contract_registry_reducer/reducer.py +9 -26
  35. omnibase_infra/nodes/effects/__init__.py +1 -1
  36. omnibase_infra/nodes/effects/models/__init__.py +6 -4
  37. omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
  38. omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
  39. omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
  40. omnibase_infra/nodes/effects/registry_effect.py +1 -1
  41. omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
  42. omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
  43. omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
  44. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
  45. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
  46. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
  47. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
  48. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
  49. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
  50. omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
  51. omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
  52. omnibase_infra/nodes/node_contract_persistence_effect/node.py +131 -0
  53. omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
  54. omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +251 -0
  55. omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +8 -12
  56. omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
  57. omnibase_infra/nodes/node_slack_alerter_effect/__init__.py +33 -0
  58. omnibase_infra/nodes/node_slack_alerter_effect/contract.yaml +291 -0
  59. omnibase_infra/nodes/node_slack_alerter_effect/node.py +106 -0
  60. omnibase_infra/projectors/__init__.py +6 -0
  61. omnibase_infra/projectors/projection_reader_contract.py +1301 -0
  62. omnibase_infra/runtime/__init__.py +12 -0
  63. omnibase_infra/runtime/baseline_subscriptions.py +13 -6
  64. omnibase_infra/runtime/contract_dependency_resolver.py +455 -0
  65. omnibase_infra/runtime/contract_registration_event_router.py +500 -0
  66. omnibase_infra/runtime/db/__init__.py +4 -0
  67. omnibase_infra/runtime/db/models/__init__.py +15 -10
  68. omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
  69. omnibase_infra/runtime/db/models/model_db_param.py +24 -0
  70. omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
  71. omnibase_infra/runtime/db/models/model_db_return.py +26 -0
  72. omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
  73. omnibase_infra/runtime/emit_daemon/event_registry.py +34 -22
  74. omnibase_infra/runtime/event_bus_subcontract_wiring.py +63 -23
  75. omnibase_infra/runtime/intent_execution_router.py +430 -0
  76. omnibase_infra/runtime/models/__init__.py +6 -0
  77. omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
  78. omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
  79. omnibase_infra/runtime/models/model_runtime_config.py +8 -0
  80. omnibase_infra/runtime/protocols/__init__.py +16 -0
  81. omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
  82. omnibase_infra/runtime/publisher_topic_scoped.py +16 -11
  83. omnibase_infra/runtime/registry_policy.py +29 -15
  84. omnibase_infra/runtime/request_response_wiring.py +793 -0
  85. omnibase_infra/runtime/service_kernel.py +295 -8
  86. omnibase_infra/runtime/service_runtime_host_process.py +149 -5
  87. omnibase_infra/runtime/util_version.py +5 -1
  88. omnibase_infra/schemas/schema_latency_baseline.sql +135 -0
  89. omnibase_infra/services/contract_publisher/config.py +4 -4
  90. omnibase_infra/services/contract_publisher/service.py +8 -5
  91. omnibase_infra/services/observability/injection_effectiveness/__init__.py +67 -0
  92. omnibase_infra/services/observability/injection_effectiveness/config.py +295 -0
  93. omnibase_infra/services/observability/injection_effectiveness/consumer.py +1461 -0
  94. omnibase_infra/services/observability/injection_effectiveness/models/__init__.py +32 -0
  95. omnibase_infra/services/observability/injection_effectiveness/models/model_agent_match.py +79 -0
  96. omnibase_infra/services/observability/injection_effectiveness/models/model_context_utilization.py +118 -0
  97. omnibase_infra/services/observability/injection_effectiveness/models/model_latency_breakdown.py +107 -0
  98. omnibase_infra/services/observability/injection_effectiveness/models/model_pattern_utilization.py +46 -0
  99. omnibase_infra/services/observability/injection_effectiveness/writer_postgres.py +596 -0
  100. omnibase_infra/services/registry_api/models/__init__.py +25 -0
  101. omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
  102. omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
  103. omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
  104. omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
  105. omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
  106. omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
  107. omnibase_infra/services/registry_api/routes.py +205 -6
  108. omnibase_infra/services/registry_api/service.py +528 -1
  109. omnibase_infra/utils/__init__.py +7 -0
  110. omnibase_infra/utils/util_db_error_context.py +292 -0
  111. omnibase_infra/validation/infra_validators.py +3 -1
  112. omnibase_infra/validation/validation_exemptions.yaml +65 -0
  113. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/METADATA +3 -3
  114. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/RECORD +117 -58
  115. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/WHEEL +0 -0
  116. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/entry_points.txt +0 -0
  117. {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
+ )