omnibase_infra 0.2.1__py3-none-any.whl → 0.2.3__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 (161) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/adapters/adapter_onex_tool_execution.py +451 -0
  3. omnibase_infra/capabilities/__init__.py +15 -0
  4. omnibase_infra/capabilities/capability_inference_rules.py +211 -0
  5. omnibase_infra/capabilities/contract_capability_extractor.py +221 -0
  6. omnibase_infra/capabilities/intent_type_extractor.py +160 -0
  7. omnibase_infra/cli/commands.py +1 -1
  8. omnibase_infra/configs/widget_mapping.yaml +176 -0
  9. omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +5 -2
  10. omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +5 -2
  11. omnibase_infra/enums/__init__.py +6 -0
  12. omnibase_infra/enums/enum_handler_error_type.py +10 -0
  13. omnibase_infra/enums/enum_handler_source_mode.py +72 -0
  14. omnibase_infra/enums/enum_kafka_acks.py +99 -0
  15. omnibase_infra/errors/error_compute_registry.py +4 -1
  16. omnibase_infra/errors/error_event_bus_registry.py +4 -1
  17. omnibase_infra/errors/error_infra.py +3 -1
  18. omnibase_infra/errors/error_policy_registry.py +4 -1
  19. omnibase_infra/event_bus/event_bus_kafka.py +1 -1
  20. omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +59 -10
  21. omnibase_infra/handlers/__init__.py +8 -1
  22. omnibase_infra/handlers/handler_consul.py +7 -1
  23. omnibase_infra/handlers/handler_db.py +10 -3
  24. omnibase_infra/handlers/handler_graph.py +10 -5
  25. omnibase_infra/handlers/handler_http.py +8 -2
  26. omnibase_infra/handlers/handler_intent.py +387 -0
  27. omnibase_infra/handlers/handler_mcp.py +745 -63
  28. omnibase_infra/handlers/handler_vault.py +11 -5
  29. omnibase_infra/handlers/mixins/mixin_consul_kv.py +4 -3
  30. omnibase_infra/handlers/mixins/mixin_consul_service.py +2 -1
  31. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +7 -0
  32. omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +308 -4
  33. omnibase_infra/handlers/service_discovery/models/model_service_info.py +10 -0
  34. omnibase_infra/mixins/mixin_async_circuit_breaker.py +3 -2
  35. omnibase_infra/mixins/mixin_node_introspection.py +42 -7
  36. omnibase_infra/mixins/mixin_retry_execution.py +1 -1
  37. omnibase_infra/models/discovery/model_introspection_config.py +11 -0
  38. omnibase_infra/models/handlers/__init__.py +48 -5
  39. omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +162 -0
  40. omnibase_infra/models/handlers/model_contract_discovery_result.py +6 -4
  41. omnibase_infra/models/handlers/model_handler_descriptor.py +15 -0
  42. omnibase_infra/models/handlers/model_handler_source_config.py +220 -0
  43. omnibase_infra/models/mcp/__init__.py +15 -0
  44. omnibase_infra/models/mcp/model_mcp_contract_config.py +80 -0
  45. omnibase_infra/models/mcp/model_mcp_server_config.py +67 -0
  46. omnibase_infra/models/mcp/model_mcp_tool_definition.py +73 -0
  47. omnibase_infra/models/mcp/model_mcp_tool_parameter.py +35 -0
  48. omnibase_infra/models/registration/model_node_capabilities.py +11 -0
  49. omnibase_infra/models/registration/model_node_introspection_event.py +9 -0
  50. omnibase_infra/models/runtime/model_handler_contract.py +25 -9
  51. omnibase_infra/models/runtime/model_loaded_handler.py +9 -0
  52. omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +0 -5
  53. omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +17 -10
  54. omnibase_infra/nodes/effects/contract.yaml +0 -5
  55. omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +7 -0
  56. omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +86 -1
  57. omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +3 -3
  58. omnibase_infra/nodes/node_registration_orchestrator/plugin.py +1 -1
  59. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +9 -8
  60. omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +4 -3
  61. omnibase_infra/nodes/node_registration_orchestrator/wiring.py +14 -13
  62. omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +0 -5
  63. omnibase_infra/nodes/node_registration_storage_effect/node.py +4 -1
  64. omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +47 -26
  65. omnibase_infra/nodes/node_registry_effect/contract.yaml +0 -5
  66. omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +2 -1
  67. omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +28 -20
  68. omnibase_infra/plugins/examples/plugin_json_normalizer.py +2 -2
  69. omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +2 -2
  70. omnibase_infra/plugins/plugin_compute_base.py +16 -2
  71. omnibase_infra/protocols/__init__.py +2 -0
  72. omnibase_infra/protocols/protocol_container_aware.py +200 -0
  73. omnibase_infra/protocols/protocol_event_projector.py +1 -1
  74. omnibase_infra/runtime/__init__.py +90 -1
  75. omnibase_infra/runtime/binding_config_resolver.py +102 -37
  76. omnibase_infra/runtime/constants_notification.py +75 -0
  77. omnibase_infra/runtime/contract_handler_discovery.py +6 -1
  78. omnibase_infra/runtime/handler_bootstrap_source.py +507 -0
  79. omnibase_infra/runtime/handler_contract_config_loader.py +603 -0
  80. omnibase_infra/runtime/handler_contract_source.py +267 -186
  81. omnibase_infra/runtime/handler_identity.py +81 -0
  82. omnibase_infra/runtime/handler_plugin_loader.py +19 -2
  83. omnibase_infra/runtime/handler_registry.py +11 -3
  84. omnibase_infra/runtime/handler_source_resolver.py +326 -0
  85. omnibase_infra/runtime/mixin_semver_cache.py +25 -1
  86. omnibase_infra/runtime/mixins/__init__.py +7 -0
  87. omnibase_infra/runtime/mixins/mixin_projector_notification_publishing.py +566 -0
  88. omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +31 -10
  89. omnibase_infra/runtime/models/__init__.py +24 -0
  90. omnibase_infra/runtime/models/model_health_check_result.py +2 -1
  91. omnibase_infra/runtime/models/model_projector_notification_config.py +171 -0
  92. omnibase_infra/runtime/models/model_transition_notification_outbox_config.py +112 -0
  93. omnibase_infra/runtime/models/model_transition_notification_outbox_metrics.py +140 -0
  94. omnibase_infra/runtime/models/model_transition_notification_publisher_metrics.py +357 -0
  95. omnibase_infra/runtime/projector_plugin_loader.py +1 -1
  96. omnibase_infra/runtime/projector_shell.py +229 -1
  97. omnibase_infra/runtime/protocol_lifecycle_executor.py +6 -6
  98. omnibase_infra/runtime/protocols/__init__.py +10 -0
  99. omnibase_infra/runtime/registry/registry_protocol_binding.py +16 -15
  100. omnibase_infra/runtime/registry_contract_source.py +693 -0
  101. omnibase_infra/runtime/registry_policy.py +9 -326
  102. omnibase_infra/runtime/secret_resolver.py +4 -2
  103. omnibase_infra/runtime/service_kernel.py +11 -3
  104. omnibase_infra/runtime/service_message_dispatch_engine.py +4 -2
  105. omnibase_infra/runtime/service_runtime_host_process.py +589 -106
  106. omnibase_infra/runtime/transition_notification_outbox.py +1190 -0
  107. omnibase_infra/runtime/transition_notification_publisher.py +764 -0
  108. omnibase_infra/runtime/util_container_wiring.py +6 -5
  109. omnibase_infra/runtime/util_wiring.py +17 -4
  110. omnibase_infra/schemas/schema_transition_notification_outbox.sql +245 -0
  111. omnibase_infra/services/__init__.py +21 -0
  112. omnibase_infra/services/corpus_capture.py +7 -1
  113. omnibase_infra/services/mcp/__init__.py +31 -0
  114. omnibase_infra/services/mcp/mcp_server_lifecycle.py +449 -0
  115. omnibase_infra/services/mcp/service_mcp_tool_discovery.py +411 -0
  116. omnibase_infra/services/mcp/service_mcp_tool_registry.py +329 -0
  117. omnibase_infra/services/mcp/service_mcp_tool_sync.py +547 -0
  118. omnibase_infra/services/registry_api/__init__.py +40 -0
  119. omnibase_infra/services/registry_api/main.py +261 -0
  120. omnibase_infra/services/registry_api/models/__init__.py +66 -0
  121. omnibase_infra/services/registry_api/models/model_capability_widget_mapping.py +38 -0
  122. omnibase_infra/services/registry_api/models/model_pagination_info.py +48 -0
  123. omnibase_infra/services/registry_api/models/model_registry_discovery_response.py +73 -0
  124. omnibase_infra/services/registry_api/models/model_registry_health_response.py +49 -0
  125. omnibase_infra/services/registry_api/models/model_registry_instance_view.py +88 -0
  126. omnibase_infra/services/registry_api/models/model_registry_node_view.py +88 -0
  127. omnibase_infra/services/registry_api/models/model_registry_summary.py +60 -0
  128. omnibase_infra/services/registry_api/models/model_response_list_instances.py +43 -0
  129. omnibase_infra/services/registry_api/models/model_response_list_nodes.py +51 -0
  130. omnibase_infra/services/registry_api/models/model_warning.py +49 -0
  131. omnibase_infra/services/registry_api/models/model_widget_defaults.py +28 -0
  132. omnibase_infra/services/registry_api/models/model_widget_mapping.py +51 -0
  133. omnibase_infra/services/registry_api/routes.py +371 -0
  134. omnibase_infra/services/registry_api/service.py +837 -0
  135. omnibase_infra/services/service_capability_query.py +4 -4
  136. omnibase_infra/services/service_health.py +3 -2
  137. omnibase_infra/services/service_timeout_emitter.py +20 -3
  138. omnibase_infra/services/service_timeout_scanner.py +7 -3
  139. omnibase_infra/services/session/__init__.py +56 -0
  140. omnibase_infra/services/session/config_consumer.py +120 -0
  141. omnibase_infra/services/session/config_store.py +139 -0
  142. omnibase_infra/services/session/consumer.py +1007 -0
  143. omnibase_infra/services/session/protocol_session_aggregator.py +117 -0
  144. omnibase_infra/services/session/store.py +997 -0
  145. omnibase_infra/utils/__init__.py +19 -0
  146. omnibase_infra/utils/util_atomic_file.py +261 -0
  147. omnibase_infra/utils/util_db_transaction.py +239 -0
  148. omnibase_infra/utils/util_dsn_validation.py +1 -1
  149. omnibase_infra/utils/util_retry_optimistic.py +281 -0
  150. omnibase_infra/validation/__init__.py +3 -19
  151. omnibase_infra/validation/contracts/security.validation.yaml +114 -0
  152. omnibase_infra/validation/infra_validators.py +35 -24
  153. omnibase_infra/validation/validation_exemptions.yaml +140 -9
  154. omnibase_infra/validation/validator_chain_propagation.py +2 -2
  155. omnibase_infra/validation/validator_runtime_shape.py +1 -1
  156. omnibase_infra/validation/validator_security.py +473 -370
  157. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/METADATA +3 -3
  158. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/RECORD +161 -98
  159. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/WHEEL +0 -0
  160. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/entry_points.txt +0 -0
  161. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,357 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """
4
+ Transition Notification Publisher Metrics Model.
5
+
6
+ This module provides the Pydantic model for tracking transition notification
7
+ publisher performance and operational metrics. Used for monitoring notification
8
+ delivery reliability, timing performance, and publisher health.
9
+
10
+ Metrics Categories:
11
+ - Notification Counts: Total notifications published (single and batch)
12
+ - Timing Metrics: Duration tracking for publish operations
13
+ - Error Tracking: Failed notifications and error rates
14
+ - Circuit Breaker: Failure tolerance state
15
+
16
+ Thread Safety:
17
+ This model is immutable (frozen=True) and safe to share across threads.
18
+ Create new instances for updated metrics using model_copy(update={...}).
19
+
20
+ Example:
21
+ >>> from datetime import datetime, UTC
22
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
23
+ ... publisher_id="publisher-001",
24
+ ... topic="onex.fsm.state.transitions.v1",
25
+ ... notifications_published=100,
26
+ ... last_publish_at=datetime.now(UTC),
27
+ ... )
28
+ >>> print(metrics.publish_success_rate())
29
+ 1.0
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from datetime import datetime
35
+ from typing import ClassVar
36
+
37
+ from pydantic import BaseModel, ConfigDict, Field
38
+
39
+
40
+ class ModelTransitionNotificationPublisherMetrics(BaseModel):
41
+ """Metrics for transition notification publisher operation.
42
+
43
+ Tracks notification publishing performance, reliability, and operational
44
+ state of the ONEX transition notification publisher. These metrics are
45
+ essential for monitoring publisher health and identifying performance issues.
46
+
47
+ Attributes:
48
+ publisher_id: Unique identifier for this publisher instance
49
+ topic: Target topic for notifications
50
+ notifications_published: Total notifications successfully published
51
+ notifications_failed: Total notifications that failed to publish
52
+ batch_operations: Total batch publish operations executed
53
+ batch_notifications_attempted: Total notifications attempted via batch
54
+ batch_notifications_total: Total notifications successfully published via batch
55
+ last_publish_at: Timestamp of the most recent publish operation
56
+ last_publish_duration_ms: Duration of the most recent publish in ms
57
+ average_publish_duration_ms: Rolling average publish duration in ms
58
+ max_publish_duration_ms: Maximum publish duration observed in ms
59
+ circuit_breaker_open: Whether the circuit breaker is currently open
60
+ consecutive_failures: Number of consecutive publish failures
61
+ started_at: Timestamp when the publisher started
62
+
63
+ Note:
64
+ Notification Count Relationships:
65
+ - ``notifications_published`` counts ALL successful publishes (individual + batch)
66
+ - ``batch_notifications_attempted`` counts ALL notifications passed to ``publish_batch()``,
67
+ regardless of success or failure (sum of all batch sizes)
68
+ - ``batch_notifications_total`` is a SUBSET of ``notifications_published``,
69
+ counting only those SUCCESSFULLY published via ``publish_batch()``
70
+ - Individual publishes = ``notifications_published - batch_notifications_total``
71
+ - Batch failure rate = ``1 - (batch_notifications_total / batch_notifications_attempted)``
72
+
73
+ Derived Metrics Calculations:
74
+ The model provides convenience methods for calculating derived metrics from
75
+ the raw counters. These formulas are useful for monitoring and alerting:
76
+
77
+ **Batch Failure Rate** (via ``batch_failure_rate()`` method):
78
+ Formula: ``1 - (batch_notifications_total / batch_notifications_attempted)``
79
+ - Only valid when ``batch_notifications_attempted > 0``
80
+ - Returns 0.0 when no batch operations have been attempted
81
+ - A rate of 0.0 means all batch notifications succeeded
82
+ - A rate of 1.0 means all batch notifications failed
83
+
84
+ **Individual Publishes Count** (via ``individual_publish_count()`` method):
85
+ Formula: ``notifications_published - batch_notifications_total``
86
+ - Represents the count of single (non-batch) publish operations
87
+ - Always non-negative since batch_notifications_total is a subset
88
+
89
+ **Overall Failure Rate** (inverse of ``publish_success_rate()``):
90
+ Formula: ``notifications_failed / (notifications_published + notifications_failed)``
91
+ - Only valid when total attempts > 0
92
+ - Returns 0.0 when no notifications have been attempted
93
+ - Covers both individual and batch publish failures
94
+
95
+ Example:
96
+ >>> from datetime import datetime, UTC
97
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
98
+ ... publisher_id="prod-publisher-001",
99
+ ... topic="onex.fsm.state.transitions.v1",
100
+ ... notifications_published=1000,
101
+ ... notifications_failed=5,
102
+ ... average_publish_duration_ms=1.5,
103
+ ... )
104
+ >>> metrics.publish_success_rate()
105
+ 0.995...
106
+ """
107
+
108
+ model_config = ConfigDict(
109
+ frozen=True,
110
+ extra="forbid",
111
+ strict=True,
112
+ )
113
+
114
+ # Health check threshold - intentionally matches default circuit breaker threshold.
115
+ #
116
+ # Design Decision: This value is hardcoded rather than configurable because:
117
+ # 1. The health threshold MUST match the circuit breaker threshold for consistent
118
+ # behavior - when consecutive_failures reaches this value, the circuit opens
119
+ # 2. Making this configurable independently would break the correlation between
120
+ # is_healthy() returning False and the circuit breaker opening
121
+ # 3. If you need a different threshold, configure both circuit_breaker_threshold
122
+ # in TransitionNotificationPublisher and accept that health checks will match
123
+ #
124
+ # The is_healthy() method checks both circuit_breaker_open AND consecutive_failures
125
+ # to provide early warning before the circuit actually opens (at N-1 failures).
126
+ DEFAULT_HEALTH_FAILURE_THRESHOLD: ClassVar[int] = 5
127
+
128
+ # Publisher identification
129
+ publisher_id: str = Field(
130
+ ...,
131
+ description="Unique identifier for this publisher instance",
132
+ min_length=1,
133
+ )
134
+
135
+ topic: str = Field(
136
+ ...,
137
+ description="Target topic for transition notifications",
138
+ min_length=1,
139
+ )
140
+
141
+ # Notification counts (includes both individual and batch publishes)
142
+ notifications_published: int = Field(
143
+ default=0,
144
+ ge=0,
145
+ description=(
146
+ "Total notifications successfully published (ALL publishes). "
147
+ "Includes both individual publish() and batch publish_batch() calls. "
148
+ "Individual publishes = notifications_published - batch_notifications_total."
149
+ ),
150
+ )
151
+ notifications_failed: int = Field(
152
+ default=0,
153
+ ge=0,
154
+ description="Total number of notifications that failed to publish",
155
+ )
156
+
157
+ # Batch operation counts
158
+ batch_operations: int = Field(
159
+ default=0,
160
+ ge=0,
161
+ description="Total number of batch publish operations executed",
162
+ )
163
+ batch_notifications_attempted: int = Field(
164
+ default=0,
165
+ ge=0,
166
+ description=(
167
+ "Total notifications attempted via publish_batch() calls (includes failures). "
168
+ "This is the sum of all batch sizes passed to publish_batch(), regardless of outcome. "
169
+ "Formula: batch_failure_rate = 1 - (batch_notifications_total / batch_notifications_attempted)."
170
+ ),
171
+ )
172
+ batch_notifications_total: int = Field(
173
+ default=0,
174
+ ge=0,
175
+ description=(
176
+ "Successful batch publishes only (SUBSET of notifications_published). "
177
+ "Counts notifications that succeeded via publish_batch(), not total attempts. "
178
+ "Already included in notifications_published (not additional). "
179
+ "Formula: individual_publishes = notifications_published - batch_notifications_total."
180
+ ),
181
+ )
182
+ batch_failures_truncated: int = Field(
183
+ default=0,
184
+ ge=0,
185
+ description=(
186
+ "Count of times failure tracking was truncated due to exceeding max_tracked_failures. "
187
+ "When batch operations have more failures than max_tracked_failures (default 100), "
188
+ "only the first max_tracked_failures are stored in memory. This counter tracks "
189
+ "how many times truncation occurred, indicating potential memory pressure events."
190
+ ),
191
+ )
192
+
193
+ # Timing metrics (milliseconds)
194
+ last_publish_at: datetime | None = Field(
195
+ default=None,
196
+ description="Timestamp of the most recent publish operation",
197
+ )
198
+ last_publish_duration_ms: float = Field(
199
+ default=0.0,
200
+ ge=0.0,
201
+ description="Duration of the most recent publish in milliseconds",
202
+ )
203
+ average_publish_duration_ms: float = Field(
204
+ default=0.0,
205
+ ge=0.0,
206
+ description="Rolling average publish duration in milliseconds",
207
+ )
208
+ max_publish_duration_ms: float = Field(
209
+ default=0.0,
210
+ ge=0.0,
211
+ description="Maximum publish duration observed in milliseconds",
212
+ )
213
+
214
+ # Circuit breaker state
215
+ circuit_breaker_open: bool = Field(
216
+ default=False,
217
+ description="Whether the circuit breaker is currently open",
218
+ )
219
+ consecutive_failures: int = Field(
220
+ default=0,
221
+ ge=0,
222
+ description="Number of consecutive publish failures",
223
+ )
224
+
225
+ # Lifecycle tracking
226
+ started_at: datetime | None = Field(
227
+ default=None,
228
+ description="Timestamp when the publisher started",
229
+ )
230
+
231
+ def publish_success_rate(self) -> float:
232
+ """
233
+ Calculate the publish success rate.
234
+
235
+ Returns:
236
+ Success rate as a float between 0.0 and 1.0.
237
+ Returns 1.0 if no notifications have been attempted.
238
+
239
+ Example:
240
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
241
+ ... publisher_id="test",
242
+ ... topic="test.topic",
243
+ ... notifications_published=95,
244
+ ... notifications_failed=5,
245
+ ... )
246
+ >>> metrics.publish_success_rate()
247
+ 0.95
248
+ """
249
+ total = self.notifications_published + self.notifications_failed
250
+ if total == 0:
251
+ return 1.0
252
+ return self.notifications_published / total
253
+
254
+ def is_healthy(self) -> bool:
255
+ """
256
+ Check if the publisher is in a healthy state.
257
+
258
+ A publisher is considered healthy if:
259
+ - The circuit breaker is closed
260
+ - Consecutive failures are below the health threshold
261
+
262
+ Note:
263
+ The health threshold (DEFAULT_HEALTH_FAILURE_THRESHOLD = 5) matches
264
+ the default circuit breaker failure threshold. This alignment means
265
+ that when consecutive failures reach this count, the circuit breaker
266
+ opens and is_healthy() returns False for both conditions. This
267
+ provides early warning before the circuit breaker triggers, since
268
+ the threshold check fails at the same point the breaker would open.
269
+
270
+ Returns:
271
+ True if the publisher is healthy, False otherwise
272
+
273
+ Example:
274
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
275
+ ... publisher_id="test",
276
+ ... topic="test.topic",
277
+ ... )
278
+ >>> metrics.is_healthy()
279
+ True
280
+ """
281
+ return (
282
+ not self.circuit_breaker_open
283
+ and self.consecutive_failures < self.DEFAULT_HEALTH_FAILURE_THRESHOLD
284
+ )
285
+
286
+ def batch_failure_rate(self) -> float:
287
+ """
288
+ Calculate the batch publish failure rate.
289
+
290
+ This metric indicates the proportion of notifications that failed when
291
+ publishing via batch operations. A high batch failure rate may indicate
292
+ issues with the message broker, serialization problems, or network
293
+ instability during batch operations.
294
+
295
+ Formula: ``1 - (batch_notifications_total / batch_notifications_attempted)``
296
+
297
+ Returns:
298
+ Failure rate as a float between 0.0 and 1.0.
299
+ Returns 0.0 if no batch operations have been attempted.
300
+
301
+ Note:
302
+ - A rate of 0.0 means all batch notifications succeeded
303
+ - A rate of 1.0 means all batch notifications failed
304
+ - This only covers batch operations; individual publish failures
305
+ are tracked separately via ``publish_success_rate()``
306
+
307
+ Example:
308
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
309
+ ... publisher_id="test",
310
+ ... topic="test.topic",
311
+ ... batch_notifications_attempted=100,
312
+ ... batch_notifications_total=95,
313
+ ... )
314
+ >>> metrics.batch_failure_rate()
315
+ 0.05
316
+ >>> # 5% of batch notifications failed (5 out of 100)
317
+ """
318
+ if self.batch_notifications_attempted == 0:
319
+ return 0.0
320
+ return 1.0 - (
321
+ self.batch_notifications_total / self.batch_notifications_attempted
322
+ )
323
+
324
+ def individual_publish_count(self) -> int:
325
+ """
326
+ Calculate the count of individual (non-batch) publish operations.
327
+
328
+ This metric represents the number of notifications that were published
329
+ via single ``publish()`` calls rather than batch ``publish_batch()`` calls.
330
+ Useful for understanding the distribution of publish patterns and
331
+ optimizing batch usage.
332
+
333
+ Formula: ``notifications_published - batch_notifications_total``
334
+
335
+ Returns:
336
+ Count of individual publishes as a non-negative integer.
337
+
338
+ Note:
339
+ This value is always non-negative because ``batch_notifications_total``
340
+ is a subset of ``notifications_published`` (successful batch publishes
341
+ are counted in both fields).
342
+
343
+ Example:
344
+ >>> metrics = ModelTransitionNotificationPublisherMetrics(
345
+ ... publisher_id="test",
346
+ ... topic="test.topic",
347
+ ... notifications_published=150,
348
+ ... batch_notifications_total=100,
349
+ ... )
350
+ >>> metrics.individual_publish_count()
351
+ 50
352
+ >>> # 50 notifications were published individually, 100 via batch
353
+ """
354
+ return self.notifications_published - self.batch_notifications_total
355
+
356
+
357
+ __all__: list[str] = ["ModelTransitionNotificationPublisherMetrics"]
@@ -52,7 +52,7 @@ from omnibase_infra.protocols import (
52
52
  from omnibase_infra.runtime.models import ModelProjectorPluginLoaderConfig
53
53
 
54
54
  if TYPE_CHECKING:
55
- from omnibase_core.models.events import ModelEventEnvelope
55
+ from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope
56
56
  from omnibase_core.models.projectors import ModelProjectionResult
57
57
 
58
58
  logger = logging.getLogger(__name__)
@@ -12,22 +12,53 @@ Features:
12
12
  - Parameterized SQL queries for injection protection
13
13
  - Bulk state queries for N+1 optimization
14
14
  - Configurable query timeouts
15
+ - Optional state transition notification publishing
16
+
17
+ Notification Publishing:
18
+ When configured with a notification_publisher and notification_config,
19
+ ProjectorShell will automatically publish state transition notifications
20
+ after successful projection commits. This enables the Observer pattern
21
+ for orchestrator coordination without tight coupling to reducers.
22
+
23
+ Example:
24
+ >>> from omnibase_infra.runtime import ProjectorShell, TransitionNotificationPublisher
25
+ >>> from omnibase_infra.runtime.models import ModelProjectorNotificationConfig
26
+ >>>
27
+ >>> publisher = TransitionNotificationPublisher(event_bus, topic="transitions.v1")
28
+ >>> config = ModelProjectorNotificationConfig(
29
+ ... topic="transitions.v1",
30
+ ... state_column="current_state",
31
+ ... aggregate_id_column="entity_id",
32
+ ... version_column="version",
33
+ ... )
34
+ >>> projector = ProjectorShell(
35
+ ... contract=contract,
36
+ ... pool=pool,
37
+ ... notification_publisher=publisher,
38
+ ... notification_config=config,
39
+ ... )
15
40
 
16
41
  See Also:
17
42
  - ProtocolEventProjector: Protocol definition from omnibase_infra.protocols
18
43
  - ModelProjectorContract: Contract model from omnibase_core
19
44
  - ProjectorPluginLoader: Loader that instantiates ProjectorShell
45
+ - TransitionNotificationPublisher: Publisher for state transition notifications
20
46
 
21
47
  Related Tickets:
22
48
  - OMN-1169: ProjectorShell contract-driven projections (implemented)
49
+ - OMN-1139: TransitionNotificationPublisher integration (implemented)
23
50
 
24
51
  .. versionadded:: 0.7.0
25
52
  Created as part of OMN-1169 projector shell implementation.
53
+
54
+ .. versionchanged:: 0.8.0
55
+ Added notification publishing support as part of OMN-1139.
26
56
  """
