omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__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/constants_topic_patterns.py +26 -0
- omnibase_infra/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
- omnibase_infra/enums/enum_handler_source_mode.py +16 -2
- omnibase_infra/errors/__init__.py +4 -0
- omnibase_infra/errors/error_binding_resolution.py +128 -0
- omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
- omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
- omnibase_infra/event_bus/event_bus_kafka.py +105 -47
- omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
- omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
- omnibase_infra/event_bus/testing/__init__.py +26 -0
- omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
- omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
- omnibase_infra/handlers/handler_consul.py +2 -0
- omnibase_infra/handlers/mixins/__init__.py +5 -0
- omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
- omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
- omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
- omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
- omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
- omnibase_infra/mixins/mixin_node_introspection.py +189 -19
- omnibase_infra/models/__init__.py +8 -0
- omnibase_infra/models/bindings/__init__.py +59 -0
- omnibase_infra/models/bindings/constants.py +144 -0
- omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
- omnibase_infra/models/bindings/model_operation_binding.py +44 -0
- omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
- omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
- omnibase_infra/models/discovery/model_introspection_config.py +25 -17
- omnibase_infra/models/dispatch/__init__.py +8 -0
- omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
- omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
- omnibase_infra/models/model_node_identity.py +126 -0
- omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
- omnibase_infra/models/registration/__init__.py +9 -0
- omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
- omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
- omnibase_infra/models/runtime/__init__.py +9 -0
- omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
- omnibase_infra/nodes/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
- omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
- omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
- omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
- omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
- omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
- omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
- omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
- omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
- omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
- omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
- omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
- omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
- omnibase_infra/nodes/reducers/models/__init__.py +7 -2
- omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
- omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
- omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
- omnibase_infra/protocols/__init__.py +3 -0
- omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
- omnibase_infra/runtime/__init__.py +60 -0
- omnibase_infra/runtime/binding_resolver.py +753 -0
- omnibase_infra/runtime/constants_security.py +70 -0
- omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
- omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
- omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
- omnibase_infra/runtime/emit_daemon/cli.py +844 -0
- omnibase_infra/runtime/emit_daemon/client.py +811 -0
- omnibase_infra/runtime/emit_daemon/config.py +535 -0
- omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
- omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
- omnibase_infra/runtime/emit_daemon/queue.py +618 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
- omnibase_infra/runtime/handler_source_resolver.py +43 -2
- omnibase_infra/runtime/kafka_contract_source.py +984 -0
- omnibase_infra/runtime/models/__init__.py +13 -0
- omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
- omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
- omnibase_infra/runtime/models/model_security_config.py +109 -0
- omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
- omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
- omnibase_infra/runtime/service_kernel.py +76 -6
- omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
- omnibase_infra/runtime/service_runtime_host_process.py +770 -20
- omnibase_infra/runtime/transition_notification_publisher.py +3 -2
- omnibase_infra/runtime/util_wiring.py +206 -62
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
- omnibase_infra/services/session/config_consumer.py +25 -8
- omnibase_infra/services/session/config_store.py +2 -2
- omnibase_infra/services/session/consumer.py +1 -1
- omnibase_infra/topics/__init__.py +45 -0
- omnibase_infra/topics/platform_topic_suffixes.py +140 -0
- omnibase_infra/topics/util_topic_composition.py +95 -0
- omnibase_infra/types/typed_dict/__init__.py +9 -1
- omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
- omnibase_infra/utils/__init__.py +9 -0
- omnibase_infra/utils/util_consumer_group.py +232 -0
- omnibase_infra/validation/infra_validators.py +18 -1
- omnibase_infra/validation/validation_exemptions.yaml +192 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -146,13 +146,24 @@ from pydantic import ValidationError
|
|
|
146
146
|
from omnibase_core.enums import EnumCoreErrorCode
|
|
147
147
|
from omnibase_core.models.errors import ModelOnexError
|
|
148
148
|
from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope
|
|
149
|
-
from omnibase_core.types import PrimitiveValue
|
|
149
|
+
from omnibase_core.types import JsonType, PrimitiveValue
|
|
150
150
|
from omnibase_infra.enums import (
|
|
151
151
|
EnumDispatchStatus,
|
|
152
152
|
EnumInfraTransportType,
|
|
153
153
|
EnumMessageCategory,
|
|
154
154
|
)
|
|
155
|
-
from omnibase_infra.errors import
|
|
155
|
+
from omnibase_infra.errors import (
|
|
156
|
+
BindingResolutionError,
|
|
157
|
+
ModelInfraErrorContext,
|
|
158
|
+
ProtocolConfigurationError,
|
|
159
|
+
)
|
|
160
|
+
from omnibase_infra.models.bindings import (
|
|
161
|
+
ModelBindingResolutionResult,
|
|
162
|
+
ModelOperationBindingsSubcontract,
|
|
163
|
+
)
|
|
164
|
+
from omnibase_infra.models.dispatch.model_debug_trace_snapshot import (
|
|
165
|
+
ModelDebugTraceSnapshot,
|
|
166
|
+
)
|
|
156
167
|
from omnibase_infra.models.dispatch.model_dispatch_context import ModelDispatchContext
|
|
157
168
|
from omnibase_infra.models.dispatch.model_dispatch_log_context import (
|
|
158
169
|
ModelDispatchLogContext,
|
|
@@ -165,6 +176,10 @@ from omnibase_infra.models.dispatch.model_dispatch_route import ModelDispatchRou
|
|
|
165
176
|
from omnibase_infra.models.dispatch.model_dispatcher_metrics import (
|
|
166
177
|
ModelDispatcherMetrics,
|
|
167
178
|
)
|
|
179
|
+
from omnibase_infra.models.dispatch.model_materialized_dispatch import (
|
|
180
|
+
ModelMaterializedDispatch,
|
|
181
|
+
)
|
|
182
|
+
from omnibase_infra.runtime.binding_resolver import OperationBindingResolver
|
|
168
183
|
from omnibase_infra.runtime.dispatch_context_enforcer import DispatchContextEnforcer
|
|
169
184
|
from omnibase_infra.utils import sanitize_error_message
|
|
170
185
|
|
|
@@ -254,11 +269,14 @@ ContextAwareDispatcherFunc = Callable[
|
|
|
254
269
|
|
|
255
270
|
# Sync-only dispatcher type for use with run_in_executor
|
|
256
271
|
# Used internally after runtime type narrowing via inspect.iscoroutinefunction
|
|
257
|
-
|
|
272
|
+
# All envelopes are materialized to dict format with __bindings namespace
|
|
273
|
+
_SyncDispatcherFunc = Callable[[dict[str, object]], DispatcherOutput]
|
|
258
274
|
|
|
259
275
|
# Sync-only context-aware dispatcher type for use with run_in_executor
|
|
276
|
+
# All envelopes are materialized to dict format with __bindings namespace
|
|
260
277
|
_SyncContextAwareDispatcherFunc = Callable[
|
|
261
|
-
[
|
|
278
|
+
[dict[str, object], ModelDispatchContext],
|
|
279
|
+
DispatcherOutput,
|
|
262
280
|
]
|
|
263
281
|
|
|
264
282
|
|
|
@@ -283,6 +301,10 @@ class DispatchEntryInternal:
|
|
|
283
301
|
accepts_context: Cached result of signature inspection indicating
|
|
284
302
|
whether the dispatcher accepts a context parameter (2+ params).
|
|
285
303
|
Computed once at registration time for performance.
|
|
304
|
+
operation_bindings: Optional declarative bindings for resolving
|
|
305
|
+
handler parameters from envelope/payload/context. When set,
|
|
306
|
+
bindings are resolved BEFORE handler execution and materialized
|
|
307
|
+
into a new envelope with __bindings namespace.
|
|
286
308
|
"""
|
|
287
309
|
|
|
288
310
|
__slots__ = (
|
|
@@ -292,6 +314,7 @@ class DispatchEntryInternal:
|
|
|
292
314
|
"dispatcher_id",
|
|
293
315
|
"message_types",
|
|
294
316
|
"node_kind",
|
|
317
|
+
"operation_bindings",
|
|
295
318
|
)
|
|
296
319
|
|
|
297
320
|
def __init__(
|
|
@@ -302,6 +325,7 @@ class DispatchEntryInternal:
|
|
|
302
325
|
message_types: set[str] | None,
|
|
303
326
|
node_kind: EnumNodeKind | None = None,
|
|
304
327
|
accepts_context: bool = False,
|
|
328
|
+
operation_bindings: ModelOperationBindingsSubcontract | None = None,
|
|
305
329
|
) -> None:
|
|
306
330
|
self.dispatcher_id = dispatcher_id
|
|
307
331
|
self.dispatcher = dispatcher
|
|
@@ -309,6 +333,9 @@ class DispatchEntryInternal:
|
|
|
309
333
|
self.message_types = message_types # None means "all types"
|
|
310
334
|
self.node_kind = node_kind # None means no context injection
|
|
311
335
|
self.accepts_context = accepts_context # Cached: dispatcher has 2+ params
|
|
336
|
+
self.operation_bindings = (
|
|
337
|
+
operation_bindings # Declarative bindings for this dispatcher
|
|
338
|
+
)
|
|
312
339
|
|
|
313
340
|
|
|
314
341
|
class MessageDispatchEngine:
|
|
@@ -460,6 +487,10 @@ class MessageDispatchEngine:
|
|
|
460
487
|
# Delegates time injection rule enforcement to a single source of truth.
|
|
461
488
|
self._context_enforcer: DispatchContextEnforcer = DispatchContextEnforcer()
|
|
462
489
|
|
|
490
|
+
# Binding resolver for declarative operation bindings.
|
|
491
|
+
# Resolves ${source.path} expressions from envelope/payload/context.
|
|
492
|
+
self._binding_resolver: OperationBindingResolver = OperationBindingResolver()
|
|
493
|
+
|
|
463
494
|
def register_route(self, route: ModelDispatchRoute) -> None:
|
|
464
495
|
"""
|
|
465
496
|
Register a routing rule.
|
|
@@ -542,6 +573,7 @@ class MessageDispatchEngine:
|
|
|
542
573
|
category: EnumMessageCategory,
|
|
543
574
|
message_types: set[str] | None = None,
|
|
544
575
|
node_kind: None = None,
|
|
576
|
+
operation_bindings: ModelOperationBindingsSubcontract | None = None,
|
|
545
577
|
) -> None: ... # Stub: no node_kind -> DispatcherFunc (no context)
|
|
546
578
|
|
|
547
579
|
@overload
|
|
@@ -553,6 +585,7 @@ class MessageDispatchEngine:
|
|
|
553
585
|
message_types: set[str] | None = None,
|
|
554
586
|
*,
|
|
555
587
|
node_kind: EnumNodeKind,
|
|
588
|
+
operation_bindings: ModelOperationBindingsSubcontract | None = None,
|
|
556
589
|
) -> None: ... # Stub: with node_kind -> ContextAwareDispatcherFunc (gets context)
|
|
557
590
|
|
|
558
591
|
def register_dispatcher(
|
|
@@ -562,6 +595,7 @@ class MessageDispatchEngine:
|
|
|
562
595
|
category: EnumMessageCategory,
|
|
563
596
|
message_types: set[str] | None = None,
|
|
564
597
|
node_kind: EnumNodeKind | None = None,
|
|
598
|
+
operation_bindings: ModelOperationBindingsSubcontract | None = None,
|
|
565
599
|
) -> None:
|
|
566
600
|
"""
|
|
567
601
|
Register a message dispatcher.
|
|
@@ -585,6 +619,11 @@ class MessageDispatchEngine:
|
|
|
585
619
|
- REDUCER/COMPUTE: now=None (deterministic execution)
|
|
586
620
|
- ORCHESTRATOR/EFFECT/RUNTIME_HOST: now=datetime.now(UTC)
|
|
587
621
|
When None, dispatcher is called without context.
|
|
622
|
+
operation_bindings: Optional declarative bindings for resolving
|
|
623
|
+
handler parameters from envelope/payload/context. When provided,
|
|
624
|
+
bindings are resolved BEFORE handler execution and materialized
|
|
625
|
+
into a new envelope with __bindings namespace. The original
|
|
626
|
+
envelope is NEVER mutated.
|
|
588
627
|
|
|
589
628
|
Raises:
|
|
590
629
|
ModelOnexError: If engine is frozen (INVALID_STATE)
|
|
@@ -688,9 +727,23 @@ class MessageDispatchEngine:
|
|
|
688
727
|
message_types=message_types,
|
|
689
728
|
node_kind=node_kind,
|
|
690
729
|
accepts_context=accepts_context,
|
|
730
|
+
operation_bindings=operation_bindings,
|
|
691
731
|
)
|
|
692
732
|
self._dispatchers[dispatcher_id] = entry
|
|
693
733
|
|
|
734
|
+
# Log requirement for operation_bindings users
|
|
735
|
+
# NOTE: When operation_bindings is provided, envelopes dispatched to this
|
|
736
|
+
# handler MUST have an 'operation' attribute/key. The operation field is
|
|
737
|
+
# extracted at dispatch time via _extract_operation() and used to select
|
|
738
|
+
# the appropriate binding configuration. Missing operation fields will
|
|
739
|
+
# result in binding resolution failures at dispatch time.
|
|
740
|
+
if operation_bindings is not None:
|
|
741
|
+
self._logger.debug(
|
|
742
|
+
"Dispatcher '%s' registered with operation_bindings. "
|
|
743
|
+
"Envelopes MUST have an 'operation' attribute/key for binding resolution.",
|
|
744
|
+
dispatcher_id,
|
|
745
|
+
)
|
|
746
|
+
|
|
694
747
|
# Update category index
|
|
695
748
|
self._dispatchers_by_category[category].append(dispatcher_id)
|
|
696
749
|
|
|
@@ -1048,7 +1101,9 @@ class MessageDispatchEngine:
|
|
|
1048
1101
|
)
|
|
1049
1102
|
|
|
1050
1103
|
try:
|
|
1051
|
-
result = await self._execute_dispatcher(
|
|
1104
|
+
result = await self._execute_dispatcher(
|
|
1105
|
+
dispatcher_entry, envelope, topic
|
|
1106
|
+
)
|
|
1052
1107
|
dispatcher_duration_ms = (
|
|
1053
1108
|
time.perf_counter() - dispatcher_start_time
|
|
1054
1109
|
) * 1000
|
|
@@ -1433,6 +1488,7 @@ class MessageDispatchEngine:
|
|
|
1433
1488
|
self,
|
|
1434
1489
|
entry: DispatchEntryInternal,
|
|
1435
1490
|
envelope: ModelEventEnvelope[object],
|
|
1491
|
+
topic: str,
|
|
1436
1492
|
) -> DispatcherOutput:
|
|
1437
1493
|
"""
|
|
1438
1494
|
Execute a dispatcher (sync or async).
|
|
@@ -1475,9 +1531,87 @@ class MessageDispatchEngine:
|
|
|
1475
1531
|
|
|
1476
1532
|
.. versionchanged:: 0.5.0
|
|
1477
1533
|
Added support for context-aware dispatchers via ``node_kind``.
|
|
1534
|
+
|
|
1535
|
+
.. versionchanged:: 0.2.6
|
|
1536
|
+
Added binding resolution before handler execution (OMN-1518).
|
|
1478
1537
|
"""
|
|
1479
1538
|
dispatcher = entry.dispatcher
|
|
1480
1539
|
|
|
1540
|
+
# =================================================================
|
|
1541
|
+
# Binding Resolution Phase (OMN-1518)
|
|
1542
|
+
# =================================================================
|
|
1543
|
+
# ALWAYS materialize envelope to dict format for consistent dispatcher API.
|
|
1544
|
+
# INVARIANT: Original envelope is NEVER mutated.
|
|
1545
|
+
resolved_bindings: dict[str, JsonType] = {}
|
|
1546
|
+
|
|
1547
|
+
if entry.operation_bindings is not None:
|
|
1548
|
+
# Extract correlation_id FIRST for error context and tracing
|
|
1549
|
+
correlation_id = self._extract_correlation_id(envelope)
|
|
1550
|
+
|
|
1551
|
+
# Extract operation name from envelope for binding lookup (fail-fast)
|
|
1552
|
+
operation = self._extract_operation_from_envelope(envelope, correlation_id)
|
|
1553
|
+
|
|
1554
|
+
# Create context for binding resolution (see constants.VALID_CONTEXT_PATHS)
|
|
1555
|
+
dispatch_context: dict[str, object] = {
|
|
1556
|
+
"now_iso": datetime.now(UTC).isoformat(),
|
|
1557
|
+
"dispatcher_id": entry.dispatcher_id,
|
|
1558
|
+
"correlation_id": correlation_id,
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
# Check for declared additional_context_paths that are not provided
|
|
1562
|
+
# This is a CONTRACT: if handler declares additional_context_paths,
|
|
1563
|
+
# the dispatch engine should provide them. Log warning if missing.
|
|
1564
|
+
declared_additional_paths = (
|
|
1565
|
+
entry.operation_bindings.additional_context_paths
|
|
1566
|
+
)
|
|
1567
|
+
if declared_additional_paths:
|
|
1568
|
+
missing_paths = [
|
|
1569
|
+
path
|
|
1570
|
+
for path in declared_additional_paths
|
|
1571
|
+
if path not in dispatch_context
|
|
1572
|
+
]
|
|
1573
|
+
if missing_paths:
|
|
1574
|
+
self._logger.warning(
|
|
1575
|
+
"Dispatcher '%s' declares additional_context_paths %s "
|
|
1576
|
+
"but these are not provided in dispatch context. "
|
|
1577
|
+
"Bindings using these paths will resolve to None unless "
|
|
1578
|
+
"they have defaults. correlation_id=%s",
|
|
1579
|
+
entry.dispatcher_id,
|
|
1580
|
+
missing_paths,
|
|
1581
|
+
correlation_id,
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
# Resolve all bindings for this operation
|
|
1585
|
+
resolution = self._binding_resolver.resolve(
|
|
1586
|
+
operation=operation,
|
|
1587
|
+
bindings_subcontract=entry.operation_bindings,
|
|
1588
|
+
envelope=envelope,
|
|
1589
|
+
context=dispatch_context,
|
|
1590
|
+
correlation_id=correlation_id,
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
if not resolution.success:
|
|
1594
|
+
# Fail fast on binding resolution failure
|
|
1595
|
+
raise BindingResolutionError(
|
|
1596
|
+
f"Binding resolution failed: {resolution.error}",
|
|
1597
|
+
operation_name=resolution.operation_name,
|
|
1598
|
+
parameter_name="unknown",
|
|
1599
|
+
expression="unknown",
|
|
1600
|
+
correlation_id=correlation_id,
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
# dict() creates a shallow copy to avoid mutating the resolution result
|
|
1604
|
+
resolved_bindings = dict(resolution.resolved_parameters)
|
|
1605
|
+
|
|
1606
|
+
# ALWAYS materialize envelope with bindings (never mutate original)
|
|
1607
|
+
# Empty dict for __bindings if no bindings configured
|
|
1608
|
+
envelope_for_handler: dict[str, JsonType] = (
|
|
1609
|
+
self._materialize_envelope_with_bindings(envelope, resolved_bindings, topic)
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
# =================================================================
|
|
1613
|
+
# Context Creation Phase
|
|
1614
|
+
# =================================================================
|
|
1481
1615
|
# Create context ONLY if both conditions are met:
|
|
1482
1616
|
# 1. node_kind is set (time injection rules apply)
|
|
1483
1617
|
# 2. dispatcher accepts context (will actually use it)
|
|
@@ -1487,6 +1621,9 @@ class MessageDispatchEngine:
|
|
|
1487
1621
|
if entry.node_kind is not None and entry.accepts_context:
|
|
1488
1622
|
context = self._create_context_for_entry(entry, envelope)
|
|
1489
1623
|
|
|
1624
|
+
# =================================================================
|
|
1625
|
+
# Dispatcher Execution Phase
|
|
1626
|
+
# =================================================================
|
|
1490
1627
|
# Check if dispatcher is async
|
|
1491
1628
|
# Note: context is only non-None when entry.accepts_context is True,
|
|
1492
1629
|
# so checking `context is not None` is sufficient to determine whether
|
|
@@ -1495,9 +1632,9 @@ class MessageDispatchEngine:
|
|
|
1495
1632
|
if context is not None:
|
|
1496
1633
|
# NOTE: Dispatcher signature varies - context param may be optional.
|
|
1497
1634
|
# Return type depends on dispatcher implementation (dict or model).
|
|
1498
|
-
return await dispatcher(
|
|
1635
|
+
return await dispatcher(envelope_for_handler, context) # type: ignore[call-arg,no-any-return] # NOTE: dispatcher signature varies
|
|
1499
1636
|
# NOTE: Return type depends on dispatcher implementation (dict or model).
|
|
1500
|
-
return await dispatcher(
|
|
1637
|
+
return await dispatcher(envelope_for_handler) # type: ignore[no-any-return] # NOTE: dispatcher return type varies
|
|
1501
1638
|
else:
|
|
1502
1639
|
# Sync dispatcher execution via ThreadPoolExecutor
|
|
1503
1640
|
# -----------------------------------------------
|
|
@@ -1515,24 +1652,26 @@ class MessageDispatchEngine:
|
|
|
1515
1652
|
sync_ctx_dispatcher = cast(
|
|
1516
1653
|
"_SyncContextAwareDispatcherFunc", dispatcher
|
|
1517
1654
|
)
|
|
1655
|
+
# NOTE: run_in_executor arg-type check fails because envelope_for_handler
|
|
1656
|
+
# is dict[str, JsonType] but dispatcher expects dict[str, object].
|
|
1657
|
+
# JsonType is a subset of object, so this is safe at runtime.
|
|
1518
1658
|
return await loop.run_in_executor(
|
|
1519
1659
|
None,
|
|
1520
|
-
sync_ctx_dispatcher,
|
|
1521
|
-
|
|
1522
|
-
# type checker cannot verify generic envelope type matches dispatcher.
|
|
1523
|
-
envelope, # type: ignore[arg-type] # NOTE: generic envelope type erasure
|
|
1660
|
+
sync_ctx_dispatcher, # type: ignore[arg-type]
|
|
1661
|
+
envelope_for_handler,
|
|
1524
1662
|
context,
|
|
1525
1663
|
)
|
|
1526
1664
|
else:
|
|
1527
1665
|
# Cast to sync-only type - safe because iscoroutinefunction check above
|
|
1528
1666
|
# guarantees this branch only executes for non-async callables
|
|
1529
1667
|
sync_dispatcher = cast("_SyncDispatcherFunc", dispatcher)
|
|
1668
|
+
# NOTE: run_in_executor arg-type check fails because envelope_for_handler
|
|
1669
|
+
# is dict[str, JsonType] but dispatcher expects dict[str, object].
|
|
1670
|
+
# JsonType is a subset of object, so this is safe at runtime.
|
|
1530
1671
|
return await loop.run_in_executor(
|
|
1531
1672
|
None,
|
|
1532
|
-
sync_dispatcher,
|
|
1533
|
-
|
|
1534
|
-
# type checker cannot verify generic envelope type matches dispatcher.
|
|
1535
|
-
envelope, # type: ignore[arg-type] # NOTE: generic envelope type erasure
|
|
1673
|
+
sync_dispatcher, # type: ignore[arg-type]
|
|
1674
|
+
envelope_for_handler,
|
|
1536
1675
|
)
|
|
1537
1676
|
|
|
1538
1677
|
def _create_context_for_entry(
|
|
@@ -1684,6 +1823,410 @@ class MessageDispatchEngine:
|
|
|
1684
1823
|
)
|
|
1685
1824
|
return False
|
|
1686
1825
|
|
|
1826
|
+
def _extract_operation_from_envelope(
|
|
1827
|
+
self, envelope: object, correlation_id: UUID | None = None
|
|
1828
|
+
) -> str:
|
|
1829
|
+
"""Extract operation name from envelope.
|
|
1830
|
+
|
|
1831
|
+
Supports both dict-based envelopes and Pydantic model envelopes
|
|
1832
|
+
with an ``operation`` attribute.
|
|
1833
|
+
|
|
1834
|
+
Args:
|
|
1835
|
+
envelope: Event envelope (dict or Pydantic model).
|
|
1836
|
+
correlation_id: Optional correlation ID for error context.
|
|
1837
|
+
|
|
1838
|
+
Returns:
|
|
1839
|
+
Operation name string.
|
|
1840
|
+
|
|
1841
|
+
Raises:
|
|
1842
|
+
BindingResolutionError: When operation cannot be extracted. This
|
|
1843
|
+
error is raised in the following cases:
|
|
1844
|
+
|
|
1845
|
+
1. **Dict envelope without ``operation`` key**: The envelope is a dict
|
|
1846
|
+
but does not contain an ``"operation"`` key.
|
|
1847
|
+
2. **Model envelope without ``operation`` attribute**: The envelope is
|
|
1848
|
+
a Pydantic model or object but lacks an ``operation`` attribute.
|
|
1849
|
+
3. **Empty/None operation value**: The operation key/attribute exists
|
|
1850
|
+
but has a None or falsy value.
|
|
1851
|
+
|
|
1852
|
+
Fail-Fast Behavior:
|
|
1853
|
+
This method implements fail-fast semantics. If an operation cannot be
|
|
1854
|
+
extracted, it raises ``BindingResolutionError`` immediately rather than
|
|
1855
|
+
returning a fallback value. This ensures:
|
|
1856
|
+
|
|
1857
|
+
- Early detection of misconfigured envelopes
|
|
1858
|
+
- Clear error messages with diagnostic context
|
|
1859
|
+
- No silent failures from attempting to resolve bindings for "unknown" operation
|
|
1860
|
+
|
|
1861
|
+
Note:
|
|
1862
|
+
If ``entry.operation_bindings`` is ``None`` (no bindings configured),
|
|
1863
|
+
this method is not called at all, avoiding unnecessary extraction.
|
|
1864
|
+
|
|
1865
|
+
.. versionadded:: 0.2.6
|
|
1866
|
+
Added as part of OMN-1518 - Declarative operation bindings.
|
|
1867
|
+
|
|
1868
|
+
.. versionchanged:: 0.2.7
|
|
1869
|
+
Changed from fallback-to-unknown to fail-fast behavior.
|
|
1870
|
+
"""
|
|
1871
|
+
# Dict-based envelopes (common for Kafka/JSON payloads)
|
|
1872
|
+
if isinstance(envelope, dict):
|
|
1873
|
+
operation = envelope.get("operation")
|
|
1874
|
+
if operation is not None:
|
|
1875
|
+
return str(operation)
|
|
1876
|
+
# NOTE: Do not log envelope.keys() - may contain sensitive field names
|
|
1877
|
+
raise BindingResolutionError(
|
|
1878
|
+
"Operation extraction failed: dict envelope missing 'operation' key. "
|
|
1879
|
+
f"Ensure envelope contains 'operation' key (found {len(envelope)} keys).",
|
|
1880
|
+
operation_name="extraction_failed",
|
|
1881
|
+
parameter_name="operation",
|
|
1882
|
+
expression="envelope['operation']",
|
|
1883
|
+
missing_segment="operation",
|
|
1884
|
+
correlation_id=correlation_id,
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
# Pydantic model or object with operation attribute
|
|
1888
|
+
if hasattr(envelope, "operation"):
|
|
1889
|
+
operation = getattr(envelope, "operation", None)
|
|
1890
|
+
if operation is not None:
|
|
1891
|
+
return str(operation)
|
|
1892
|
+
raise BindingResolutionError(
|
|
1893
|
+
"Operation extraction failed: envelope has 'operation' attribute "
|
|
1894
|
+
f"but value is None or falsy. Envelope type: {type(envelope).__name__}",
|
|
1895
|
+
operation_name="extraction_failed",
|
|
1896
|
+
parameter_name="operation",
|
|
1897
|
+
expression="envelope.operation",
|
|
1898
|
+
missing_segment="operation",
|
|
1899
|
+
correlation_id=correlation_id,
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1902
|
+
# No operation attribute at all
|
|
1903
|
+
raise BindingResolutionError(
|
|
1904
|
+
"Operation extraction failed: envelope has no 'operation' attribute. "
|
|
1905
|
+
f"Envelope type: {type(envelope).__name__}",
|
|
1906
|
+
operation_name="extraction_failed",
|
|
1907
|
+
parameter_name="operation",
|
|
1908
|
+
expression="envelope.operation or envelope['operation']",
|
|
1909
|
+
missing_segment="operation",
|
|
1910
|
+
correlation_id=correlation_id,
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
def _extract_correlation_id(self, envelope: object) -> UUID | None:
|
|
1914
|
+
"""Extract correlation_id from envelope.
|
|
1915
|
+
|
|
1916
|
+
Supports both dict-based envelopes and Pydantic model envelopes
|
|
1917
|
+
with a ``correlation_id`` attribute. Handles both UUID and string values.
|
|
1918
|
+
|
|
1919
|
+
Args:
|
|
1920
|
+
envelope: Event envelope (dict or Pydantic model).
|
|
1921
|
+
|
|
1922
|
+
Returns:
|
|
1923
|
+
UUID correlation_id if found and valid, None otherwise.
|
|
1924
|
+
|
|
1925
|
+
.. versionadded:: 0.2.6
|
|
1926
|
+
Added as part of OMN-1518 - Declarative operation bindings.
|
|
1927
|
+
"""
|
|
1928
|
+
cid: object = None
|
|
1929
|
+
if isinstance(envelope, dict):
|
|
1930
|
+
cid = envelope.get("correlation_id")
|
|
1931
|
+
elif hasattr(envelope, "correlation_id"):
|
|
1932
|
+
cid = getattr(envelope, "correlation_id", None)
|
|
1933
|
+
|
|
1934
|
+
if isinstance(cid, UUID):
|
|
1935
|
+
return cid
|
|
1936
|
+
if isinstance(cid, str):
|
|
1937
|
+
try:
|
|
1938
|
+
return UUID(cid)
|
|
1939
|
+
except ValueError:
|
|
1940
|
+
return None
|
|
1941
|
+
return None
|
|
1942
|
+
|
|
1943
|
+
def _materialize_envelope_with_bindings(
|
|
1944
|
+
self,
|
|
1945
|
+
original_envelope: object,
|
|
1946
|
+
resolved_bindings: dict[str, JsonType],
|
|
1947
|
+
topic: str,
|
|
1948
|
+
) -> dict[str, JsonType]:
|
|
1949
|
+
"""Create new JSON-safe envelope with __bindings namespace.
|
|
1950
|
+
|
|
1951
|
+
INVARIANT: original_envelope is NEVER mutated.
|
|
1952
|
+
INVARIANT: All output values are JSON-serializable.
|
|
1953
|
+
|
|
1954
|
+
This method ALWAYS creates a new dict containing:
|
|
1955
|
+
- ``payload``: Event payload as JSON-safe dict
|
|
1956
|
+
- ``__bindings``: Resolved binding parameters (empty dict if no bindings)
|
|
1957
|
+
- ``__debug_trace``: Serialized trace metadata snapshot (debug only)
|
|
1958
|
+
|
|
1959
|
+
The dispatch boundary is a **serialization boundary**. All data crossing
|
|
1960
|
+
this layer must be transport-safe (JSON-serializable) to enable:
|
|
1961
|
+
- Event replay from logs or Kafka
|
|
1962
|
+
- Distributed dispatch across processes
|
|
1963
|
+
- Observability tooling (logging, tracing, dashboards)
|
|
1964
|
+
|
|
1965
|
+
Warning:
|
|
1966
|
+
``__debug_trace`` is provided ONLY for debugging and observability.
|
|
1967
|
+
It is a serialized snapshot of trace metadata, NOT the live envelope.
|
|
1968
|
+
|
|
1969
|
+
**DO NOT**:
|
|
1970
|
+
- Use ``__debug_trace`` for business logic
|
|
1971
|
+
- Assume ``__debug_trace`` reflects complete envelope state
|
|
1972
|
+
- Depend on specific fields being present
|
|
1973
|
+
|
|
1974
|
+
Args:
|
|
1975
|
+
original_envelope: Original event envelope (dict or model).
|
|
1976
|
+
resolved_bindings: JSON-safe resolved binding parameters (may be empty).
|
|
1977
|
+
topic: The topic this message was received on.
|
|
1978
|
+
|
|
1979
|
+
Returns:
|
|
1980
|
+
New dict conforming to ModelMaterializedDispatch schema.
|
|
1981
|
+
All values are JSON-serializable (transport-safe).
|
|
1982
|
+
|
|
1983
|
+
Example:
|
|
1984
|
+
>>> materialized = engine._materialize_envelope_with_bindings(
|
|
1985
|
+
... original_envelope={"payload": {"sql": "SELECT 1"}},
|
|
1986
|
+
... resolved_bindings={"sql": "SELECT 1", "limit": 100},
|
|
1987
|
+
... topic="dev.db.commands.v1",
|
|
1988
|
+
... )
|
|
1989
|
+
>>> materialized["__bindings"]
|
|
1990
|
+
{'sql': 'SELECT 1', 'limit': 100}
|
|
1991
|
+
|
|
1992
|
+
.. versionadded:: 0.2.6
|
|
1993
|
+
Added as part of OMN-1518 - Declarative operation bindings.
|
|
1994
|
+
|
|
1995
|
+
.. versionchanged:: 0.2.8
|
|
1996
|
+
Changed to strict JSON-safe contract:
|
|
1997
|
+
- Payload is serialized to dict (Pydantic models call model_dump)
|
|
1998
|
+
- Bindings values are serialized (UUIDs/datetimes → strings)
|
|
1999
|
+
- __debug_original_envelope replaced with __debug_trace snapshot
|
|
2000
|
+
- Return type changed to dict[str, JsonType]
|
|
2001
|
+
"""
|
|
2002
|
+
# Extract and serialize payload to JSON-safe dict
|
|
2003
|
+
payload_json = self._serialize_payload(original_envelope)
|
|
2004
|
+
|
|
2005
|
+
# Serialize bindings to JSON-safe values
|
|
2006
|
+
bindings_json = self._serialize_bindings(resolved_bindings)
|
|
2007
|
+
|
|
2008
|
+
# Create debug trace snapshot (serialized, non-authoritative)
|
|
2009
|
+
debug_trace = self._create_debug_trace_snapshot(original_envelope, topic)
|
|
2010
|
+
|
|
2011
|
+
# Build materialized dict conforming to ModelMaterializedDispatch schema
|
|
2012
|
+
# NOTE: debug_trace is dict[str, str | None] which is a subset of JsonType
|
|
2013
|
+
materialized: dict[str, JsonType] = {
|
|
2014
|
+
"payload": payload_json,
|
|
2015
|
+
"__bindings": bindings_json,
|
|
2016
|
+
"__debug_trace": cast("JsonType", debug_trace),
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
# Validate against schema to enforce contract invariants
|
|
2020
|
+
# This catches shape drift and provides clear error messages
|
|
2021
|
+
ModelMaterializedDispatch.model_validate(materialized)
|
|
2022
|
+
|
|
2023
|
+
return materialized
|
|
2024
|
+
|
|
2025
|
+
def _serialize_payload(self, original_envelope: object) -> JsonType:
|
|
2026
|
+
"""Extract and serialize payload to JSON-safe dict.
|
|
2027
|
+
|
|
2028
|
+
Handlers that need typed Pydantic models should hydrate locally:
|
|
2029
|
+
``ModelFoo.model_validate(dispatch["payload"])``
|
|
2030
|
+
|
|
2031
|
+
Args:
|
|
2032
|
+
original_envelope: Original event envelope (dict or model).
|
|
2033
|
+
|
|
2034
|
+
Returns:
|
|
2035
|
+
JSON-safe payload (dict for complex types, wrapped for primitives).
|
|
2036
|
+
"""
|
|
2037
|
+
# Extract original payload
|
|
2038
|
+
original_payload: object
|
|
2039
|
+
if isinstance(original_envelope, dict):
|
|
2040
|
+
original_payload = original_envelope.get("payload", original_envelope)
|
|
2041
|
+
elif hasattr(original_envelope, "payload"):
|
|
2042
|
+
original_payload = getattr(original_envelope, "payload", original_envelope)
|
|
2043
|
+
else:
|
|
2044
|
+
original_payload = original_envelope
|
|
2045
|
+
|
|
2046
|
+
# Serialize to JSON-safe format
|
|
2047
|
+
if hasattr(original_payload, "model_dump"):
|
|
2048
|
+
# Pydantic model - serialize to dict with JSON mode
|
|
2049
|
+
# NOTE: model_dump returns Any, but we know it's JSON-compatible
|
|
2050
|
+
return original_payload.model_dump(mode="json") # type: ignore[union-attr, no-any-return]
|
|
2051
|
+
elif isinstance(original_payload, dict):
|
|
2052
|
+
# Dict - recursively serialize values
|
|
2053
|
+
return self._serialize_dict_values(original_payload)
|
|
2054
|
+
elif isinstance(original_payload, (str, int, float, bool, type(None))):
|
|
2055
|
+
# JSON primitive - wrap to maintain dict structure
|
|
2056
|
+
# NOTE: This is a last-resort escape hatch. Prefer dict payloads.
|
|
2057
|
+
return {"_raw": original_payload}
|
|
2058
|
+
elif isinstance(original_payload, list):
|
|
2059
|
+
# List - recursively serialize elements
|
|
2060
|
+
return [self._serialize_value(item) for item in original_payload]
|
|
2061
|
+
elif hasattr(original_payload, "__dict__"):
|
|
2062
|
+
# Plain Python object with attributes - serialize its __dict__
|
|
2063
|
+
# This handles domain objects that aren't Pydantic models
|
|
2064
|
+
self._logger.warning(
|
|
2065
|
+
"Serializing payload via __dict__ fallback for type %s. "
|
|
2066
|
+
"Consider using a Pydantic model for explicit serialization control.",
|
|
2067
|
+
type(original_payload).__name__,
|
|
2068
|
+
)
|
|
2069
|
+
return self._serialize_dict_values(vars(original_payload))
|
|
2070
|
+
else:
|
|
2071
|
+
# Unknown type - attempt string conversion
|
|
2072
|
+
self._logger.warning(
|
|
2073
|
+
"Serializing payload via string fallback for type %s. "
|
|
2074
|
+
"This may indicate a configuration error - payload types should be "
|
|
2075
|
+
"Pydantic models, dicts, or JSON primitives.",
|
|
2076
|
+
type(original_payload).__name__,
|
|
2077
|
+
)
|
|
2078
|
+
return {"_raw": str(original_payload)}
|
|
2079
|
+
|
|
2080
|
+
def _serialize_bindings(self, bindings: dict[str, JsonType]) -> dict[str, JsonType]:
|
|
2081
|
+
"""Ensure binding values are JSON-safe (idempotent serialization).
|
|
2082
|
+
|
|
2083
|
+
When bindings are already JSON-safe (from OperationBindingResolver),
|
|
2084
|
+
this method is effectively a no-op but provides defensive serialization.
|
|
2085
|
+
|
|
2086
|
+
Args:
|
|
2087
|
+
bindings: Dict of resolved binding parameters (already JSON-safe).
|
|
2088
|
+
|
|
2089
|
+
Returns:
|
|
2090
|
+
Dict with JSON-safe values.
|
|
2091
|
+
"""
|
|
2092
|
+
return {key: self._serialize_value(value) for key, value in bindings.items()}
|
|
2093
|
+
|
|
2094
|
+
def _serialize_value(self, value: object) -> JsonType:
|
|
2095
|
+
"""Serialize a single value to JSON-safe type.
|
|
2096
|
+
|
|
2097
|
+
Args:
|
|
2098
|
+
value: Any value to serialize.
|
|
2099
|
+
|
|
2100
|
+
Returns:
|
|
2101
|
+
JSON-safe representation.
|
|
2102
|
+
"""
|
|
2103
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
2104
|
+
return value # type: ignore[return-value]
|
|
2105
|
+
elif isinstance(value, UUID):
|
|
2106
|
+
return str(value)
|
|
2107
|
+
elif isinstance(value, datetime):
|
|
2108
|
+
return value.isoformat()
|
|
2109
|
+
elif hasattr(value, "model_dump"):
|
|
2110
|
+
# Pydantic model - model_dump returns Any, but we know it's JSON-compatible
|
|
2111
|
+
return value.model_dump(mode="json") # type: ignore[union-attr, no-any-return]
|
|
2112
|
+
elif isinstance(value, dict):
|
|
2113
|
+
return self._serialize_dict_values(value)
|
|
2114
|
+
elif isinstance(value, list):
|
|
2115
|
+
return [self._serialize_value(item) for item in value]
|
|
2116
|
+
else:
|
|
2117
|
+
# Unknown type - string conversion
|
|
2118
|
+
return str(value)
|
|
2119
|
+
|
|
2120
|
+
def _serialize_dict_values(self, d: dict[str, object]) -> dict[str, JsonType]:
|
|
2121
|
+
"""Recursively serialize dict values."""
|
|
2122
|
+
return {key: self._serialize_value(value) for key, value in d.items()}
|
|
2123
|
+
|
|
2124
|
+
def _create_debug_trace_snapshot(
|
|
2125
|
+
self, original_envelope: object, topic: str
|
|
2126
|
+
) -> dict[str, str | None]:
|
|
2127
|
+
"""Create serialized trace metadata snapshot.
|
|
2128
|
+
|
|
2129
|
+
This snapshot is for debugging and observability ONLY.
|
|
2130
|
+
It is NOT authoritative and should NOT be used for business logic.
|
|
2131
|
+
|
|
2132
|
+
Args:
|
|
2133
|
+
original_envelope: Original event envelope.
|
|
2134
|
+
topic: The topic this message was received on.
|
|
2135
|
+
|
|
2136
|
+
Returns:
|
|
2137
|
+
Dict with serialized trace metadata (all strings or None).
|
|
2138
|
+
"""
|
|
2139
|
+
# Extract trace fields safely (any missing field → None)
|
|
2140
|
+
event_type: str | None = None
|
|
2141
|
+
correlation_id: str | None = None
|
|
2142
|
+
trace_id: str | None = None
|
|
2143
|
+
causation_id: str | None = None
|
|
2144
|
+
timestamp: str | None = None
|
|
2145
|
+
partition_key: str | None = None
|
|
2146
|
+
|
|
2147
|
+
if isinstance(original_envelope, dict):
|
|
2148
|
+
event_type = self._safe_str(original_envelope.get("event_type"))
|
|
2149
|
+
correlation_id = self._safe_str(original_envelope.get("correlation_id"))
|
|
2150
|
+
trace_id = self._safe_str(original_envelope.get("trace_id"))
|
|
2151
|
+
causation_id = self._safe_str(original_envelope.get("causation_id"))
|
|
2152
|
+
timestamp = self._safe_str(original_envelope.get("timestamp"))
|
|
2153
|
+
partition_key = self._safe_str(original_envelope.get("partition_key"))
|
|
2154
|
+
else:
|
|
2155
|
+
# Pydantic model or other object - use getattr
|
|
2156
|
+
# All fields go through _safe_str to handle non-string types gracefully
|
|
2157
|
+
event_type = self._safe_str(getattr(original_envelope, "event_type", None))
|
|
2158
|
+
correlation_id = self._safe_str(
|
|
2159
|
+
getattr(original_envelope, "correlation_id", None)
|
|
2160
|
+
)
|
|
2161
|
+
trace_id = self._safe_str(getattr(original_envelope, "trace_id", None))
|
|
2162
|
+
causation_id = self._safe_str(
|
|
2163
|
+
getattr(original_envelope, "causation_id", None)
|
|
2164
|
+
)
|
|
2165
|
+
timestamp = self._safe_str(getattr(original_envelope, "timestamp", None))
|
|
2166
|
+
partition_key = self._safe_str(
|
|
2167
|
+
getattr(original_envelope, "partition_key", None)
|
|
2168
|
+
)
|
|
2169
|
+
|
|
2170
|
+
# Build snapshot using the model for validation
|
|
2171
|
+
snapshot = ModelDebugTraceSnapshot(
|
|
2172
|
+
event_type=event_type,
|
|
2173
|
+
correlation_id=correlation_id,
|
|
2174
|
+
trace_id=trace_id,
|
|
2175
|
+
causation_id=causation_id,
|
|
2176
|
+
topic=topic,
|
|
2177
|
+
timestamp=timestamp,
|
|
2178
|
+
partition_key=partition_key,
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
return snapshot.model_dump()
|
|
2182
|
+
|
|
2183
|
+
def _safe_str(self, value: object) -> str | None:
|
|
2184
|
+
"""Safely convert value to string or None.
|
|
2185
|
+
|
|
2186
|
+
This method is defensive - it only returns a string if the value
|
|
2187
|
+
can be meaningfully converted. Mock objects and other test artifacts
|
|
2188
|
+
are filtered out to avoid polluting trace snapshots.
|
|
2189
|
+
"""
|
|
2190
|
+
if value is None:
|
|
2191
|
+
return None
|
|
2192
|
+
if isinstance(value, str):
|
|
2193
|
+
return value
|
|
2194
|
+
if isinstance(value, UUID):
|
|
2195
|
+
return str(value)
|
|
2196
|
+
if isinstance(value, datetime):
|
|
2197
|
+
return value.isoformat()
|
|
2198
|
+
# For other types, only convert if it results in a meaningful string
|
|
2199
|
+
# Filter out mock objects and other test artifacts
|
|
2200
|
+
str_value = str(value)
|
|
2201
|
+
|
|
2202
|
+
# Check for mock objects by module (most reliable)
|
|
2203
|
+
value_module = type(value).__module__
|
|
2204
|
+
if value_module.startswith("unittest.mock"):
|
|
2205
|
+
return None
|
|
2206
|
+
|
|
2207
|
+
# Check for common mock patterns in string representation
|
|
2208
|
+
mock_patterns = ("Mock", "MagicMock", "AsyncMock")
|
|
2209
|
+
if any(pattern in str_value for pattern in mock_patterns):
|
|
2210
|
+
return None
|
|
2211
|
+
|
|
2212
|
+
# Check for Python internal repr patterns (more specific than just <...>)
|
|
2213
|
+
# These patterns indicate non-serializable internal objects
|
|
2214
|
+
if str_value.startswith("<") and str_value.endswith(">"):
|
|
2215
|
+
internal_prefixes = (
|
|
2216
|
+
"<class ",
|
|
2217
|
+
"<function ",
|
|
2218
|
+
"<module ",
|
|
2219
|
+
"<bound method ",
|
|
2220
|
+
"<built-in ",
|
|
2221
|
+
"<coroutine ",
|
|
2222
|
+
"<generator ",
|
|
2223
|
+
"<async_generator ",
|
|
2224
|
+
)
|
|
2225
|
+
if any(str_value.startswith(prefix) for prefix in internal_prefixes):
|
|
2226
|
+
return None
|
|
2227
|
+
|
|
2228
|
+
return str_value
|
|
2229
|
+
|
|
1687
2230
|
def get_structured_metrics(self) -> ModelDispatchMetrics:
|
|
1688
2231
|
"""
|
|
1689
2232
|
Get structured dispatch metrics using Pydantic model.
|