omnibase_infra 0.3.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
  4. omnibase_infra/enums/enum_postgres_error_code.py +188 -0
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_infra.py +60 -0
  7. omnibase_infra/handlers/__init__.py +3 -0
  8. omnibase_infra/handlers/handler_slack_webhook.py +426 -0
  9. omnibase_infra/handlers/models/__init__.py +14 -0
  10. omnibase_infra/handlers/models/enum_alert_severity.py +36 -0
  11. omnibase_infra/handlers/models/model_slack_alert.py +24 -0
  12. omnibase_infra/handlers/models/model_slack_alert_payload.py +77 -0
  13. omnibase_infra/handlers/models/model_slack_alert_result.py +73 -0
  14. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
  15. omnibase_infra/mixins/__init__.py +14 -0
  16. omnibase_infra/mixins/mixin_node_introspection.py +42 -20
  17. omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
  18. omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
  19. omnibase_infra/models/__init__.py +3 -0
  20. omnibase_infra/models/discovery/model_dependency_spec.py +1 -0
  21. omnibase_infra/models/discovery/model_discovered_capabilities.py +1 -1
  22. omnibase_infra/models/discovery/model_introspection_config.py +28 -1
  23. omnibase_infra/models/discovery/model_introspection_performance_metrics.py +1 -0
  24. omnibase_infra/models/discovery/model_introspection_task_config.py +1 -0
  25. omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
  26. omnibase_infra/models/projection/__init__.py +11 -0
  27. omnibase_infra/models/projection/model_contract_projection.py +170 -0
  28. omnibase_infra/models/projection/model_topic_projection.py +148 -0
  29. omnibase_infra/models/runtime/__init__.py +4 -0
  30. omnibase_infra/models/runtime/model_resolved_dependencies.py +116 -0
  31. omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
  32. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +6 -5
  33. omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
  34. omnibase_infra/nodes/contract_registry_reducer/reducer.py +9 -26
  35. omnibase_infra/nodes/effects/__init__.py +1 -1
  36. omnibase_infra/nodes/effects/models/__init__.py +6 -4
  37. omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
  38. omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
  39. omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
  40. omnibase_infra/nodes/effects/registry_effect.py +1 -1
  41. omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
  42. omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
  43. omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
  44. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
  45. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
  46. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
  47. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
  48. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
  49. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
  50. omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
  51. omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
  52. omnibase_infra/nodes/node_contract_persistence_effect/node.py +131 -0
  53. omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
  54. omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +251 -0
  55. omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +8 -12
  56. omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
  57. omnibase_infra/nodes/node_slack_alerter_effect/__init__.py +33 -0
  58. omnibase_infra/nodes/node_slack_alerter_effect/contract.yaml +291 -0
  59. omnibase_infra/nodes/node_slack_alerter_effect/node.py +106 -0
  60. omnibase_infra/projectors/__init__.py +6 -0
  61. omnibase_infra/projectors/projection_reader_contract.py +1301 -0
  62. omnibase_infra/runtime/__init__.py +12 -0
  63. omnibase_infra/runtime/baseline_subscriptions.py +13 -6
  64. omnibase_infra/runtime/contract_dependency_resolver.py +455 -0
  65. omnibase_infra/runtime/contract_registration_event_router.py +500 -0
  66. omnibase_infra/runtime/db/__init__.py +4 -0
  67. omnibase_infra/runtime/db/models/__init__.py +15 -10
  68. omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
  69. omnibase_infra/runtime/db/models/model_db_param.py +24 -0
  70. omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
  71. omnibase_infra/runtime/db/models/model_db_return.py +26 -0
  72. omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
  73. omnibase_infra/runtime/emit_daemon/event_registry.py +34 -22
  74. omnibase_infra/runtime/event_bus_subcontract_wiring.py +63 -23
  75. omnibase_infra/runtime/intent_execution_router.py +430 -0
  76. omnibase_infra/runtime/models/__init__.py +6 -0
  77. omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
  78. omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
  79. omnibase_infra/runtime/models/model_runtime_config.py +8 -0
  80. omnibase_infra/runtime/protocols/__init__.py +16 -0
  81. omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
  82. omnibase_infra/runtime/publisher_topic_scoped.py +16 -11
  83. omnibase_infra/runtime/registry_policy.py +29 -15
  84. omnibase_infra/runtime/request_response_wiring.py +793 -0
  85. omnibase_infra/runtime/service_kernel.py +295 -8
  86. omnibase_infra/runtime/service_runtime_host_process.py +149 -5
  87. omnibase_infra/runtime/util_version.py +5 -1
  88. omnibase_infra/schemas/schema_latency_baseline.sql +135 -0
  89. omnibase_infra/services/contract_publisher/config.py +4 -4
  90. omnibase_infra/services/contract_publisher/service.py +8 -5
  91. omnibase_infra/services/observability/injection_effectiveness/__init__.py +67 -0
  92. omnibase_infra/services/observability/injection_effectiveness/config.py +295 -0
  93. omnibase_infra/services/observability/injection_effectiveness/consumer.py +1461 -0
  94. omnibase_infra/services/observability/injection_effectiveness/models/__init__.py +32 -0
  95. omnibase_infra/services/observability/injection_effectiveness/models/model_agent_match.py +79 -0
  96. omnibase_infra/services/observability/injection_effectiveness/models/model_context_utilization.py +118 -0
  97. omnibase_infra/services/observability/injection_effectiveness/models/model_latency_breakdown.py +107 -0
  98. omnibase_infra/services/observability/injection_effectiveness/models/model_pattern_utilization.py +46 -0
  99. omnibase_infra/services/observability/injection_effectiveness/writer_postgres.py +596 -0
  100. omnibase_infra/services/registry_api/models/__init__.py +25 -0
  101. omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
  102. omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
  103. omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
  104. omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
  105. omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
  106. omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
  107. omnibase_infra/services/registry_api/routes.py +205 -6
  108. omnibase_infra/services/registry_api/service.py +528 -1
  109. omnibase_infra/utils/__init__.py +7 -0
  110. omnibase_infra/utils/util_db_error_context.py +292 -0
  111. omnibase_infra/validation/infra_validators.py +3 -1
  112. omnibase_infra/validation/validation_exemptions.yaml +65 -0
  113. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/METADATA +3 -3
  114. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/RECORD +117 -58
  115. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/WHEEL +0 -0
  116. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/entry_points.txt +0 -0
  117. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,217 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Handler for PostgreSQL topic reference cleanup.