27
57
 
28
58
  from __future__ import annotations
29
59
 
30
60
  import logging
61
+ from typing import TYPE_CHECKING
31
62
  from uuid import UUID
32
63
 
33
64
  import asyncpg
@@ -38,6 +69,9 @@ from omnibase_core.models.projectors import (
38
69
  ModelProjectionResult,
39
70
  ModelProjectorContract,
40
71
  )
72
+ from omnibase_core.protocols.notifications import (
73
+ ProtocolTransitionNotificationPublisher,
74
+ )
41
75
  from omnibase_infra.enums import EnumInfraTransportType
42
76
  from omnibase_infra.errors import (
43
77
  InfraConnectionError,
@@ -49,11 +83,17 @@ from omnibase_infra.errors import (
49
83
  )
50
84
  from omnibase_infra.models.projectors.util_sql_identifiers import quote_identifier
51
85
  from omnibase_infra.runtime.mixins import MixinProjectorSqlOperations
86
+ from omnibase_infra.runtime.mixins.mixin_projector_notification_publishing import (
87
+ MixinProjectorNotificationPublishing,
88
+ )
89
+ from omnibase_infra.runtime.models.model_projector_notification_config import (
90
+ ModelProjectorNotificationConfig,
91
+ )
52
92
 
53
93
  logger = logging.getLogger(__name__)
54
94
 
55
95
 
56
- class ProjectorShell(MixinProjectorSqlOperations):
96
+ class ProjectorShell(MixinProjectorNotificationPublishing, MixinProjectorSqlOperations):
57
97
  """Generic contract-driven projector implementation.
58
98
 
59
99
  Transforms events into persistent state projections based on a
@@ -65,6 +105,22 @@ class ProjectorShell(MixinProjectorSqlOperations):
65
105
  - insert_only: INSERT only, fail on conflict
66
106
  - append: Always INSERT, event-log style
67
107
 
108
+ Notification Publishing:
109
+ When configured with a notification_publisher and notification_config,
110
+ the projector will automatically publish state transition notifications
111
+ after successful projection commits. The notification includes:
112
+
113
+ - aggregate_type: From the projector contract
114
+ - aggregate_id: Extracted from projection values
115
+ - from_state: Previous state (fetched before projection)
116
+ - to_state: New state (extracted from projection values)
117
+ - projection_version: Version from projection values (if configured)
118
+ - correlation_id: Propagated from the incoming event
119
+ - causation_id: The envelope_id of the triggering event
120
+
121
+ Notifications are best-effort - publishing failures are logged but
122
+ do not cause the projection to fail.
123
+
68
124
  UniqueViolationError Handling:
69
125
  The projector handles ``asyncpg.UniqueViolationError`` differently based
70
126
  on the projection mode. Understanding these semantics is critical for
@@ -181,6 +237,8 @@ class ProjectorShell(MixinProjectorSqlOperations):
181
237
  contract: ModelProjectorContract,
182
238
  pool: asyncpg.Pool,
183
239
  query_timeout_seconds: float | None = None,
240
+ notification_publisher: ProtocolTransitionNotificationPublisher | None = None,
241
+ notification_config: ModelProjectorNotificationConfig | None = None,
184
242
  ) -> None:
185
243
  """Initialize projector shell with contract and database pool.
186
244
 
@@ -192,6 +250,59 @@ class ProjectorShell(MixinProjectorSqlOperations):
192
250
  query_timeout_seconds: Timeout for individual database queries in
193
251
  seconds. Defaults to 30.0 seconds. Set to None to disable
194
252
  timeout (not recommended for production).
253
+ notification_publisher: Optional publisher for state transition
254
+ notifications. If provided along with notification_config,
255
+ notifications will be published after successful projections.
256
+ notification_config: Optional configuration for notification
257
+ publishing. Specifies which columns contain state, aggregate ID,
258
+ and version information for notification creation.
259
+
260
+ Note:
261
+ Both notification_publisher and notification_config must be provided
262
+ for notification publishing to be enabled. If only one is provided,
263
+ notifications will not be published (silent no-op).
264
+
265
+ **Topic Consistency**: When both notification_publisher and
266
+ notification_config are provided, be aware that:
267
+
268
+ - The ``notification_config.expected_topic`` field is for
269
+ **documentation and validation purposes only**
270
+ - The **actual topic** used for publishing is determined solely by
271
+ the ``notification_publisher``'s internal configuration (the topic
272
+ passed to ``TransitionNotificationPublisher.__init__``)
273
+ - A **warning is logged** if these values differ to catch configuration
274
+ errors early
275
+ - **Users should ensure these match** when configuring both components
276
+
277
+ Example of consistent configuration::
278
+
279
+ # Publisher determines actual destination
280
+ publisher = TransitionNotificationPublisher(event_bus, "transitions.v1")
281
+
282
+ # Config expected_topic should match for consistency validation
283
+ config = ModelProjectorNotificationConfig(
284
+ expected_topic="transitions.v1", # Match publisher's topic
285
+ state_column="current_state",
286
+ ...
287
+ )
288
+
289
+ Example:
290
+ >>> from omnibase_infra.runtime import TransitionNotificationPublisher
291
+ >>> from omnibase_infra.runtime.models import ModelProjectorNotificationConfig
292
+ >>>
293
+ >>> publisher = TransitionNotificationPublisher(event_bus, "transitions.v1")
294
+ >>> config = ModelProjectorNotificationConfig(
295
+ ... expected_topic="transitions.v1",
296
+ ... state_column="current_state",
297
+ ... aggregate_id_column="entity_id",
298
+ ... version_column="version",
299
+ ... )
300
+ >>> projector = ProjectorShell(
301
+ ... contract=contract,
302
+ ... pool=pool,
303
+ ... notification_publisher=publisher,
304
+ ... notification_config=config,
305
+ ... )
195
306
  """
196
307
  self._contract = contract
197
308
  self._pool = pool
@@ -200,6 +311,31 @@ class ProjectorShell(MixinProjectorSqlOperations):
200
311
  if query_timeout_seconds is not None
201
312
  else self.DEFAULT_QUERY_TIMEOUT_SECONDS
202
313
  )
314
+ self._notification_publisher = notification_publisher
315
+ self._notification_config = notification_config
316
+
317
+ # Validate notification config against contract schema if provided
318
+ if notification_config is not None:
319
+ self._validate_notification_config(notification_config)
320
+
321
+ # Warn if notification config expected_topic differs from publisher topic
322
+ # The config.expected_topic is for validation only; publisher determines destination
323
+ if (
324
+ notification_config is not None
325
+ and notification_publisher is not None
326
+ and hasattr(notification_publisher, "topic")
327
+ and notification_config.expected_topic != notification_publisher.topic
328
+ ):
329
+ logger.warning(
330
+ "Notification config expected_topic differs from publisher topic - "
331
+ "expected_topic is for validation only, publisher determines actual destination",
332
+ extra={
333
+ "projector_id": contract.projector_id,
334
+ "config_expected_topic": notification_config.expected_topic,
335
+ "publisher_topic": notification_publisher.topic,
336
+ },
337
+ )
338
+
203
339
  logger.debug(
204
340
  "ProjectorShell initialized for projector '%s'",
205
341
  contract.projector_id,
@@ -209,9 +345,73 @@ class ProjectorShell(MixinProjectorSqlOperations):
209
345
  "consumed_events": contract.consumed_events,
210
346
  "mode": contract.behavior.mode,
211
347
  "query_timeout_seconds": self._query_timeout,
348
+ "notifications_enabled": self._is_notification_enabled(),
212
349
  },
213
350
  )
214
351
 
352
+ def _validate_notification_config(
353
+ self,
354
+ config: ModelProjectorNotificationConfig,
355
+ ) -> None:
356
+ """Validate notification config against the contract schema.
357
+
358
+ Ensures that the configured column names exist in the projection schema.
359
+
360
+ Args:
361
+ config: The notification configuration to validate.
362
+
363
+ Raises:
364
+ ProtocolConfigurationError: If any configured column does not exist
365
+ in the projection schema.
366
+ """
367
+ schema = self._contract.projection_schema
368
+ column_names = {col.name for col in schema.columns}
369
+
370
+ # Check state_column
371
+ if config.state_column not in column_names:
372
+ context = ModelInfraErrorContext(
373
+ transport_type=EnumInfraTransportType.RUNTIME,
374
+ operation="validate_notification_config",
375
+ target_name=f"projector.{self._contract.projector_id}",
376
+ )
377
+ raise ProtocolConfigurationError(
378
+ f"notification_config.state_column '{config.state_column}' "
379
+ f"not found in projection schema. "
380
+ f"Available columns: {sorted(column_names)}",
381
+ context=context,
382
+ )
383
+
384
+ # Check aggregate_id_column
385
+ if config.aggregate_id_column not in column_names:
386
+ context = ModelInfraErrorContext(
387
+ transport_type=EnumInfraTransportType.RUNTIME,
388
+ operation="validate_notification_config",
389
+ target_name=f"projector.{self._contract.projector_id}",
390
+ )
391
+ raise ProtocolConfigurationError(
392
+ f"notification_config.aggregate_id_column '{config.aggregate_id_column}' "
393
+ f"not found in projection schema. "
394
+ f"Available columns: {sorted(column_names)}",
395
+ context=context,
396
+ )
397
+
398
+ # Check version_column if specified
399
+ if (
400
+ config.version_column is not None
401
+ and config.version_column not in column_names
402
+ ):
403
+ context = ModelInfraErrorContext(
404
+ transport_type=EnumInfraTransportType.RUNTIME,
405
+ operation="validate_notification_config",
406
+ target_name=f"projector.{self._contract.projector_id}",
407
+ )
408
+ raise ProtocolConfigurationError(
409
+ f"notification_config.version_column '{config.version_column}' "
410
+ f"not found in projection schema. "
411
+ f"Available columns: {sorted(column_names)}",
412
+ context=context,
413
+ )
414
+
215
415
  @property
216
416
  def projector_id(self) -> str:
217
417
  """Unique identifier from contract."""
@@ -293,6 +493,20 @@ class ProjectorShell(MixinProjectorSqlOperations):
293
493
  # Extract column values from event
294
494
  values = self._extract_values(event, event_type)
295
495
 
496
+ # Extract notification-related values before projection
497
+ # These are needed for both fetching previous state and publishing notification
498
+ aggregate_id_for_notification = self._extract_aggregate_id_from_values(values)
499
+ from_state: str | None = None
500
+
501
+ # Fetch previous state if notifications are enabled
502
+ if (
503
+ self._is_notification_enabled()
504
+ and aggregate_id_for_notification is not None
505
+ ):
506
+ from_state = await self._fetch_current_state_for_notification(
507
+ aggregate_id_for_notification, correlation_id
508
+ )
509
+
296
510
  # Execute projection based on mode
297
511
  try:
298
512
  rows_affected = await self._execute_projection(
@@ -309,6 +523,20 @@ class ProjectorShell(MixinProjectorSqlOperations):
309
523
  },
310
524
  )
311
525
 
526
+ # Publish transition notification if configured and projection succeeded
527
+ if rows_affected > 0 and aggregate_id_for_notification is not None:
528
+ to_state = self._extract_state_from_values(values)
529
+ if to_state is not None:
530
+ projection_version = self._extract_version_from_values(values)
531
+ await self._publish_transition_notification(
532
+ event=event,
533
+ from_state=from_state,
534
+ to_state=to_state,
535
+ projection_version=projection_version,
536
+ aggregate_id=aggregate_id_for_notification,
537
+ correlation_id=correlation_id,
538
+ )
539
+
312
540
  return ModelProjectionResult(
313
541
  success=True,
314
542
  skipped=False,