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.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/adapters/adapter_onex_tool_execution.py +451 -0
- omnibase_infra/capabilities/__init__.py +15 -0
- omnibase_infra/capabilities/capability_inference_rules.py +211 -0
- omnibase_infra/capabilities/contract_capability_extractor.py +221 -0
- omnibase_infra/capabilities/intent_type_extractor.py +160 -0
- omnibase_infra/cli/commands.py +1 -1
- omnibase_infra/configs/widget_mapping.yaml +176 -0
- omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +5 -2
- omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +5 -2
- omnibase_infra/enums/__init__.py +6 -0
- omnibase_infra/enums/enum_handler_error_type.py +10 -0
- omnibase_infra/enums/enum_handler_source_mode.py +72 -0
- omnibase_infra/enums/enum_kafka_acks.py +99 -0
- omnibase_infra/errors/error_compute_registry.py +4 -1
- omnibase_infra/errors/error_event_bus_registry.py +4 -1
- omnibase_infra/errors/error_infra.py +3 -1
- omnibase_infra/errors/error_policy_registry.py +4 -1
- omnibase_infra/event_bus/event_bus_kafka.py +1 -1
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +59 -10
- omnibase_infra/handlers/__init__.py +8 -1
- omnibase_infra/handlers/handler_consul.py +7 -1
- omnibase_infra/handlers/handler_db.py +10 -3
- omnibase_infra/handlers/handler_graph.py +10 -5
- omnibase_infra/handlers/handler_http.py +8 -2
- omnibase_infra/handlers/handler_intent.py +387 -0
- omnibase_infra/handlers/handler_mcp.py +745 -63
- omnibase_infra/handlers/handler_vault.py +11 -5
- omnibase_infra/handlers/mixins/mixin_consul_kv.py +4 -3
- omnibase_infra/handlers/mixins/mixin_consul_service.py +2 -1
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +7 -0
- omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +308 -4
- omnibase_infra/handlers/service_discovery/models/model_service_info.py +10 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +3 -2
- omnibase_infra/mixins/mixin_node_introspection.py +42 -7
- omnibase_infra/mixins/mixin_retry_execution.py +1 -1
- omnibase_infra/models/discovery/model_introspection_config.py +11 -0
- omnibase_infra/models/handlers/__init__.py +48 -5
- omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +162 -0
- omnibase_infra/models/handlers/model_contract_discovery_result.py +6 -4
- omnibase_infra/models/handlers/model_handler_descriptor.py +15 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +220 -0
- omnibase_infra/models/mcp/__init__.py +15 -0
- omnibase_infra/models/mcp/model_mcp_contract_config.py +80 -0
- omnibase_infra/models/mcp/model_mcp_server_config.py +67 -0
- omnibase_infra/models/mcp/model_mcp_tool_definition.py +73 -0
- omnibase_infra/models/mcp/model_mcp_tool_parameter.py +35 -0
- omnibase_infra/models/registration/model_node_capabilities.py +11 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +9 -0
- omnibase_infra/models/runtime/model_handler_contract.py +25 -9
- omnibase_infra/models/runtime/model_loaded_handler.py +9 -0
- omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +0 -5
- omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +17 -10
- omnibase_infra/nodes/effects/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +7 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +86 -1
- omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +3 -3
- omnibase_infra/nodes/node_registration_orchestrator/plugin.py +1 -1
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +9 -8
- omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +4 -3
- omnibase_infra/nodes/node_registration_orchestrator/wiring.py +14 -13
- omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_storage_effect/node.py +4 -1
- omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +47 -26
- omnibase_infra/nodes/node_registry_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +2 -1
- omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +28 -20
- omnibase_infra/plugins/examples/plugin_json_normalizer.py +2 -2
- omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +2 -2
- omnibase_infra/plugins/plugin_compute_base.py +16 -2
- omnibase_infra/protocols/__init__.py +2 -0
- omnibase_infra/protocols/protocol_container_aware.py +200 -0
- omnibase_infra/protocols/protocol_event_projector.py +1 -1
- omnibase_infra/runtime/__init__.py +90 -1
- omnibase_infra/runtime/binding_config_resolver.py +102 -37
- omnibase_infra/runtime/constants_notification.py +75 -0
- omnibase_infra/runtime/contract_handler_discovery.py +6 -1
- omnibase_infra/runtime/handler_bootstrap_source.py +507 -0
- omnibase_infra/runtime/handler_contract_config_loader.py +603 -0
- omnibase_infra/runtime/handler_contract_source.py +267 -186
- omnibase_infra/runtime/handler_identity.py +81 -0
- omnibase_infra/runtime/handler_plugin_loader.py +19 -2
- omnibase_infra/runtime/handler_registry.py +11 -3
- omnibase_infra/runtime/handler_source_resolver.py +326 -0
- omnibase_infra/runtime/mixin_semver_cache.py +25 -1
- omnibase_infra/runtime/mixins/__init__.py +7 -0
- omnibase_infra/runtime/mixins/mixin_projector_notification_publishing.py +566 -0
- omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +31 -10
- omnibase_infra/runtime/models/__init__.py +24 -0
- omnibase_infra/runtime/models/model_health_check_result.py +2 -1
- omnibase_infra/runtime/models/model_projector_notification_config.py +171 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_config.py +112 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_metrics.py +140 -0
- omnibase_infra/runtime/models/model_transition_notification_publisher_metrics.py +357 -0
- omnibase_infra/runtime/projector_plugin_loader.py +1 -1
- omnibase_infra/runtime/projector_shell.py +229 -1
- omnibase_infra/runtime/protocol_lifecycle_executor.py +6 -6
- omnibase_infra/runtime/protocols/__init__.py +10 -0
- omnibase_infra/runtime/registry/registry_protocol_binding.py +16 -15
- omnibase_infra/runtime/registry_contract_source.py +693 -0
- omnibase_infra/runtime/registry_policy.py +9 -326
- omnibase_infra/runtime/secret_resolver.py +4 -2
- omnibase_infra/runtime/service_kernel.py +11 -3
- omnibase_infra/runtime/service_message_dispatch_engine.py +4 -2
- omnibase_infra/runtime/service_runtime_host_process.py +589 -106
- omnibase_infra/runtime/transition_notification_outbox.py +1190 -0
- omnibase_infra/runtime/transition_notification_publisher.py +764 -0
- omnibase_infra/runtime/util_container_wiring.py +6 -5
- omnibase_infra/runtime/util_wiring.py +17 -4
- omnibase_infra/schemas/schema_transition_notification_outbox.sql +245 -0
- omnibase_infra/services/__init__.py +21 -0
- omnibase_infra/services/corpus_capture.py +7 -1
- omnibase_infra/services/mcp/__init__.py +31 -0
- omnibase_infra/services/mcp/mcp_server_lifecycle.py +449 -0
- omnibase_infra/services/mcp/service_mcp_tool_discovery.py +411 -0
- omnibase_infra/services/mcp/service_mcp_tool_registry.py +329 -0
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +547 -0
- omnibase_infra/services/registry_api/__init__.py +40 -0
- omnibase_infra/services/registry_api/main.py +261 -0
- omnibase_infra/services/registry_api/models/__init__.py +66 -0
- omnibase_infra/services/registry_api/models/model_capability_widget_mapping.py +38 -0
- omnibase_infra/services/registry_api/models/model_pagination_info.py +48 -0
- omnibase_infra/services/registry_api/models/model_registry_discovery_response.py +73 -0
- omnibase_infra/services/registry_api/models/model_registry_health_response.py +49 -0
- omnibase_infra/services/registry_api/models/model_registry_instance_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_node_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_summary.py +60 -0
- omnibase_infra/services/registry_api/models/model_response_list_instances.py +43 -0
- omnibase_infra/services/registry_api/models/model_response_list_nodes.py +51 -0
- omnibase_infra/services/registry_api/models/model_warning.py +49 -0
- omnibase_infra/services/registry_api/models/model_widget_defaults.py +28 -0
- omnibase_infra/services/registry_api/models/model_widget_mapping.py +51 -0
- omnibase_infra/services/registry_api/routes.py +371 -0
- omnibase_infra/services/registry_api/service.py +837 -0
- omnibase_infra/services/service_capability_query.py +4 -4
- omnibase_infra/services/service_health.py +3 -2
- omnibase_infra/services/service_timeout_emitter.py +20 -3
- omnibase_infra/services/service_timeout_scanner.py +7 -3
- omnibase_infra/services/session/__init__.py +56 -0
- omnibase_infra/services/session/config_consumer.py +120 -0
- omnibase_infra/services/session/config_store.py +139 -0
- omnibase_infra/services/session/consumer.py +1007 -0
- omnibase_infra/services/session/protocol_session_aggregator.py +117 -0
- omnibase_infra/services/session/store.py +997 -0
- omnibase_infra/utils/__init__.py +19 -0
- omnibase_infra/utils/util_atomic_file.py +261 -0
- omnibase_infra/utils/util_db_transaction.py +239 -0
- omnibase_infra/utils/util_dsn_validation.py +1 -1
- omnibase_infra/utils/util_retry_optimistic.py +281 -0
- omnibase_infra/validation/__init__.py +3 -19
- omnibase_infra/validation/contracts/security.validation.yaml +114 -0
- omnibase_infra/validation/infra_validators.py +35 -24
- omnibase_infra/validation/validation_exemptions.yaml +140 -9
- omnibase_infra/validation/validator_chain_propagation.py +2 -2
- omnibase_infra/validation/validator_runtime_shape.py +1 -1
- omnibase_infra/validation/validator_security.py +473 -370
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/RECORD +161 -98
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/entry_points.txt +0 -0
- {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,
|