omnibase_infra 0.3.1__py3-none-any.whl → 0.3.2__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/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
- omnibase_infra/enums/enum_postgres_error_code.py +188 -0
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
- omnibase_infra/mixins/__init__.py +14 -0
- omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
- omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
- omnibase_infra/models/__init__.py +3 -0
- omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
- omnibase_infra/models/projection/__init__.py +11 -0
- omnibase_infra/models/projection/model_contract_projection.py +170 -0
- omnibase_infra/models/projection/model_topic_projection.py +148 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
- omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
- omnibase_infra/nodes/effects/__init__.py +1 -1
- omnibase_infra/nodes/effects/models/__init__.py +6 -4
- omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
- omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
- omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
- omnibase_infra/nodes/effects/registry_effect.py +1 -1
- omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
- omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
- omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
- omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
- omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
- omnibase_infra/nodes/node_contract_persistence_effect/node.py +114 -0
- omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
- omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +220 -0
- omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
- omnibase_infra/projectors/__init__.py +6 -0
- omnibase_infra/projectors/projection_reader_contract.py +1301 -0
- omnibase_infra/runtime/__init__.py +5 -0
- omnibase_infra/runtime/contract_registration_event_router.py +500 -0
- omnibase_infra/runtime/db/__init__.py +4 -0
- omnibase_infra/runtime/db/models/__init__.py +15 -10
- omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
- omnibase_infra/runtime/db/models/model_db_param.py +24 -0
- omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
- omnibase_infra/runtime/db/models/model_db_return.py +26 -0
- omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
- omnibase_infra/runtime/intent_execution_router.py +430 -0
- omnibase_infra/runtime/models/__init__.py +6 -0
- omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
- omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
- omnibase_infra/runtime/models/model_runtime_config.py +8 -0
- omnibase_infra/runtime/protocols/__init__.py +16 -0
- omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
- omnibase_infra/runtime/request_response_wiring.py +785 -0
- omnibase_infra/runtime/service_kernel.py +295 -8
- omnibase_infra/services/registry_api/models/__init__.py +25 -0
- omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
- omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
- omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
- omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
- omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
- omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
- omnibase_infra/services/registry_api/routes.py +205 -6
- omnibase_infra/services/registry_api/service.py +528 -1
- omnibase_infra/validation/infra_validators.py +3 -1
- omnibase_infra/validation/validation_exemptions.yaml +54 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/METADATA +3 -3
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/RECORD +72 -34
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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"]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Handler for updating contract heartbeat timestamp.
|
|
4
|
+
|
|
5
|
+
This handler encapsulates PostgreSQL-specific heartbeat update 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
|
+
HandlerPostgresHeartbeat is responsible for:
|
|
11
|
+
- Executing heartbeat timestamp updates against PostgreSQL
|
|
12
|
+
- Tracking whether the target row was found
|
|
13
|
+
- Returning structured ModelBackendResult
|
|
14
|
+
|
|
15
|
+
Timing, error classification, and sanitization are delegated to
|
|
16
|
+
MixinPostgresOpExecutor to eliminate boilerplate drift across handlers.
|
|
17
|
+
|
|
18
|
+
Operation:
|
|
19
|
+
Updates the last_seen_at timestamp for an active contract identified
|
|
20
|
+
by contract_id. Only active contracts (is_active = TRUE) are updated.
|
|
21
|
+
If the contract is not found or is inactive, the operation succeeds
|
|
22
|
+
but row_found is logged as false.
|
|
23
|
+
|
|
24
|
+
SQL:
|
|
25
|
+
UPDATE contracts
|
|
26
|
+
SET last_seen_at = $1
|
|
27
|
+
WHERE contract_id = $2 AND is_active = TRUE
|
|
28
|
+
|
|
29
|
+
Coroutine Safety:
|
|
30
|
+
This handler is stateless and coroutine-safe for concurrent calls
|
|
31
|
+
with different payload instances. Thread-safety depends on the
|
|
32
|
+
underlying asyncpg.Pool implementation.
|
|
33
|
+
|
|
34
|
+
Related:
|
|
35
|
+
- NodeContractPersistenceEffect: Parent effect node that coordinates handlers
|
|
36
|
+
- ModelPayloadUpdateHeartbeat: Payload model defining heartbeat parameters
|
|
37
|
+
- ModelBackendResult: Structured result model for backend operations
|
|
38
|
+
- MixinPostgresOpExecutor: Shared execution core for timing/error handling
|
|
39
|
+
- OMN-1845: Implementation ticket
|
|
40
|
+
- OMN-1857: Executor extraction ticket
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
|
+
from uuid import UUID
|
|
48
|
+
|
|
49
|
+
from omnibase_infra.enums import (
|
|
50
|
+
EnumHandlerType,
|
|
51
|
+
EnumHandlerTypeCategory,
|
|
52
|
+
EnumPostgresErrorCode,
|
|
53
|
+
)
|
|
54
|
+
from omnibase_infra.mixins.mixin_postgres_op_executor import MixinPostgresOpExecutor
|
|
55
|
+
from omnibase_infra.models.model_backend_result import ModelBackendResult
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
import asyncpg
|
|
59
|
+
|
|
60
|
+
from omnibase_infra.nodes.contract_registry_reducer.models.model_payload_update_heartbeat import (
|
|
61
|
+
ModelPayloadUpdateHeartbeat,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
# SQL for updating heartbeat timestamp
|
|
67
|
+
_UPDATE_HEARTBEAT_SQL = """
|
|
68
|
+
UPDATE contracts
|
|
69
|
+
SET last_seen_at = $1
|
|
70
|
+
WHERE contract_id = $2 AND is_active = TRUE
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HandlerPostgresHeartbeat(MixinPostgresOpExecutor):
|
|
75
|
+
"""Handler for updating contract heartbeat timestamp.
|
|
76
|
+
|
|
77
|
+
Encapsulates PostgreSQL-specific heartbeat update logic extracted from
|
|
78
|
+
NodeContractPersistenceEffect for declarative node compliance. The handler
|
|
79
|
+
provides a clean interface for executing timestamp updates.
|
|
80
|
+
|
|
81
|
+
Timing, error classification, and sanitization are handled by the
|
|
82
|
+
MixinPostgresOpExecutor base class, reducing boilerplate and ensuring
|
|
83
|
+
consistent error handling across all PostgreSQL handlers.
|
|
84
|
+
|
|
85
|
+
The heartbeat operation updates the last_seen_at timestamp for an active
|
|
86
|
+
contract, supporting contract lifecycle management by tracking node
|
|
87
|
+
liveness.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
_pool: asyncpg connection pool for database operations.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> from unittest.mock import AsyncMock, MagicMock
|
|
94
|
+
>>> conn = MagicMock()
|
|
95
|
+
>>> conn.execute = AsyncMock(return_value="UPDATE 1")
|
|
96
|
+
>>> pool = MagicMock()
|
|
97
|
+
>>> pool.acquire = MagicMock(return_value=AsyncContextManager(conn))
|
|
98
|
+
>>> handler = HandlerPostgresHeartbeat(pool)
|
|
99
|
+
>>> payload = MagicMock(
|
|
100
|
+
... contract_id="my-node:1.0.0",
|
|
101
|
+
... last_seen_at=datetime.now(),
|
|
102
|
+
... )
|
|
103
|
+
>>> result = await handler.handle(payload, uuid4())
|
|
104
|
+
>>> result.success
|
|
105
|
+
True
|
|
106
|
+
|
|
107
|
+
See Also:
|
|
108
|
+
- NodeContractPersistenceEffect: Parent node that uses this handler
|
|
109
|
+
- ModelPayloadUpdateHeartbeat: Payload model for heartbeat parameters
|
|
110
|
+
- MixinPostgresOpExecutor: Shared execution mechanics
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, pool: asyncpg.Pool) -> None:
|
|
114
|
+
"""Initialize handler with asyncpg connection pool.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
pool: asyncpg connection pool for executing heartbeat
|
|
118
|
+
update operations against the contracts table.
|
|
119
|
+
"""
|
|
120
|
+
self._pool = pool
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def handler_type(self) -> EnumHandlerType:
|
|
124
|
+
"""Architectural role of this handler."""
|
|
125
|
+
return EnumHandlerType.INFRA_HANDLER
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def handler_category(self) -> EnumHandlerTypeCategory:
|
|
129
|
+
"""Behavioral classification of this handler."""
|
|
130
|
+
return EnumHandlerTypeCategory.EFFECT
|
|
131
|
+
|
|
132
|
+
async def handle(
|
|
133
|
+
self,
|
|
134
|
+
payload: ModelPayloadUpdateHeartbeat,
|
|
135
|
+
correlation_id: UUID,
|
|
136
|
+
) -> ModelBackendResult:
|
|
137
|
+
"""Execute heartbeat timestamp update for a contract.
|
|
138
|
+
|
|
139
|
+
Updates the last_seen_at timestamp for the contract identified
|
|
140
|
+
by contract_id, if the contract exists and is active.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
payload: Heartbeat parameters containing:
|
|
144
|
+
- contract_id: Derived natural key (node_name:major.minor.patch)
|
|
145
|
+
- last_seen_at: New heartbeat timestamp
|
|
146
|
+
- node_name: Contract node name (for logging)
|
|
147
|
+
- source_node_id: Optional source node ID (for logging)
|
|
148
|
+
- uptime_seconds: Optional node uptime (for logging)
|
|
149
|
+
- sequence_number: Optional heartbeat sequence (for logging)
|
|
150
|
+
correlation_id: Request correlation ID for distributed tracing.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
ModelBackendResult with:
|
|
154
|
+
- success: True if update completed (even if row not found)
|
|
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
|
+
The row_found status is logged for observability but not
|
|
163
|
+
returned in the result model (ModelBackendResult does not support
|
|
164
|
+
metadata). The operation is considered successful even if no row
|
|
165
|
+
was found (the contract may have been deregistered), which allows
|
|
166
|
+
heartbeat processing to continue without errors.
|
|
167
|
+
"""
|
|
168
|
+
return await self._execute_postgres_op(
|
|
169
|
+
op_error_code=EnumPostgresErrorCode.HEARTBEAT_ERROR,
|
|
170
|
+
correlation_id=correlation_id,
|
|
171
|
+
log_context={
|
|
172
|
+
"contract_id": payload.contract_id,
|
|
173
|
+
"node_name": payload.node_name,
|
|
174
|
+
"source_node_id": payload.source_node_id,
|
|
175
|
+
"uptime_seconds": payload.uptime_seconds,
|
|
176
|
+
"sequence_number": payload.sequence_number,
|
|
177
|
+
},
|
|
178
|
+
fn=lambda: self._execute_heartbeat(payload, correlation_id),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def _execute_heartbeat(
|
|
182
|
+
self,
|
|
183
|
+
payload: ModelPayloadUpdateHeartbeat,
|
|
184
|
+
correlation_id: UUID,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Execute the heartbeat UPDATE query.
|
|
187
|
+
|
|
188
|
+
This method contains the handler-specific business logic:
|
|
189
|
+
- Execute the UPDATE query
|
|
190
|
+
- Parse the affected row count
|
|
191
|
+
- Log success/warning based on row_found
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
payload: Heartbeat payload with contract_id and timestamp.
|
|
195
|
+
correlation_id: Correlation ID for logging.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
Any exception from asyncpg (handled by MixinPostgresOpExecutor).
|
|
199
|
+
"""
|
|
200
|
+
async with self._pool.acquire() as conn:
|
|
201
|
+
# Execute update - returns status string like "UPDATE 1" or "UPDATE 0"
|
|
202
|
+
status = await conn.execute(
|
|
203
|
+
_UPDATE_HEARTBEAT_SQL,
|
|
204
|
+
payload.last_seen_at,
|
|
205
|
+
payload.contract_id,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Parse affected row count from status (format: "UPDATE N")
|
|
209
|
+
row_found = False
|
|
210
|
+
if status and status.startswith("UPDATE "):
|
|
211
|
+
try:
|
|
212
|
+
affected_rows = int(status.split()[1])
|
|
213
|
+
row_found = affected_rows > 0
|
|
214
|
+
except (IndexError, ValueError):
|
|
215
|
+
pass # Fallback to False if parsing fails
|
|
216
|
+
|
|
217
|
+
# Log for observability
|
|
218
|
+
_logger.info(
|
|
219
|
+
"Heartbeat update completed",
|
|
220
|
+
extra={
|
|
221
|
+
"correlation_id": str(correlation_id),
|
|
222
|
+
"contract_id": payload.contract_id,
|
|
223
|
+
"node_name": payload.node_name,
|
|
224
|
+
"row_found": row_found,
|
|
225
|
+
"source_node_id": payload.source_node_id,
|
|
226
|
+
"uptime_seconds": payload.uptime_seconds,
|
|
227
|
+
"sequence_number": payload.sequence_number,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Log warning if row not found (contract may be deregistered)
|
|
232
|
+
if not row_found:
|
|
233
|
+
_logger.warning(
|
|
234
|
+
"Heartbeat update found no matching active contract",
|
|
235
|
+
extra={
|
|
236
|
+
"correlation_id": str(correlation_id),
|
|
237
|
+
"contract_id": payload.contract_id,
|
|
238
|
+
"node_name": payload.node_name,
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__: list[str] = ["HandlerPostgresHeartbeat"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Handler for batch marking stale contracts as inactive.
|
|
4
|
+
|
|
5
|
+
This handler encapsulates PostgreSQL-specific staleness marking 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
|
+
HandlerPostgresMarkStale is responsible for:
|
|
11
|
+
- Executing batch update operations against PostgreSQL
|
|
12
|
+
- Returning structured ModelBackendResult with affected row count
|
|
13
|
+
|
|
14
|
+
Timing, error classification, and sanitization are delegated to
|
|
15
|
+
MixinPostgresOpExecutor to eliminate boilerplate drift across handlers.
|
|
16
|
+
|
|
17
|
+
Operation:
|
|
18
|
+
Marks all active contracts with last_seen_at before the stale_cutoff
|
|
19
|
+
timestamp as inactive. This is a batch operation that may affect
|
|
20
|
+
multiple rows in a single execution.
|
|
21
|
+
|
|
22
|
+
SQL:
|
|
23
|
+
UPDATE contracts
|
|
24
|
+
SET is_active = FALSE, deregistered_at = $1
|
|
25
|
+
WHERE is_active = TRUE AND last_seen_at < $2
|
|
26
|
+
|
|
27
|
+
Coroutine Safety:
|
|
28
|
+
This handler is stateless and coroutine-safe for concurrent calls
|
|
29
|
+
with different payload instances. Thread-safety depends on the
|
|
30
|
+
underlying asyncpg.Pool implementation.
|
|
31
|
+
|
|
32
|
+
Related:
|
|
33
|
+
- NodeContractPersistenceEffect: Parent effect node that coordinates handlers
|
|
34
|
+
- ModelPayloadMarkStale: Payload model defining staleness parameters
|
|
35
|
+
- ModelBackendResult: Structured result model for backend operations
|
|
36
|
+
- MixinPostgresOpExecutor: Shared execution core for timing/error handling
|
|
37
|
+
- OMN-1845: Implementation ticket
|
|
38
|
+
- OMN-1857: Executor extraction ticket
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
from typing import TYPE_CHECKING
|
|
45
|
+
from uuid import UUID
|
|
46
|
+
|
|
47
|
+
from omnibase_infra.enums import (
|
|
48
|
+
EnumHandlerType,
|
|
49
|
+
EnumHandlerTypeCategory,
|
|
50
|
+
EnumPostgresErrorCode,
|
|
51
|
+
)
|
|
52
|
+
from omnibase_infra.mixins.mixin_postgres_op_executor import MixinPostgresOpExecutor
|
|
53
|
+
from omnibase_infra.models.model_backend_result import ModelBackendResult
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
import asyncpg
|
|
57
|
+
|
|
58
|
+
from omnibase_infra.nodes.contract_registry_reducer.models.model_payload_mark_stale import (
|
|
59
|
+
ModelPayloadMarkStale,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
# SQL for batch marking stale contracts
|
|
65
|
+
_MARK_STALE_SQL = """
|
|
66
|
+
UPDATE contracts
|
|
67
|
+
SET is_active = FALSE, deregistered_at = $1
|
|
68
|
+
WHERE is_active = TRUE AND last_seen_at < $2
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HandlerPostgresMarkStale(MixinPostgresOpExecutor):
|
|
73
|
+
"""Handler for batch marking stale contracts as inactive.
|
|
74
|
+
|
|
75
|
+
Encapsulates PostgreSQL-specific batch staleness marking logic extracted
|
|
76
|
+
from NodeContractPersistenceEffect for declarative node compliance.
|
|
77
|
+
|
|
78
|
+
Timing, error classification, and sanitization are handled by the
|
|
79
|
+
MixinPostgresOpExecutor base class, reducing boilerplate and ensuring
|
|
80
|
+
consistent error handling across all PostgreSQL handlers.
|
|
81
|
+
|
|
82
|
+
The staleness operation marks contracts as inactive if their last_seen_at
|
|
83
|
+
timestamp is older than the specified stale_cutoff, supporting contract
|
|
84
|
+
lifecycle management through automatic deregistration of stale nodes.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
_pool: asyncpg connection pool for database operations.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> from unittest.mock import AsyncMock, MagicMock
|
|
91
|
+
>>> conn = MagicMock()
|
|
92
|
+
>>> conn.execute = AsyncMock(return_value="UPDATE 5")
|
|
93
|
+
>>> pool = MagicMock()
|
|
94
|
+
>>> pool.acquire = MagicMock(return_value=AsyncContextManager(conn))
|
|
95
|
+
>>> handler = HandlerPostgresMarkStale(pool)
|
|
96
|
+
>>> payload = MagicMock(stale_cutoff=datetime.now(), checked_at=datetime.now())
|
|
97
|
+
>>> result = await handler.handle(payload, uuid4())
|
|
98
|
+
>>> result.success
|
|
99
|
+
True
|
|
100
|
+
|
|
101
|
+
See Also:
|
|
102
|
+
- NodeContractPersistenceEffect: Parent node that uses this handler
|
|
103
|
+
- ModelPayloadMarkStale: Payload model for staleness parameters
|
|
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 executing batch update
|
|
112
|
+
operations against the contracts table.
|
|
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: ModelPayloadMarkStale,
|
|
129
|
+
correlation_id: UUID,
|
|
130
|
+
) -> ModelBackendResult:
|
|
131
|
+
"""Execute batch staleness update on contracts.
|
|
132
|
+
|
|
133
|
+
Marks all active contracts with last_seen_at before stale_cutoff
|
|
134
|
+
as inactive. This is a batch operation that may affect multiple
|
|
135
|
+
rows in a single execution.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
payload: Staleness parameters containing:
|
|
139
|
+
- stale_cutoff: Contracts older than this are marked stale
|
|
140
|
+
- checked_at: Timestamp used for deregistered_at value
|
|
141
|
+
correlation_id: Request correlation ID for distributed tracing.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
ModelBackendResult with:
|
|
145
|
+
- success: True if update 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
|
+
Note:
|
|
153
|
+
The number of affected rows is logged for observability but not
|
|
154
|
+
returned in the result model (ModelBackendResult does not support
|
|
155
|
+
metadata). Callers requiring the count should query the database
|
|
156
|
+
separately or rely on log aggregation.
|
|
157
|
+
"""
|
|
158
|
+
return await self._execute_postgres_op(
|
|
159
|
+
op_error_code=EnumPostgresErrorCode.MARK_STALE_ERROR,
|
|
160
|
+
correlation_id=correlation_id,
|
|
161
|
+
log_context={
|
|
162
|
+
"stale_cutoff": payload.stale_cutoff.isoformat(),
|
|
163
|
+
},
|
|
164
|
+
fn=lambda: self._execute_mark_stale(payload, correlation_id),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def _execute_mark_stale(
|
|
168
|
+
self,
|
|
169
|
+
payload: ModelPayloadMarkStale,
|
|
170
|
+
correlation_id: UUID,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Execute the batch staleness UPDATE query.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
payload: Mark stale payload with timestamps.
|
|
176
|
+
correlation_id: Correlation ID for logging.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
Any exception from asyncpg (handled by MixinPostgresOpExecutor).
|
|
180
|
+
"""
|
|
181
|
+
async with self._pool.acquire() as conn:
|
|
182
|
+
# Execute batch update - returns status string like "UPDATE 5"
|
|
183
|
+
status = await conn.execute(
|
|
184
|
+
_MARK_STALE_SQL,
|
|
185
|
+
payload.checked_at,
|
|
186
|
+
payload.stale_cutoff,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Parse affected row count from status (format: "UPDATE N")
|
|
190
|
+
affected_rows = 0
|
|
191
|
+
if status and status.startswith("UPDATE "):
|
|
192
|
+
try:
|
|
193
|
+
affected_rows = int(status.split()[1])
|
|
194
|
+
except (IndexError, ValueError):
|
|
195
|
+
pass # Fallback to 0 if parsing fails
|
|
196
|
+
|
|
197
|
+
# Log for observability
|
|
198
|
+
_logger.info(
|
|
199
|
+
"Mark stale operation completed",
|
|
200
|
+
extra={
|
|
201
|
+
"correlation_id": str(correlation_id),
|
|
202
|
+
"affected_rows": affected_rows,
|
|
203
|
+
"stale_cutoff": payload.stale_cutoff.isoformat(),
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__: list[str] = ["HandlerPostgresMarkStale"]
|