4
+
5
+ This handler encapsulates PostgreSQL-specific topic cleanup logic for the
6
+ NodeContractPersistenceEffect node, following the declarative node pattern where
7
+ handlers are extracted for testability and separation of concerns.
8
+
9
+ Architecture:
10
+ HandlerPostgresCleanupTopics is responsible for:
11
+ - Removing contract_id from topics.contract_ids JSONB arrays
12
+ - Returning structured ModelBackendResult
13
+
14
+ Timing, error classification, and sanitization are delegated to
15
+ MixinPostgresOpExecutor to eliminate boilerplate drift across handlers.
16
+
17
+ This handler removes a contract_id from the contract_ids JSONB array in
18
+ all topics that reference it. Topic rows are NOT deleted even when the
19
+ contract_ids array becomes empty.
20
+
21
+ Topic Orphan Handling (OMN-1709):
22
+ When all contracts are removed from a topic, the topic row remains with
23
+ empty contract_ids array. This is intentional:
24
+ - Preserves topic routing history for auditing and debugging
25
+ - Allows topic reactivation if a new contract references the same topic
26
+ - Avoids complex cascading deletes during high-volume deregistration
27
+
28
+ To clean up orphaned topics, run:
29
+ DELETE FROM topics WHERE contract_ids = '[]' AND is_active = FALSE;
30
+
31
+ Coroutine Safety:
32
+ This handler is stateless and coroutine-safe for concurrent calls
33
+ with different request instances. Thread-safety depends on the
34
+ underlying asyncpg.Pool implementation.
35
+
36
+ Related:
37
+ - NodeContractPersistenceEffect: Parent effect node that coordinates handlers
38
+ - ModelPayloadCleanupTopicReferences: Input payload model
39
+ - ModelBackendResult: Structured result model for backend operations
40
+ - MixinPostgresOpExecutor: Shared execution core for timing/error handling
41
+ - OMN-1845: Implementation ticket
42
+ - OMN-1857: Executor extraction ticket
43
+ - OMN-1709: Topic orphan handling documentation
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ from typing import TYPE_CHECKING
50
+ from uuid import UUID
51
+
52
+ import asyncpg
53
+
54
+ from omnibase_infra.enums import (
55
+ EnumHandlerType,
56
+ EnumHandlerTypeCategory,
57
+ EnumPostgresErrorCode,
58
+ )
59
+ from omnibase_infra.mixins.mixin_postgres_op_executor import MixinPostgresOpExecutor
60
+ from omnibase_infra.models.model_backend_result import ModelBackendResult
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ if TYPE_CHECKING:
65
+ from omnibase_infra.nodes.contract_registry_reducer.models import (
66
+ ModelPayloadCleanupTopicReferences,
67
+ )
68
+
69
+ # SQL for removing contract_id from all topic contract_ids arrays
70
+ # Uses parameterized query: $1 = contract_id (as text)
71
+ #
72
+ # JSONB operators:
73
+ # - `contract_ids ? $1` checks if string exists as element in JSONB array
74
+ # - `contract_ids - $1` removes the string element from JSONB array
75
+ #
76
+ # Note: updated_at is handled by the trigger (trigger_topics_updated_at)
77
+ # but we update it explicitly here for clarity and for cases where
78
+ # the trigger might not be installed.
79
+ SQL_CLEANUP_TOPIC_REFERENCES = """
80
+ UPDATE topics
81
+ SET contract_ids = contract_ids - $1,
82
+ updated_at = NOW()
83
+ WHERE contract_ids ? $1
84
+ """
85
+
86
+
87
+ class HandlerPostgresCleanupTopics(MixinPostgresOpExecutor):
88
+ """Handler for removing contract references from topics.
89
+
90
+ Encapsulates all PostgreSQL-specific topic cleanup logic extracted from
91
+ NodeContractPersistenceEffect for declarative node compliance.
92
+
93
+ Timing, error classification, and sanitization are handled by the
94
+ MixinPostgresOpExecutor base class, reducing boilerplate and ensuring
95
+ consistent error handling across all PostgreSQL handlers.
96
+
97
+ Important: Topic rows are NOT deleted even when contract_ids becomes empty.
98
+ This is intentional per OMN-1709 - orphaned topics are preserved for
99
+ auditing and can be cleaned up separately if needed.
100
+
101
+ Attributes:
102
+ _pool: asyncpg connection pool for database operations.
103
+
104
+ Example:
105
+ >>> import asyncpg
106
+ >>> pool = await asyncpg.create_pool(dsn="postgresql://...")
107
+ >>> handler = HandlerPostgresCleanupTopics(pool)
108
+ >>> result = await handler.handle(payload, correlation_id)
109
+ >>> result.success
110
+ True
111
+
112
+ See Also:
113
+ - NodeContractPersistenceEffect: Parent node that uses this handler
114
+ - ModelPayloadCleanupTopicReferences: Input payload model
115
+ - MixinPostgresOpExecutor: Shared execution mechanics
116
+ """
117
+
118
+ def __init__(self, pool: asyncpg.Pool) -> None:
119
+ """Initialize handler with asyncpg connection pool.
120
+
121
+ Args:
122
+ pool: asyncpg connection pool for executing database operations.
123
+ """
124
+ self._pool = pool
125
+
126
+ @property
127
+ def handler_type(self) -> EnumHandlerType:
128
+ """Architectural role of this handler."""
129
+ return EnumHandlerType.INFRA_HANDLER
130
+
131
+ @property
132
+ def handler_category(self) -> EnumHandlerTypeCategory:
133
+ """Behavioral classification of this handler."""
134
+ return EnumHandlerTypeCategory.EFFECT
135
+
136
+ async def handle(
137
+ self,
138
+ payload: ModelPayloadCleanupTopicReferences,
139
+ correlation_id: UUID,
140
+ ) -> ModelBackendResult:
141
+ """Remove contract_id from all topic contract_ids arrays.
142
+
143
+ The cleanup removes the contract_id from all topics.contract_ids
144
+ JSONB arrays. Topic rows are preserved even if contract_ids becomes
145
+ empty (per OMN-1709 topic orphan handling).
146
+
147
+ Args:
148
+ payload: Cleanup payload containing contract_id to remove and
149
+ cleaned_at timestamp.
150
+ correlation_id: Request correlation ID for distributed tracing.
151
+
152
+ Returns:
153
+ ModelBackendResult with:
154
+ - success: True if cleanup completed successfully
155
+ - error: Sanitized error message if failed
156
+ - error_code: Error code for programmatic handling
157
+ - duration_ms: Operation duration in milliseconds
158
+ - backend_id: Set to "postgres"
159
+ - correlation_id: Passed through for tracing
160
+
161
+ Note:
162
+ If no topics contain the contract_id, success=True is still
163
+ returned. This follows the idempotency principle - cleaning up
164
+ references for a contract that has no topic associations is
165
+ not an error.
166
+ """
167
+ return await self._execute_postgres_op(
168
+ op_error_code=EnumPostgresErrorCode.CLEANUP_ERROR,
169
+ correlation_id=correlation_id,
170
+ log_context={
171
+ "contract_id": payload.contract_id,
172
+ },
173
+ fn=lambda: self._execute_cleanup(payload, correlation_id),
174
+ )
175
+
176
+ async def _execute_cleanup(
177
+ self,
178
+ payload: ModelPayloadCleanupTopicReferences,
179
+ correlation_id: UUID,
180
+ ) -> None:
181
+ """Execute the topic cleanup UPDATE query.
182
+
183
+ Args:
184
+ payload: Cleanup payload with contract_id.
185
+ correlation_id: Correlation ID for logging.
186
+
187
+ Raises:
188
+ Any exception from asyncpg (handled by MixinPostgresOpExecutor).
189
+ """
190
+ async with self._pool.acquire() as conn:
191
+ # Execute the update and get the number of affected rows
192
+ result = await conn.execute(
193
+ SQL_CLEANUP_TOPIC_REFERENCES,
194
+ payload.contract_id,
195
+ )
196
+
197
+ # Parse the result to get affected row count
198
+ # asyncpg execute returns a string like "UPDATE N"
199
+ topics_updated = 0
200
+ if result and result.startswith("UPDATE "):
201
+ try:
202
+ topics_updated = int(result.split(" ")[1])
203
+ except (ValueError, IndexError):
204
+ pass # Keep default of 0 if parsing fails
205
+
206
+ # Log for observability
207
+ logger.info(
208
+ "Topic cleanup operation completed",
209
+ extra={
210
+ "correlation_id": str(correlation_id),
211
+ "contract_id": payload.contract_id,
212
+ "topics_updated": topics_updated,
213
+ },
214
+ )
215
+
216
+
217
+ __all__: list[str] = ["HandlerPostgresCleanupTopics"]
@@ -0,0 +1,242 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Handler for PostgreSQL contract record upsert.
4
+
5
+ This handler encapsulates PostgreSQL-specific persistence logic for the
6
+ NodeContractPersistenceEffect node, following the declarative node pattern where
7
+ handlers are extracted for testability and separation of concerns.
8
+
9
+ Architecture:
10
+ HandlerPostgresContractUpsert is responsible for:
11
+ - Executing upsert operations against the PostgreSQL contracts table
12
+ - Serializing contract_yaml dict to YAML string before INSERT
13
+ - Returning structured ModelBackendResult
14
+
15
+ Timing, error classification, and sanitization are delegated to
16
+ MixinPostgresOpExecutor to eliminate boilerplate drift across handlers.
17
+
18
+ Coroutine Safety:
19
+ This handler is stateless and coroutine-safe for concurrent calls
20
+ with different payload instances. Thread-safety depends on the
21
+ underlying asyncpg connection pool implementation.
22
+
23
+ SQL Security:
24
+ All SQL queries use parameterized queries with positional placeholders
25
+ ($1, $2, etc.) to prevent SQL injection attacks. The asyncpg library
26
+ handles proper escaping and type conversion for all parameters.
27
+
28
+ Related:
29
+ - NodeContractPersistenceEffect: Parent effect node that coordinates handlers
30
+ - ModelPayloadUpsertContract: Input payload model
31
+ - ModelBackendResult: Structured result model for backend operations
32
+ - MixinPostgresOpExecutor: Shared execution core for timing/error handling
33
+ - OMN-1845: Implementation ticket
34
+ - OMN-1857: Executor extraction ticket
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ from typing import TYPE_CHECKING
41
+ from uuid import UUID
42
+
43
+ import yaml
44
+
45
+ from omnibase_infra.enums import (
46
+ EnumHandlerType,
47
+ EnumHandlerTypeCategory,
48
+ EnumPostgresErrorCode,
49
+ )
50
+ from omnibase_infra.errors import ModelInfraErrorContext, RepositoryExecutionError
51
+ from omnibase_infra.mixins.mixin_postgres_op_executor import MixinPostgresOpExecutor
52
+ from omnibase_infra.models.model_backend_result import ModelBackendResult
53
+
54
+ if TYPE_CHECKING:
55
+ import asyncpg
56
+
57
+ from omnibase_infra.nodes.contract_registry_reducer.models import (
58
+ ModelPayloadUpsertContract,
59
+ )
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # SQL statement for contract upsert with ON CONFLICT for idempotency.
64
+ # Uses RETURNING to confirm the operation was executed.
65
+ SQL_UPSERT_CONTRACT = """
66
+ INSERT INTO contracts (
67
+ contract_id, node_name, version_major, version_minor, version_patch,
68
+ contract_hash, contract_yaml, is_active, registered_at, last_seen_at
69
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
70
+ ON CONFLICT (contract_id) DO UPDATE SET
71
+ contract_hash = EXCLUDED.contract_hash,
72
+ contract_yaml = EXCLUDED.contract_yaml,
73
+ is_active = EXCLUDED.is_active,
74
+ last_seen_at = EXCLUDED.last_seen_at,
75
+ updated_at = NOW()
76
+ RETURNING contract_id, (xmax = 0) AS was_insert;
77
+ """
78
+
79
+
80
+ class HandlerPostgresContractUpsert(MixinPostgresOpExecutor):
81
+ """Handler for PostgreSQL contract record upsert.
82
+
83
+ Encapsulates all PostgreSQL-specific persistence logic for contract
84
+ record upserts.
85
+
86
+ Timing, error classification, and sanitization are handled by the
87
+ MixinPostgresOpExecutor base class, reducing boilerplate and ensuring
88
+ consistent error handling across all PostgreSQL handlers.
89
+
90
+ Attributes:
91
+ _pool: asyncpg connection pool for database operations.
92
+
93
+ Example:
94
+ >>> import asyncpg
95
+ >>> pool = await asyncpg.create_pool(dsn="...")
96
+ >>> handler = HandlerPostgresContractUpsert(pool)
97
+ >>> result = await handler.handle(payload, correlation_id)
98
+ >>> result.success
99
+ True
100
+
101
+ See Also:
102
+ - NodeContractPersistenceEffect: Parent node that uses this handler
103
+ - ModelPayloadUpsertContract: Input payload model
104
+ - MixinPostgresOpExecutor: Shared execution mechanics
105
+ """
106
+
107
+ def __init__(self, pool: asyncpg.Pool) -> None:
108
+ """Initialize handler with asyncpg connection pool.
109
+
110
+ Args:
111
+ pool: asyncpg connection pool for database operations.
112
+ The pool should be pre-configured and ready for use.
113
+ """
114
+ self._pool = pool
115
+
116
+ @property
117
+ def handler_type(self) -> EnumHandlerType:
118
+ """Architectural role of this handler."""
119
+ return EnumHandlerType.INFRA_HANDLER
120
+
121
+ @property
122
+ def handler_category(self) -> EnumHandlerTypeCategory:
123
+ """Behavioral classification of this handler."""
124
+ return EnumHandlerTypeCategory.EFFECT
125
+
126
+ async def handle(
127
+ self,
128
+ payload: ModelPayloadUpsertContract,
129
+ correlation_id: UUID,
130
+ ) -> ModelBackendResult:
131
+ """Execute PostgreSQL contract record upsert.
132
+
133
+ Performs the upsert operation against the contracts table with:
134
+ - Contract YAML serialization (dict to YAML string)
135
+ - Parameterized SQL for injection prevention
136
+
137
+ Args:
138
+ payload: Upsert contract payload containing all contract fields
139
+ including contract_id, node_name, version components,
140
+ contract_hash, contract_yaml, and timestamps.
141
+ correlation_id: Request correlation ID for distributed tracing.
142
+
143
+ Returns:
144
+ ModelBackendResult with:
145
+ - success: True if upsert completed successfully
146
+ - error: Sanitized error message if failed
147
+ - error_code: Error code for programmatic handling
148
+ - duration_ms: Operation duration in milliseconds
149
+ - backend_id: Set to "postgres"
150
+ - correlation_id: Passed through for tracing
151
+ """
152
+ return await self._execute_postgres_op(
153
+ op_error_code=EnumPostgresErrorCode.UPSERT_ERROR,
154
+ correlation_id=correlation_id,
155
+ log_context={
156
+ "contract_id": payload.contract_id,
157
+ "node_name": payload.node_name,
158
+ },
159
+ fn=lambda: self._execute_upsert(payload, correlation_id),
160
+ )
161
+
162
+ async def _execute_upsert(
163
+ self,
164
+ payload: ModelPayloadUpsertContract,
165
+ correlation_id: UUID,
166
+ ) -> None:
167
+ """Execute the contract upsert query.
168
+
169
+ Args:
170
+ payload: Contract upsert payload.
171
+ correlation_id: Correlation ID for logging.
172
+
173
+ Raises:
174
+ RepositoryExecutionError: If no result returned from upsert.
175
+ Any exception from asyncpg (handled by MixinPostgresOpExecutor).
176
+ """
177
+ # Serialize contract_yaml to YAML string if it's a dict
178
+ # The PostgreSQL column is TEXT type, so we need a string representation
179
+ contract_yaml_str: str
180
+ if isinstance(payload.contract_yaml, dict):
181
+ contract_yaml_str = yaml.safe_dump(
182
+ payload.contract_yaml,
183
+ default_flow_style=False,
184
+ sort_keys=True,
185
+ allow_unicode=True,
186
+ )
187
+ elif isinstance(payload.contract_yaml, str):
188
+ contract_yaml_str = payload.contract_yaml
189
+ else:
190
+ # Handle unexpected types by converting to string representation
191
+ contract_yaml_str = str(payload.contract_yaml)
192
+
193
+ async with self._pool.acquire() as conn:
194
+ result = await conn.fetchrow(
195
+ SQL_UPSERT_CONTRACT,
196
+ payload.contract_id,
197
+ payload.node_name,
198
+ payload.version_major,
199
+ payload.version_minor,
200
+ payload.version_patch,
201
+ payload.contract_hash,
202
+ contract_yaml_str,
203
+ payload.is_active,
204
+ payload.registered_at,
205
+ payload.last_seen_at,
206
+ )
207
+
208
+ if result is None:
209
+ # RETURNING clause should always return a row on success
210
+ # If None, something unexpected happened
211
+ logger.warning(
212
+ "Contract upsert returned no result",
213
+ extra={
214
+ "contract_id": payload.contract_id,
215
+ "correlation_id": str(correlation_id),
216
+ },
217
+ )
218
+ context = ModelInfraErrorContext.with_correlation(
219
+ correlation_id=correlation_id,
220
+ transport_type="db",
221
+ operation="contract_upsert",
222
+ )
223
+ raise RepositoryExecutionError(
224
+ "postgres operation failed: no result returned",
225
+ context=context,
226
+ )
227
+
228
+ # Log for observability
229
+ was_insert = result["was_insert"]
230
+ operation = "insert" if was_insert else "update"
231
+ logger.info(
232
+ "Contract upsert completed",
233
+ extra={
234
+ "contract_id": payload.contract_id,
235
+ "node_name": payload.node_name,
236
+ "operation": operation,
237
+ "correlation_id": str(correlation_id),
238
+ },
239
+ )
240
+
241
+
242
+ __all__: list[str] = ["HandlerPostgresContractUpsert"]
@@ -0,0 +1,194 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Handler for PostgreSQL contract deactivation (soft-delete).
4
+
5
+ This handler encapsulates PostgreSQL-specific deactivation logic for the
6
+ NodeContractPersistenceEffect node, following the declarative node pattern where
7
+ handlers are extracted for testability and separation of concerns.
8
+
9
+ Architecture:
10
+ HandlerPostgresDeactivate is responsible for:
11
+ - Executing soft-delete operations against PostgreSQL
12
+ - Returning structured ModelBackendResult
13
+
14
+ Timing, error classification, and sanitization are delegated to
15
+ MixinPostgresOpExecutor to eliminate boilerplate drift across handlers.
16
+
17
+ The deactivation operation performs a soft delete by marking the contract
18
+ record as inactive (is_active=FALSE) and setting deregistered_at timestamp,
19
+ preserving historical data for auditing.
20
+
21
+ Coroutine Safety:
22
+ This handler is stateless and coroutine-safe for concurrent calls
23
+ with different request instances. Thread-safety depends on the
24
+ underlying asyncpg.Pool implementation.
25
+
26
+ Related:
27
+ - NodeContractPersistenceEffect: Parent effect node that coordinates handlers
28
+ - ModelPayloadDeactivateContract: Input payload model
29
+ - ModelBackendResult: Structured result model for backend operations
30
+ - MixinPostgresOpExecutor: Shared execution core for timing/error handling
31
+ - OMN-1845: Implementation ticket
32
+ - OMN-1857: Executor extraction ticket
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ from typing import TYPE_CHECKING
39
+ from uuid import UUID
40
+
41
+ import asyncpg
42
+
43
+ from omnibase_infra.enums import (
44
+ EnumHandlerType,
45
+ EnumHandlerTypeCategory,
46
+ EnumPostgresErrorCode,
47
+ )
48
+ from omnibase_infra.mixins.mixin_postgres_op_executor import MixinPostgresOpExecutor
49
+ from omnibase_infra.models.model_backend_result import ModelBackendResult
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ if TYPE_CHECKING:
54
+ from omnibase_infra.nodes.contract_registry_reducer.models import (
55
+ ModelPayloadDeactivateContract,
56
+ )
57
+
58
+ # SQL for soft-deleting a contract by marking it inactive
59
+ # Uses parameterized query: $1 = deregistered_at, $2 = contract_id
60
+ # RETURNING contract_id allows us to check if the row existed
61
+ SQL_DEACTIVATE_CONTRACT = """
62
+ UPDATE contracts
63
+ SET is_active = FALSE, deregistered_at = $1
64
+ WHERE contract_id = $2
65
+ RETURNING contract_id
66
+ """
67
+
68
+
69
+ class HandlerPostgresDeactivate(MixinPostgresOpExecutor):
70
+ """Handler for PostgreSQL contract deactivation (soft-delete).
71
+
72
+ Encapsulates all PostgreSQL-specific deactivation logic extracted from
73
+ NodeContractPersistenceEffect for declarative node compliance.
74
+
75
+ Timing, error classification, and sanitization are handled by the
76
+ MixinPostgresOpExecutor base class, reducing boilerplate and ensuring
77
+ consistent error handling across all PostgreSQL handlers.
78
+
79
+ The deactivation operation marks a contract as inactive (soft delete)
80
+ rather than performing a hard delete, preserving audit trails and enabling
81
+ potential reactivation.
82
+
83
+ Attributes:
84
+ _pool: asyncpg connection pool for database operations.
85
+
86
+ Example:
87
+ >>> import asyncpg
88
+ >>> pool = await asyncpg.create_pool(dsn="postgresql://...")
89
+ >>> handler = HandlerPostgresDeactivate(pool)
90
+ >>> result = await handler.handle(payload, correlation_id)
91
+ >>> result.success
92
+ True
93
+
94
+ See Also:
95
+ - NodeContractPersistenceEffect: Parent node that uses this handler
96
+ - ModelPayloadDeactivateContract: Input payload model
97
+ - MixinPostgresOpExecutor: Shared execution mechanics
98
+ """
99
+
100
+ def __init__(self, pool: asyncpg.Pool) -> None:
101
+ """Initialize handler with asyncpg connection pool.
102
+
103
+ Args:
104
+ pool: asyncpg connection pool for executing database operations.
105
+ """
106
+ self._pool = pool
107
+
108
+ @property
109
+ def handler_type(self) -> EnumHandlerType:
110
+ """Architectural role of this handler."""
111
+ return EnumHandlerType.INFRA_HANDLER
112
+
113
+ @property
114
+ def handler_category(self) -> EnumHandlerTypeCategory:
115
+ """Behavioral classification of this handler."""
116
+ return EnumHandlerTypeCategory.EFFECT
117
+
118
+ async def handle(
119
+ self,
120
+ payload: ModelPayloadDeactivateContract,
121
+ correlation_id: UUID,
122
+ ) -> ModelBackendResult:
123
+ """Execute PostgreSQL contract deactivation (soft-delete).
124
+
125
+ Performs the deactivation operation against PostgreSQL.
126
+ The deactivation marks the contract record as inactive without
127
+ deleting the underlying data, supporting audit requirements and
128
+ potential reactivation scenarios.
129
+
130
+ Args:
131
+ payload: Deactivation payload containing contract_id and
132
+ deactivated_at timestamp.
133
+ correlation_id: Request correlation ID for distributed tracing.
134
+
135
+ Returns:
136
+ ModelBackendResult with:
137
+ - success: True if deactivation completed successfully
138
+ - error: Sanitized error message if failed
139
+ - error_code: Error code for programmatic handling
140
+ - duration_ms: Operation duration in milliseconds
141
+ - backend_id: Set to "postgres"
142
+ - correlation_id: Passed through for tracing
143
+
144
+ Note:
145
+ If the contract_id doesn't exist, success=True is still returned
146
+ but with an appropriate message indicating no row was found.
147
+ This follows the idempotency principle - deactivating a
148
+ non-existent or already-deactivated contract is not an error.
149
+ """
150
+ return await self._execute_postgres_op(
151
+ op_error_code=EnumPostgresErrorCode.DEACTIVATE_ERROR,
152
+ correlation_id=correlation_id,
153
+ log_context={
154
+ "contract_id": payload.contract_id,
155
+ },
156
+ fn=lambda: self._execute_deactivate(payload, correlation_id),
157
+ )
158
+
159
+ async def _execute_deactivate(
160
+ self,
161
+ payload: ModelPayloadDeactivateContract,
162
+ correlation_id: UUID,
163
+ ) -> None:
164
+ """Execute the deactivation UPDATE query.
165
+
166
+ Args:
167
+ payload: Deactivation payload with contract_id and timestamp.
168
+ correlation_id: Correlation ID for logging.
169
+
170
+ Raises:
171
+ Any exception from asyncpg (handled by MixinPostgresOpExecutor).
172
+ """
173
+ async with self._pool.acquire() as conn:
174
+ result = await conn.fetchval(
175
+ SQL_DEACTIVATE_CONTRACT,
176
+ payload.deactivated_at,
177
+ payload.contract_id,
178
+ )
179
+
180
+ # result will be the contract_id if row was updated, None otherwise
181
+ row_found = result is not None
182
+
183
+ # Log the not-found case for observability
184
+ if not row_found:
185
+ logger.info(
186
+ "Contract not found during deactivation (idempotent no-op)",
187
+ extra={
188
+ "contract_id": payload.contract_id,
189
+ "correlation_id": str(correlation_id),
190
+ },
191
+ )
192
+
193
+
194
+ __all__: list[str] = ["HandlerPostgresDeactivate"]