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.
- 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/errors/__init__.py +4 -0
- omnibase_infra/errors/error_infra.py +60 -0
- omnibase_infra/handlers/__init__.py +3 -0
- omnibase_infra/handlers/handler_slack_webhook.py +426 -0
- omnibase_infra/handlers/models/__init__.py +14 -0
- omnibase_infra/handlers/models/enum_alert_severity.py +36 -0
- omnibase_infra/handlers/models/model_slack_alert.py +24 -0
- omnibase_infra/handlers/models/model_slack_alert_payload.py +77 -0
- omnibase_infra/handlers/models/model_slack_alert_result.py +73 -0
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
- omnibase_infra/mixins/__init__.py +14 -0
- omnibase_infra/mixins/mixin_node_introspection.py +42 -20
- 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/models/discovery/model_dependency_spec.py +1 -0
- omnibase_infra/models/discovery/model_discovered_capabilities.py +1 -1
- omnibase_infra/models/discovery/model_introspection_config.py +28 -1
- omnibase_infra/models/discovery/model_introspection_performance_metrics.py +1 -0
- omnibase_infra/models/discovery/model_introspection_task_config.py +1 -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/models/runtime/__init__.py +4 -0
- omnibase_infra/models/runtime/model_resolved_dependencies.py +116 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +6 -5
- omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +9 -26
- 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 +131 -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 +251 -0
- omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +8 -12
- omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
- omnibase_infra/nodes/node_slack_alerter_effect/__init__.py +33 -0
- omnibase_infra/nodes/node_slack_alerter_effect/contract.yaml +291 -0
- omnibase_infra/nodes/node_slack_alerter_effect/node.py +106 -0
- omnibase_infra/projectors/__init__.py +6 -0
- omnibase_infra/projectors/projection_reader_contract.py +1301 -0
- omnibase_infra/runtime/__init__.py +12 -0
- omnibase_infra/runtime/baseline_subscriptions.py +13 -6
- omnibase_infra/runtime/contract_dependency_resolver.py +455 -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/emit_daemon/event_registry.py +34 -22
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +63 -23
- 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/publisher_topic_scoped.py +16 -11
- omnibase_infra/runtime/registry_policy.py +29 -15
- omnibase_infra/runtime/request_response_wiring.py +793 -0
- omnibase_infra/runtime/service_kernel.py +295 -8
- omnibase_infra/runtime/service_runtime_host_process.py +149 -5
- omnibase_infra/runtime/util_version.py +5 -1
- omnibase_infra/schemas/schema_latency_baseline.sql +135 -0
- omnibase_infra/services/contract_publisher/config.py +4 -4
- omnibase_infra/services/contract_publisher/service.py +8 -5
- omnibase_infra/services/observability/injection_effectiveness/__init__.py +67 -0
- omnibase_infra/services/observability/injection_effectiveness/config.py +295 -0
- omnibase_infra/services/observability/injection_effectiveness/consumer.py +1461 -0
- omnibase_infra/services/observability/injection_effectiveness/models/__init__.py +32 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_agent_match.py +79 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_context_utilization.py +118 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_latency_breakdown.py +107 -0
- omnibase_infra/services/observability/injection_effectiveness/models/model_pattern_utilization.py +46 -0
- omnibase_infra/services/observability/injection_effectiveness/writer_postgres.py +596 -0
- 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/utils/__init__.py +7 -0
- omnibase_infra/utils/util_db_error_context.py +292 -0
- omnibase_infra/validation/infra_validators.py +3 -1
- omnibase_infra/validation/validation_exemptions.yaml +65 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/METADATA +3 -3
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/RECORD +117 -58
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Contract Projection Reader Implementation.
|
|
4
|
+
|
|
5
|
+
Implements projection reads for the contract registry domain to support
|
|
6
|
+
Registry API queries. Provides access to contracts and topics stored in
|
|
7
|
+
PostgreSQL by the NodeContractRegistryReducer.
|
|
8
|
+
|
|
9
|
+
Concurrency Safety:
|
|
10
|
+
This implementation is coroutine-safe for concurrent async read operations.
|
|
11
|
+
Uses asyncpg connection pool for connection management, and asyncio.Lock
|
|
12
|
+
(via MixinAsyncCircuitBreaker) for circuit breaker state protection.
|
|
13
|
+
|
|
14
|
+
Note: This is not thread-safe. For multi-threaded access, additional
|
|
15
|
+
synchronization would be required.
|
|
16
|
+
|
|
17
|
+
Related Tickets:
|
|
18
|
+
- OMN-1845: Create ProjectionReaderContract for contract/topic queries
|
|
19
|
+
- OMN-1653: Contract registry state materialization
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from uuid import UUID, uuid4
|
|
27
|
+
|
|
28
|
+
import asyncpg
|
|
29
|
+
|
|
30
|
+
from omnibase_infra.enums import EnumInfraTransportType
|
|
31
|
+
from omnibase_infra.errors import (
|
|
32
|
+
InfraConnectionError,
|
|
33
|
+
InfraTimeoutError,
|
|
34
|
+
ModelInfraErrorContext,
|
|
35
|
+
ModelTimeoutErrorContext,
|
|
36
|
+
RuntimeHostError,
|
|
37
|
+
)
|
|
38
|
+
from omnibase_infra.mixins import MixinAsyncCircuitBreaker
|
|
39
|
+
from omnibase_infra.models.projection.model_contract_projection import (
|
|
40
|
+
ModelContractProjection,
|
|
41
|
+
)
|
|
42
|
+
from omnibase_infra.models.projection.model_topic_projection import (
|
|
43
|
+
ModelTopicProjection,
|
|
44
|
+
)
|
|
45
|
+
from omnibase_infra.models.resilience import ModelCircuitBreakerConfig
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProjectionReaderContract(MixinAsyncCircuitBreaker):
|
|
51
|
+
"""Contract projection reader implementation using asyncpg.
|
|
52
|
+
|
|
53
|
+
Provides read access to contract and topic projections for the Registry API.
|
|
54
|
+
Supports contract lookups, searches, and topic routing queries.
|
|
55
|
+
|
|
56
|
+
Circuit Breaker:
|
|
57
|
+
Uses MixinAsyncCircuitBreaker for resilience. Opens after 5 consecutive
|
|
58
|
+
failures and resets after 60 seconds.
|
|
59
|
+
|
|
60
|
+
Security:
|
|
61
|
+
All queries use parameterized statements for SQL injection protection.
|
|
62
|
+
|
|
63
|
+
Error Handling Pattern:
|
|
64
|
+
All public methods follow a consistent error handling structure:
|
|
65
|
+
|
|
66
|
+
1. Create fresh ModelInfraErrorContext per operation (intentionally NOT
|
|
67
|
+
reused to ensure each operation has isolated context with its own
|
|
68
|
+
correlation ID for distributed tracing).
|
|
69
|
+
|
|
70
|
+
2. Check circuit breaker before database operation.
|
|
71
|
+
|
|
72
|
+
3. Map exceptions consistently:
|
|
73
|
+
- asyncpg.PostgresConnectionError -> InfraConnectionError
|
|
74
|
+
- asyncpg.QueryCanceledError -> InfraTimeoutError
|
|
75
|
+
- Generic Exception -> RuntimeHostError
|
|
76
|
+
|
|
77
|
+
4. Record circuit breaker failures for all exception types.
|
|
78
|
+
|
|
79
|
+
JSONB Handling:
|
|
80
|
+
The contract_ids field in topics table is stored as JSONB. While asyncpg
|
|
81
|
+
typically returns JSONB as Python lists, some connection configurations
|
|
82
|
+
may return strings. The _row_to_topic_projection method handles both cases.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> pool = await asyncpg.create_pool(dsn)
|
|
86
|
+
>>> reader = ProjectionReaderContract(pool)
|
|
87
|
+
>>> contract = await reader.get_contract_by_id("my-node:1.0.0")
|
|
88
|
+
>>> if contract and contract.is_active:
|
|
89
|
+
... print(f"Contract hash: {contract.contract_hash}")
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, pool: asyncpg.Pool) -> None:
|
|
93
|
+
"""Initialize reader with connection pool.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
pool: asyncpg connection pool for database access.
|
|
97
|
+
Pool should be created by the caller (e.g., from HandlerDb).
|
|
98
|
+
"""
|
|
99
|
+
self._pool = pool
|
|
100
|
+
config = ModelCircuitBreakerConfig.from_env(
|
|
101
|
+
service_name="projection_reader.contract",
|
|
102
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
103
|
+
)
|
|
104
|
+
self._init_circuit_breaker_from_config(config)
|
|
105
|
+
|
|
106
|
+
def _row_to_contract_projection(
|
|
107
|
+
self, row: asyncpg.Record
|
|
108
|
+
) -> ModelContractProjection:
|
|
109
|
+
"""Convert database row to contract projection model.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
row: asyncpg Record from query result
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
ModelContractProjection instance
|
|
116
|
+
"""
|
|
117
|
+
return ModelContractProjection(
|
|
118
|
+
contract_id=row["contract_id"],
|
|
119
|
+
node_name=row["node_name"],
|
|
120
|
+
version_major=row["version_major"],
|
|
121
|
+
version_minor=row["version_minor"],
|
|
122
|
+
version_patch=row["version_patch"],
|
|
123
|
+
contract_hash=row["contract_hash"],
|
|
124
|
+
contract_yaml=row["contract_yaml"],
|
|
125
|
+
registered_at=row["registered_at"],
|
|
126
|
+
deregistered_at=row["deregistered_at"],
|
|
127
|
+
last_seen_at=row["last_seen_at"],
|
|
128
|
+
is_active=row["is_active"],
|
|
129
|
+
last_event_topic=row["last_event_topic"],
|
|
130
|
+
last_event_partition=row["last_event_partition"],
|
|
131
|
+
last_event_offset=row["last_event_offset"],
|
|
132
|
+
created_at=row["created_at"],
|
|
133
|
+
updated_at=row["updated_at"],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _row_to_topic_projection(self, row: asyncpg.Record) -> ModelTopicProjection:
|
|
137
|
+
"""Convert database row to topic projection model.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
row: asyncpg Record from query result
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
ModelTopicProjection instance
|
|
144
|
+
|
|
145
|
+
Note:
|
|
146
|
+
Handles JSONB contract_ids which may be returned as string or list
|
|
147
|
+
depending on connection configuration.
|
|
148
|
+
"""
|
|
149
|
+
# Parse contract_ids from JSONB
|
|
150
|
+
contract_ids_data = row["contract_ids"]
|
|
151
|
+
if isinstance(contract_ids_data, str):
|
|
152
|
+
try:
|
|
153
|
+
contract_ids_data = json.loads(contract_ids_data)
|
|
154
|
+
except json.JSONDecodeError as e:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"Failed to parse contract_ids JSON for topic %s/%s: %s. "
|
|
157
|
+
"Using empty list.",
|
|
158
|
+
row["topic_suffix"],
|
|
159
|
+
row["direction"],
|
|
160
|
+
str(e),
|
|
161
|
+
)
|
|
162
|
+
contract_ids_data = []
|
|
163
|
+
|
|
164
|
+
return ModelTopicProjection(
|
|
165
|
+
topic_suffix=row["topic_suffix"],
|
|
166
|
+
direction=row["direction"],
|
|
167
|
+
contract_ids=contract_ids_data or [],
|
|
168
|
+
first_seen_at=row["first_seen_at"],
|
|
169
|
+
last_seen_at=row["last_seen_at"],
|
|
170
|
+
is_active=row["is_active"],
|
|
171
|
+
created_at=row["created_at"],
|
|
172
|
+
updated_at=row["updated_at"],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ============================================================
|
|
176
|
+
# Contract Query Methods
|
|
177
|
+
# ============================================================
|
|
178
|
+
|
|
179
|
+
async def get_contract_by_id(
|
|
180
|
+
self,
|
|
181
|
+
contract_id: str,
|
|
182
|
+
correlation_id: UUID | None = None,
|
|
183
|
+
) -> ModelContractProjection | None:
|
|
184
|
+
"""Get contract by ID.
|
|
185
|
+
|
|
186
|
+
Point lookup for a single contract by its natural key.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
contract_id: Contract ID (e.g., "my-node:1.0.0")
|
|
190
|
+
correlation_id: Optional correlation ID for tracing
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Contract projection if exists, None otherwise
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
InfraConnectionError: If database connection fails
|
|
197
|
+
InfraTimeoutError: If query times out
|
|
198
|
+
RuntimeHostError: For other database errors
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
>>> contract = await reader.get_contract_by_id("my-node:1.0.0")
|
|
202
|
+
>>> if contract:
|
|
203
|
+
... print(f"Active: {contract.is_active}")
|
|
204
|
+
"""
|
|
205
|
+
corr_id = correlation_id or uuid4()
|
|
206
|
+
ctx = ModelInfraErrorContext(
|
|
207
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
208
|
+
operation="get_contract_by_id",
|
|
209
|
+
target_name="projection_reader.contract",
|
|
210
|
+
correlation_id=corr_id,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async with self._circuit_breaker_lock:
|
|
214
|
+
await self._check_circuit_breaker("get_contract_by_id", corr_id)
|
|
215
|
+
|
|
216
|
+
query_sql = """
|
|
217
|
+
SELECT * FROM contracts
|
|
218
|
+
WHERE contract_id = $1
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
async with self._pool.acquire() as conn:
|
|
223
|
+
row = await conn.fetchrow(query_sql, contract_id)
|
|
224
|
+
|
|
225
|
+
async with self._circuit_breaker_lock:
|
|
226
|
+
await self._reset_circuit_breaker()
|
|
227
|
+
|
|
228
|
+
if row is None:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
return self._row_to_contract_projection(row)
|
|
232
|
+
|
|
233
|
+
except asyncpg.PostgresConnectionError as e:
|
|
234
|
+
async with self._circuit_breaker_lock:
|
|
235
|
+
await self._record_circuit_failure("get_contract_by_id", corr_id)
|
|
236
|
+
raise InfraConnectionError(
|
|
237
|
+
"Failed to connect to database for contract lookup",
|
|
238
|
+
context=ctx,
|
|
239
|
+
) from e
|
|
240
|
+
|
|
241
|
+
except asyncpg.QueryCanceledError as e:
|
|
242
|
+
async with self._circuit_breaker_lock:
|
|
243
|
+
await self._record_circuit_failure("get_contract_by_id", corr_id)
|
|
244
|
+
raise InfraTimeoutError(
|
|
245
|
+
"Contract lookup timed out",
|
|
246
|
+
context=ModelTimeoutErrorContext(
|
|
247
|
+
transport_type=ctx.transport_type,
|
|
248
|
+
operation=ctx.operation,
|
|
249
|
+
target_name=ctx.target_name,
|
|
250
|
+
correlation_id=ctx.correlation_id,
|
|
251
|
+
),
|
|
252
|
+
) from e
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
async with self._circuit_breaker_lock:
|
|
256
|
+
await self._record_circuit_failure("get_contract_by_id", corr_id)
|
|
257
|
+
raise RuntimeHostError(
|
|
258
|
+
f"Failed to get contract by ID: {type(e).__name__}",
|
|
259
|
+
context=ctx,
|
|
260
|
+
) from e
|
|
261
|
+
|
|
262
|
+
async def list_active_contracts(
|
|
263
|
+
self,
|
|
264
|
+
limit: int = 100,
|
|
265
|
+
offset: int = 0,
|
|
266
|
+
correlation_id: UUID | None = None,
|
|
267
|
+
) -> list[ModelContractProjection]:
|
|
268
|
+
"""List active contracts with pagination.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
limit: Maximum results to return (default: 100)
|
|
272
|
+
offset: Number of results to skip (default: 0)
|
|
273
|
+
correlation_id: Optional correlation ID for tracing
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of active contract projections
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
InfraConnectionError: If database connection fails
|
|
280
|
+
InfraTimeoutError: If query times out
|
|
281
|
+
RuntimeHostError: For other database errors
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
>>> contracts = await reader.list_active_contracts(limit=50, offset=0)
|
|
285
|
+
>>> for c in contracts:
|
|
286
|
+
... print(f"{c.contract_id}: {c.node_name}")
|
|
287
|
+
"""
|
|
288
|
+
# Validate pagination parameters
|
|
289
|
+
offset = max(offset, 0)
|
|
290
|
+
if limit <= 0:
|
|
291
|
+
limit = 100
|
|
292
|
+
elif limit > 1000:
|
|
293
|
+
limit = 1000
|
|
294
|
+
|
|
295
|
+
corr_id = correlation_id or uuid4()
|
|
296
|
+
ctx = ModelInfraErrorContext(
|
|
297
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
298
|
+
operation="list_active_contracts",
|
|
299
|
+
target_name="projection_reader.contract",
|
|
300
|
+
correlation_id=corr_id,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async with self._circuit_breaker_lock:
|
|
304
|
+
await self._check_circuit_breaker("list_active_contracts", corr_id)
|
|
305
|
+
|
|
306
|
+
query_sql = """
|
|
307
|
+
SELECT * FROM contracts
|
|
308
|
+
WHERE is_active = TRUE
|
|
309
|
+
ORDER BY last_seen_at DESC
|
|
310
|
+
LIMIT $1 OFFSET $2
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
async with self._pool.acquire() as conn:
|
|
315
|
+
rows = await conn.fetch(query_sql, limit, offset)
|
|
316
|
+
|
|
317
|
+
async with self._circuit_breaker_lock:
|
|
318
|
+
await self._reset_circuit_breaker()
|
|
319
|
+
|
|
320
|
+
return [self._row_to_contract_projection(row) for row in rows]
|
|
321
|
+
|
|
322
|
+
except asyncpg.PostgresConnectionError as e:
|
|
323
|
+
async with self._circuit_breaker_lock:
|
|
324
|
+
await self._record_circuit_failure("list_active_contracts", corr_id)
|
|
325
|
+
raise InfraConnectionError(
|
|
326
|
+
"Failed to connect to database for active contracts query",
|
|
327
|
+
context=ctx,
|
|
328
|
+
) from e
|
|
329
|
+
|
|
330
|
+
except asyncpg.QueryCanceledError as e:
|
|
331
|
+
async with self._circuit_breaker_lock:
|
|
332
|
+
await self._record_circuit_failure("list_active_contracts", corr_id)
|
|
333
|
+
raise InfraTimeoutError(
|
|
334
|
+
"Active contracts query timed out",
|
|
335
|
+
context=ModelTimeoutErrorContext(
|
|
336
|
+
transport_type=ctx.transport_type,
|
|
337
|
+
operation=ctx.operation,
|
|
338
|
+
target_name=ctx.target_name,
|
|
339
|
+
correlation_id=ctx.correlation_id,
|
|
340
|
+
),
|
|
341
|
+
) from e
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
async with self._circuit_breaker_lock:
|
|
345
|
+
await self._record_circuit_failure("list_active_contracts", corr_id)
|
|
346
|
+
raise RuntimeHostError(
|
|
347
|
+
f"Failed to list active contracts: {type(e).__name__}",
|
|
348
|
+
context=ctx,
|
|
349
|
+
) from e
|
|
350
|
+
|
|
351
|
+
async def list_all_contracts(
|
|
352
|
+
self,
|
|
353
|
+
include_inactive: bool = True,
|
|
354
|
+
limit: int = 100,
|
|
355
|
+
offset: int = 0,
|
|
356
|
+
correlation_id: UUID | None = None,
|
|
357
|
+
) -> list[ModelContractProjection]:
|
|
358
|
+
"""List all contracts with pagination and optional inactive filter.
|
|
359
|
+
|
|
360
|
+
Retrieves contracts from the registry, optionally including inactive
|
|
361
|
+
(deregistered) contracts. Useful for administrative views and auditing.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
include_inactive: If True, include deregistered contracts.
|
|
365
|
+
If False, equivalent to list_active_contracts. Default: True.
|
|
366
|
+
limit: Maximum results to return (default: 100, max: 1000)
|
|
367
|
+
offset: Number of results to skip (default: 0)
|
|
368
|
+
correlation_id: Optional correlation ID for tracing
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of contract projections ordered by last_seen_at descending
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
InfraConnectionError: If database connection fails
|
|
375
|
+
InfraTimeoutError: If query times out
|
|
376
|
+
RuntimeHostError: For other database errors
|
|
377
|
+
|
|
378
|
+
Example:
|
|
379
|
+
>>> # Get all contracts including inactive
|
|
380
|
+
>>> all_contracts = await reader.list_all_contracts(
|
|
381
|
+
... include_inactive=True, limit=50, offset=0
|
|
382
|
+
... )
|
|
383
|
+
>>> for c in all_contracts:
|
|
384
|
+
... status = "active" if c.is_active else "inactive"
|
|
385
|
+
... print(f"{c.contract_id}: {status}")
|
|
386
|
+
>>>
|
|
387
|
+
>>> # Get only active contracts (same as list_active_contracts)
|
|
388
|
+
>>> active_only = await reader.list_all_contracts(include_inactive=False)
|
|
389
|
+
"""
|
|
390
|
+
# Validate pagination parameters
|
|
391
|
+
offset = max(offset, 0)
|
|
392
|
+
if limit <= 0:
|
|
393
|
+
limit = 100
|
|
394
|
+
elif limit > 1000:
|
|
395
|
+
limit = 1000
|
|
396
|
+
|
|
397
|
+
corr_id = correlation_id or uuid4()
|
|
398
|
+
ctx = ModelInfraErrorContext(
|
|
399
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
400
|
+
operation="list_all_contracts",
|
|
401
|
+
target_name="projection_reader.contract",
|
|
402
|
+
correlation_id=corr_id,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
async with self._circuit_breaker_lock:
|
|
406
|
+
await self._check_circuit_breaker("list_all_contracts", corr_id)
|
|
407
|
+
|
|
408
|
+
if include_inactive:
|
|
409
|
+
query_sql = """
|
|
410
|
+
SELECT * FROM contracts
|
|
411
|
+
ORDER BY last_seen_at DESC
|
|
412
|
+
LIMIT $1 OFFSET $2
|
|
413
|
+
"""
|
|
414
|
+
params = [limit, offset]
|
|
415
|
+
else:
|
|
416
|
+
query_sql = """
|
|
417
|
+
SELECT * FROM contracts
|
|
418
|
+
WHERE is_active = TRUE
|
|
419
|
+
ORDER BY last_seen_at DESC
|
|
420
|
+
LIMIT $1 OFFSET $2
|
|
421
|
+
"""
|
|
422
|
+
params = [limit, offset]
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
async with self._pool.acquire() as conn:
|
|
426
|
+
rows = await conn.fetch(query_sql, *params)
|
|
427
|
+
|
|
428
|
+
async with self._circuit_breaker_lock:
|
|
429
|
+
await self._reset_circuit_breaker()
|
|
430
|
+
|
|
431
|
+
return [self._row_to_contract_projection(row) for row in rows]
|
|
432
|
+
|
|
433
|
+
except asyncpg.PostgresConnectionError as e:
|
|
434
|
+
async with self._circuit_breaker_lock:
|
|
435
|
+
await self._record_circuit_failure("list_all_contracts", corr_id)
|
|
436
|
+
raise InfraConnectionError(
|
|
437
|
+
"Failed to connect to database for all contracts query",
|
|
438
|
+
context=ctx,
|
|
439
|
+
) from e
|
|
440
|
+
|
|
441
|
+
except asyncpg.QueryCanceledError as e:
|
|
442
|
+
async with self._circuit_breaker_lock:
|
|
443
|
+
await self._record_circuit_failure("list_all_contracts", corr_id)
|
|
444
|
+
raise InfraTimeoutError(
|
|
445
|
+
"All contracts query timed out",
|
|
446
|
+
context=ModelTimeoutErrorContext(
|
|
447
|
+
transport_type=ctx.transport_type,
|
|
448
|
+
operation=ctx.operation,
|
|
449
|
+
target_name=ctx.target_name,
|
|
450
|
+
correlation_id=ctx.correlation_id,
|
|
451
|
+
),
|
|
452
|
+
) from e
|
|
453
|
+
|
|
454
|
+
except Exception as e:
|
|
455
|
+
async with self._circuit_breaker_lock:
|
|
456
|
+
await self._record_circuit_failure("list_all_contracts", corr_id)
|
|
457
|
+
raise RuntimeHostError(
|
|
458
|
+
f"Failed to list all contracts: {type(e).__name__}",
|
|
459
|
+
context=ctx,
|
|
460
|
+
) from e
|
|
461
|
+
|
|
462
|
+
async def list_contracts_by_node_name(
|
|
463
|
+
self,
|
|
464
|
+
node_name: str,
|
|
465
|
+
include_inactive: bool = False,
|
|
466
|
+
correlation_id: UUID | None = None,
|
|
467
|
+
) -> list[ModelContractProjection]:
|
|
468
|
+
"""List all contracts for a node name.
|
|
469
|
+
|
|
470
|
+
Retrieves all versions of a contract by node name. Useful for
|
|
471
|
+
checking available versions of a node.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
node_name: ONEX node name to search for
|
|
475
|
+
include_inactive: Whether to include deregistered contracts
|
|
476
|
+
correlation_id: Optional correlation ID for tracing
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
List of contract projections ordered by version descending
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
InfraConnectionError: If database connection fails
|
|
483
|
+
InfraTimeoutError: If query times out
|
|
484
|
+
RuntimeHostError: For other database errors
|
|
485
|
+
|
|
486
|
+
Example:
|
|
487
|
+
>>> contracts = await reader.list_contracts_by_node_name("my-node")
|
|
488
|
+
>>> for c in contracts:
|
|
489
|
+
... print(f"{c.version_string}: active={c.is_active}")
|
|
490
|
+
"""
|
|
491
|
+
corr_id = correlation_id or uuid4()
|
|
492
|
+
ctx = ModelInfraErrorContext(
|
|
493
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
494
|
+
operation="list_contracts_by_node_name",
|
|
495
|
+
target_name="projection_reader.contract",
|
|
496
|
+
correlation_id=corr_id,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
async with self._circuit_breaker_lock:
|
|
500
|
+
await self._check_circuit_breaker("list_contracts_by_node_name", corr_id)
|
|
501
|
+
|
|
502
|
+
# Params are the same for both queries
|
|
503
|
+
params = [node_name]
|
|
504
|
+
|
|
505
|
+
if include_inactive:
|
|
506
|
+
query_sql = """
|
|
507
|
+
SELECT * FROM contracts
|
|
508
|
+
WHERE node_name = $1
|
|
509
|
+
ORDER BY version_major DESC, version_minor DESC, version_patch DESC
|
|
510
|
+
"""
|
|
511
|
+
else:
|
|
512
|
+
query_sql = """
|
|
513
|
+
SELECT * FROM contracts
|
|
514
|
+
WHERE node_name = $1 AND is_active = TRUE
|
|
515
|
+
ORDER BY version_major DESC, version_minor DESC, version_patch DESC
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
async with self._pool.acquire() as conn:
|
|
520
|
+
rows = await conn.fetch(query_sql, *params)
|
|
521
|
+
|
|
522
|
+
async with self._circuit_breaker_lock:
|
|
523
|
+
await self._reset_circuit_breaker()
|
|
524
|
+
|
|
525
|
+
return [self._row_to_contract_projection(row) for row in rows]
|
|
526
|
+
|
|
527
|
+
except asyncpg.PostgresConnectionError as e:
|
|
528
|
+
async with self._circuit_breaker_lock:
|
|
529
|
+
await self._record_circuit_failure(
|
|
530
|
+
"list_contracts_by_node_name", corr_id
|
|
531
|
+
)
|
|
532
|
+
raise InfraConnectionError(
|
|
533
|
+
"Failed to connect to database for node name query",
|
|
534
|
+
context=ctx,
|
|
535
|
+
) from e
|
|
536
|
+
|
|
537
|
+
except asyncpg.QueryCanceledError as e:
|
|
538
|
+
async with self._circuit_breaker_lock:
|
|
539
|
+
await self._record_circuit_failure(
|
|
540
|
+
"list_contracts_by_node_name", corr_id
|
|
541
|
+
)
|
|
542
|
+
raise InfraTimeoutError(
|
|
543
|
+
"Node name query timed out",
|
|
544
|
+
context=ModelTimeoutErrorContext(
|
|
545
|
+
transport_type=ctx.transport_type,
|
|
546
|
+
operation=ctx.operation,
|
|
547
|
+
target_name=ctx.target_name,
|
|
548
|
+
correlation_id=ctx.correlation_id,
|
|
549
|
+
),
|
|
550
|
+
) from e
|
|
551
|
+
|
|
552
|
+
except Exception as e:
|
|
553
|
+
async with self._circuit_breaker_lock:
|
|
554
|
+
await self._record_circuit_failure(
|
|
555
|
+
"list_contracts_by_node_name", corr_id
|
|
556
|
+
)
|
|
557
|
+
raise RuntimeHostError(
|
|
558
|
+
f"Failed to list contracts by node name: {type(e).__name__}",
|
|
559
|
+
context=ctx,
|
|
560
|
+
) from e
|
|
561
|
+
|
|
562
|
+
async def search_contracts(
|
|
563
|
+
self,
|
|
564
|
+
query: str,
|
|
565
|
+
limit: int = 100,
|
|
566
|
+
correlation_id: UUID | None = None,
|
|
567
|
+
) -> list[ModelContractProjection]:
|
|
568
|
+
"""Search contracts by node name.
|
|
569
|
+
|
|
570
|
+
Performs case-insensitive search on node_name field.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
query: Search query string
|
|
574
|
+
limit: Maximum results to return (default: 100)
|
|
575
|
+
correlation_id: Optional correlation ID for tracing
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
List of matching contract projections
|
|
579
|
+
|
|
580
|
+
Raises:
|
|
581
|
+
InfraConnectionError: If database connection fails
|
|
582
|
+
InfraTimeoutError: If query times out
|
|
583
|
+
RuntimeHostError: For other database errors
|
|
584
|
+
|
|
585
|
+
Example:
|
|
586
|
+
>>> contracts = await reader.search_contracts("registry")
|
|
587
|
+
>>> for c in contracts:
|
|
588
|
+
... print(f"{c.node_name}: {c.contract_id}")
|
|
589
|
+
"""
|
|
590
|
+
# Validate pagination parameters
|
|
591
|
+
if limit <= 0:
|
|
592
|
+
logger.debug("Invalid limit %d corrected to default 100", limit)
|
|
593
|
+
limit = 100
|
|
594
|
+
elif limit > 1000:
|
|
595
|
+
logger.debug("Limit %d exceeds maximum, corrected to 1000", limit)
|
|
596
|
+
limit = 1000
|
|
597
|
+
|
|
598
|
+
corr_id = correlation_id or uuid4()
|
|
599
|
+
ctx = ModelInfraErrorContext(
|
|
600
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
601
|
+
operation="search_contracts",
|
|
602
|
+
target_name="projection_reader.contract",
|
|
603
|
+
correlation_id=corr_id,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
async with self._circuit_breaker_lock:
|
|
607
|
+
await self._check_circuit_breaker("search_contracts", corr_id)
|
|
608
|
+
|
|
609
|
+
# Escape ILIKE metacharacters to prevent pattern injection
|
|
610
|
+
# The backslash escapes % and _ so they match literally
|
|
611
|
+
escaped_query = (
|
|
612
|
+
query.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
query_sql = """
|
|
616
|
+
SELECT * FROM contracts
|
|
617
|
+
WHERE node_name ILIKE '%' || $1 || '%'
|
|
618
|
+
ORDER BY is_active DESC, last_seen_at DESC
|
|
619
|
+
LIMIT $2
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
async with self._pool.acquire() as conn:
|
|
624
|
+
rows = await conn.fetch(query_sql, escaped_query, limit)
|
|
625
|
+
|
|
626
|
+
async with self._circuit_breaker_lock:
|
|
627
|
+
await self._reset_circuit_breaker()
|
|
628
|
+
|
|
629
|
+
return [self._row_to_contract_projection(row) for row in rows]
|
|
630
|
+
|
|
631
|
+
except asyncpg.PostgresConnectionError as e:
|
|
632
|
+
async with self._circuit_breaker_lock:
|
|
633
|
+
await self._record_circuit_failure("search_contracts", corr_id)
|
|
634
|
+
raise InfraConnectionError(
|
|
635
|
+
"Failed to connect to database for contract search",
|
|
636
|
+
context=ctx,
|
|
637
|
+
) from e
|
|
638
|
+
|
|
639
|
+
except asyncpg.QueryCanceledError as e:
|
|
640
|
+
async with self._circuit_breaker_lock:
|
|
641
|
+
await self._record_circuit_failure("search_contracts", corr_id)
|
|
642
|
+
raise InfraTimeoutError(
|
|
643
|
+
"Contract search timed out",
|
|
644
|
+
context=ModelTimeoutErrorContext(
|
|
645
|
+
transport_type=ctx.transport_type,
|
|
646
|
+
operation=ctx.operation,
|
|
647
|
+
target_name=ctx.target_name,
|
|
648
|
+
correlation_id=ctx.correlation_id,
|
|
649
|
+
),
|
|
650
|
+
) from e
|
|
651
|
+
|
|
652
|
+
except Exception as e:
|
|
653
|
+
async with self._circuit_breaker_lock:
|
|
654
|
+
await self._record_circuit_failure("search_contracts", corr_id)
|
|
655
|
+
raise RuntimeHostError(
|
|
656
|
+
f"Failed to search contracts: {type(e).__name__}",
|
|
657
|
+
context=ctx,
|
|
658
|
+
) from e
|
|
659
|
+
|
|
660
|
+
async def count_contracts_by_status(
|
|
661
|
+
self,
|
|
662
|
+
correlation_id: UUID | None = None,
|
|
663
|
+
) -> dict[str, int]:
|
|
664
|
+
"""Count contracts by active/inactive status.
|
|
665
|
+
|
|
666
|
+
Returns aggregated counts for monitoring and dashboards.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
correlation_id: Optional correlation ID for tracing
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Dict with 'active' and 'inactive' counts
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
InfraConnectionError: If database connection fails
|
|
676
|
+
InfraTimeoutError: If query times out
|
|
677
|
+
RuntimeHostError: For other database errors
|
|
678
|
+
|
|
679
|
+
Example:
|
|
680
|
+
>>> counts = await reader.count_contracts_by_status()
|
|
681
|
+
>>> print(f"Active: {counts['active']}, Inactive: {counts['inactive']}")
|
|
682
|
+
"""
|
|
683
|
+
corr_id = correlation_id or uuid4()
|
|
684
|
+
ctx = ModelInfraErrorContext(
|
|
685
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
686
|
+
operation="count_contracts_by_status",
|
|
687
|
+
target_name="projection_reader.contract",
|
|
688
|
+
correlation_id=corr_id,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
async with self._circuit_breaker_lock:
|
|
692
|
+
await self._check_circuit_breaker("count_contracts_by_status", corr_id)
|
|
693
|
+
|
|
694
|
+
query_sql = """
|
|
695
|
+
SELECT is_active, COUNT(*) as count
|
|
696
|
+
FROM contracts
|
|
697
|
+
GROUP BY is_active
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
async with self._pool.acquire() as conn:
|
|
702
|
+
rows = await conn.fetch(query_sql)
|
|
703
|
+
|
|
704
|
+
async with self._circuit_breaker_lock:
|
|
705
|
+
await self._reset_circuit_breaker()
|
|
706
|
+
|
|
707
|
+
result: dict[str, int] = {"active": 0, "inactive": 0}
|
|
708
|
+
for row in rows:
|
|
709
|
+
if row["is_active"]:
|
|
710
|
+
result["active"] = row["count"]
|
|
711
|
+
else:
|
|
712
|
+
result["inactive"] = row["count"]
|
|
713
|
+
|
|
714
|
+
return result
|
|
715
|
+
|
|
716
|
+
except asyncpg.PostgresConnectionError as e:
|
|
717
|
+
async with self._circuit_breaker_lock:
|
|
718
|
+
await self._record_circuit_failure("count_contracts_by_status", corr_id)
|
|
719
|
+
raise InfraConnectionError(
|
|
720
|
+
"Failed to connect to database for contract count",
|
|
721
|
+
context=ctx,
|
|
722
|
+
) from e
|
|
723
|
+
|
|
724
|
+
except asyncpg.QueryCanceledError as e:
|
|
725
|
+
async with self._circuit_breaker_lock:
|
|
726
|
+
await self._record_circuit_failure("count_contracts_by_status", corr_id)
|
|
727
|
+
raise InfraTimeoutError(
|
|
728
|
+
"Contract count query timed out",
|
|
729
|
+
context=ModelTimeoutErrorContext(
|
|
730
|
+
transport_type=ctx.transport_type,
|
|
731
|
+
operation=ctx.operation,
|
|
732
|
+
target_name=ctx.target_name,
|
|
733
|
+
correlation_id=ctx.correlation_id,
|
|
734
|
+
),
|
|
735
|
+
) from e
|
|
736
|
+
|
|
737
|
+
except Exception as e:
|
|
738
|
+
async with self._circuit_breaker_lock:
|
|
739
|
+
await self._record_circuit_failure("count_contracts_by_status", corr_id)
|
|
740
|
+
raise RuntimeHostError(
|
|
741
|
+
f"Failed to count contracts: {type(e).__name__}",
|
|
742
|
+
context=ctx,
|
|
743
|
+
) from e
|
|
744
|
+
|
|
745
|
+
# ============================================================
|
|
746
|
+
# Topic Query Methods
|
|
747
|
+
# ============================================================
|
|
748
|
+
|
|
749
|
+
async def list_topics(
|
|
750
|
+
self,
|
|
751
|
+
direction: str | None = None,
|
|
752
|
+
limit: int = 100,
|
|
753
|
+
offset: int = 0,
|
|
754
|
+
correlation_id: UUID | None = None,
|
|
755
|
+
) -> list[ModelTopicProjection]:
|
|
756
|
+
"""List topics with optional direction filter.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
direction: Optional filter by direction ('publish' or 'subscribe')
|
|
760
|
+
limit: Maximum results to return (default: 100)
|
|
761
|
+
offset: Number of results to skip (default: 0)
|
|
762
|
+
correlation_id: Optional correlation ID for tracing
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
List of topic projections
|
|
766
|
+
|
|
767
|
+
Raises:
|
|
768
|
+
InfraConnectionError: If database connection fails
|
|
769
|
+
InfraTimeoutError: If query times out
|
|
770
|
+
RuntimeHostError: For other database errors
|
|
771
|
+
|
|
772
|
+
Example:
|
|
773
|
+
>>> topics = await reader.list_topics(direction="publish")
|
|
774
|
+
>>> for t in topics:
|
|
775
|
+
... print(f"{t.topic_suffix}: {t.contract_count} contracts")
|
|
776
|
+
"""
|
|
777
|
+
# Validate pagination parameters
|
|
778
|
+
offset = max(offset, 0)
|
|
779
|
+
if limit <= 0:
|
|
780
|
+
limit = 100
|
|
781
|
+
elif limit > 1000:
|
|
782
|
+
limit = 1000
|
|
783
|
+
|
|
784
|
+
corr_id = correlation_id or uuid4()
|
|
785
|
+
ctx = ModelInfraErrorContext(
|
|
786
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
787
|
+
operation="list_topics",
|
|
788
|
+
target_name="projection_reader.contract",
|
|
789
|
+
correlation_id=corr_id,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
async with self._circuit_breaker_lock:
|
|
793
|
+
await self._check_circuit_breaker("list_topics", corr_id)
|
|
794
|
+
|
|
795
|
+
if direction is not None:
|
|
796
|
+
query_sql = """
|
|
797
|
+
SELECT * FROM topics
|
|
798
|
+
WHERE direction = $1
|
|
799
|
+
ORDER BY last_seen_at DESC
|
|
800
|
+
LIMIT $2 OFFSET $3
|
|
801
|
+
"""
|
|
802
|
+
params = [direction, limit, offset]
|
|
803
|
+
else:
|
|
804
|
+
query_sql = """
|
|
805
|
+
SELECT * FROM topics
|
|
806
|
+
ORDER BY last_seen_at DESC
|
|
807
|
+
LIMIT $1 OFFSET $2
|
|
808
|
+
"""
|
|
809
|
+
params = [limit, offset]
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
async with self._pool.acquire() as conn:
|
|
813
|
+
rows = await conn.fetch(query_sql, *params)
|
|
814
|
+
|
|
815
|
+
async with self._circuit_breaker_lock:
|
|
816
|
+
await self._reset_circuit_breaker()
|
|
817
|
+
|
|
818
|
+
return [self._row_to_topic_projection(row) for row in rows]
|
|
819
|
+
|
|
820
|
+
except asyncpg.PostgresConnectionError as e:
|
|
821
|
+
async with self._circuit_breaker_lock:
|
|
822
|
+
await self._record_circuit_failure("list_topics", corr_id)
|
|
823
|
+
raise InfraConnectionError(
|
|
824
|
+
"Failed to connect to database for topics query",
|
|
825
|
+
context=ctx,
|
|
826
|
+
) from e
|
|
827
|
+
|
|
828
|
+
except asyncpg.QueryCanceledError as e:
|
|
829
|
+
async with self._circuit_breaker_lock:
|
|
830
|
+
await self._record_circuit_failure("list_topics", corr_id)
|
|
831
|
+
raise InfraTimeoutError(
|
|
832
|
+
"Topics query timed out",
|
|
833
|
+
context=ModelTimeoutErrorContext(
|
|
834
|
+
transport_type=ctx.transport_type,
|
|
835
|
+
operation=ctx.operation,
|
|
836
|
+
target_name=ctx.target_name,
|
|
837
|
+
correlation_id=ctx.correlation_id,
|
|
838
|
+
),
|
|
839
|
+
) from e
|
|
840
|
+
|
|
841
|
+
except Exception as e:
|
|
842
|
+
async with self._circuit_breaker_lock:
|
|
843
|
+
await self._record_circuit_failure("list_topics", corr_id)
|
|
844
|
+
raise RuntimeHostError(
|
|
845
|
+
f"Failed to list topics: {type(e).__name__}",
|
|
846
|
+
context=ctx,
|
|
847
|
+
) from e
|
|
848
|
+
|
|
849
|
+
async def count_topics(
|
|
850
|
+
self,
|
|
851
|
+
direction: str | None = None,
|
|
852
|
+
correlation_id: UUID | None = None,
|
|
853
|
+
) -> int:
|
|
854
|
+
"""Count total topics with optional direction filter.
|
|
855
|
+
|
|
856
|
+
Provides accurate count for pagination in list_topics queries.
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
direction: Optional filter by direction ('publish' or 'subscribe')
|
|
860
|
+
correlation_id: Optional correlation ID for tracing
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
Total number of topics matching the filter
|
|
864
|
+
|
|
865
|
+
Raises:
|
|
866
|
+
InfraConnectionError: If database connection fails
|
|
867
|
+
InfraTimeoutError: If query times out
|
|
868
|
+
RuntimeHostError: For other database errors
|
|
869
|
+
|
|
870
|
+
Example:
|
|
871
|
+
>>> total = await reader.count_topics(direction="publish")
|
|
872
|
+
>>> print(f"Total publish topics: {total}")
|
|
873
|
+
"""
|
|
874
|
+
corr_id = correlation_id or uuid4()
|
|
875
|
+
ctx = ModelInfraErrorContext(
|
|
876
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
877
|
+
operation="count_topics",
|
|
878
|
+
target_name="projection_reader.contract",
|
|
879
|
+
correlation_id=corr_id,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
async with self._circuit_breaker_lock:
|
|
883
|
+
await self._check_circuit_breaker("count_topics", corr_id)
|
|
884
|
+
|
|
885
|
+
if direction is not None:
|
|
886
|
+
query_sql = """
|
|
887
|
+
SELECT COUNT(*) as count FROM topics
|
|
888
|
+
WHERE direction = $1
|
|
889
|
+
"""
|
|
890
|
+
params = [direction]
|
|
891
|
+
else:
|
|
892
|
+
query_sql = """
|
|
893
|
+
SELECT COUNT(*) as count FROM topics
|
|
894
|
+
"""
|
|
895
|
+
params = []
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
async with self._pool.acquire() as conn:
|
|
899
|
+
row = await conn.fetchrow(query_sql, *params)
|
|
900
|
+
|
|
901
|
+
async with self._circuit_breaker_lock:
|
|
902
|
+
await self._reset_circuit_breaker()
|
|
903
|
+
|
|
904
|
+
return row["count"] if row else 0
|
|
905
|
+
|
|
906
|
+
except asyncpg.PostgresConnectionError as e:
|
|
907
|
+
async with self._circuit_breaker_lock:
|
|
908
|
+
await self._record_circuit_failure("count_topics", corr_id)
|
|
909
|
+
raise InfraConnectionError(
|
|
910
|
+
"Failed to connect to database for topic count",
|
|
911
|
+
context=ctx,
|
|
912
|
+
) from e
|
|
913
|
+
|
|
914
|
+
except asyncpg.QueryCanceledError as e:
|
|
915
|
+
async with self._circuit_breaker_lock:
|
|
916
|
+
await self._record_circuit_failure("count_topics", corr_id)
|
|
917
|
+
raise InfraTimeoutError(
|
|
918
|
+
"Topic count query timed out",
|
|
919
|
+
context=ModelTimeoutErrorContext(
|
|
920
|
+
transport_type=ctx.transport_type,
|
|
921
|
+
operation=ctx.operation,
|
|
922
|
+
target_name=ctx.target_name,
|
|
923
|
+
correlation_id=ctx.correlation_id,
|
|
924
|
+
),
|
|
925
|
+
) from e
|
|
926
|
+
|
|
927
|
+
except Exception as e:
|
|
928
|
+
async with self._circuit_breaker_lock:
|
|
929
|
+
await self._record_circuit_failure("count_topics", corr_id)
|
|
930
|
+
raise RuntimeHostError(
|
|
931
|
+
f"Failed to count topics: {type(e).__name__}",
|
|
932
|
+
context=ctx,
|
|
933
|
+
) from e
|
|
934
|
+
|
|
935
|
+
async def get_topic(
|
|
936
|
+
self,
|
|
937
|
+
topic_suffix: str,
|
|
938
|
+
direction: str,
|
|
939
|
+
correlation_id: UUID | None = None,
|
|
940
|
+
) -> ModelTopicProjection | None:
|
|
941
|
+
"""Get topic by suffix and direction.
|
|
942
|
+
|
|
943
|
+
Point lookup for a single topic by its composite primary key.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
topic_suffix: Topic suffix without environment prefix
|
|
947
|
+
direction: Direction ('publish' or 'subscribe')
|
|
948
|
+
correlation_id: Optional correlation ID for tracing
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Topic projection if exists, None otherwise
|
|
952
|
+
|
|
953
|
+
Raises:
|
|
954
|
+
InfraConnectionError: If database connection fails
|
|
955
|
+
InfraTimeoutError: If query times out
|
|
956
|
+
RuntimeHostError: For other database errors
|
|
957
|
+
|
|
958
|
+
Example:
|
|
959
|
+
>>> topic = await reader.get_topic(
|
|
960
|
+
... "onex.evt.platform.contract-registered.v1",
|
|
961
|
+
... "publish"
|
|
962
|
+
... )
|
|
963
|
+
>>> if topic:
|
|
964
|
+
... print(f"Contracts: {topic.contract_ids}")
|
|
965
|
+
"""
|
|
966
|
+
corr_id = correlation_id or uuid4()
|
|
967
|
+
ctx = ModelInfraErrorContext(
|
|
968
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
969
|
+
operation="get_topic",
|
|
970
|
+
target_name="projection_reader.contract",
|
|
971
|
+
correlation_id=corr_id,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
async with self._circuit_breaker_lock:
|
|
975
|
+
await self._check_circuit_breaker("get_topic", corr_id)
|
|
976
|
+
|
|
977
|
+
query_sql = """
|
|
978
|
+
SELECT * FROM topics
|
|
979
|
+
WHERE topic_suffix = $1 AND direction = $2
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
async with self._pool.acquire() as conn:
|
|
984
|
+
row = await conn.fetchrow(query_sql, topic_suffix, direction)
|
|
985
|
+
|
|
986
|
+
async with self._circuit_breaker_lock:
|
|
987
|
+
await self._reset_circuit_breaker()
|
|
988
|
+
|
|
989
|
+
if row is None:
|
|
990
|
+
return None
|
|
991
|
+
|
|
992
|
+
return self._row_to_topic_projection(row)
|
|
993
|
+
|
|
994
|
+
except asyncpg.PostgresConnectionError as e:
|
|
995
|
+
async with self._circuit_breaker_lock:
|
|
996
|
+
await self._record_circuit_failure("get_topic", corr_id)
|
|
997
|
+
raise InfraConnectionError(
|
|
998
|
+
"Failed to connect to database for topic lookup",
|
|
999
|
+
context=ctx,
|
|
1000
|
+
) from e
|
|
1001
|
+
|
|
1002
|
+
except asyncpg.QueryCanceledError as e:
|
|
1003
|
+
async with self._circuit_breaker_lock:
|
|
1004
|
+
await self._record_circuit_failure("get_topic", corr_id)
|
|
1005
|
+
raise InfraTimeoutError(
|
|
1006
|
+
"Topic lookup timed out",
|
|
1007
|
+
context=ModelTimeoutErrorContext(
|
|
1008
|
+
transport_type=ctx.transport_type,
|
|
1009
|
+
operation=ctx.operation,
|
|
1010
|
+
target_name=ctx.target_name,
|
|
1011
|
+
correlation_id=ctx.correlation_id,
|
|
1012
|
+
),
|
|
1013
|
+
) from e
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
async with self._circuit_breaker_lock:
|
|
1017
|
+
await self._record_circuit_failure("get_topic", corr_id)
|
|
1018
|
+
raise RuntimeHostError(
|
|
1019
|
+
f"Failed to get topic: {type(e).__name__}",
|
|
1020
|
+
context=ctx,
|
|
1021
|
+
) from e
|
|
1022
|
+
|
|
1023
|
+
async def get_topics_by_contract(
|
|
1024
|
+
self,
|
|
1025
|
+
contract_id: str,
|
|
1026
|
+
correlation_id: UUID | None = None,
|
|
1027
|
+
) -> list[ModelTopicProjection]:
|
|
1028
|
+
"""Get all topics referenced by a contract.
|
|
1029
|
+
|
|
1030
|
+
Uses GIN index on contract_ids JSONB array for efficient lookup.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
contract_id: Contract ID to search for
|
|
1034
|
+
correlation_id: Optional correlation ID for tracing
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
List of topic projections that reference the contract
|
|
1038
|
+
|
|
1039
|
+
Raises:
|
|
1040
|
+
InfraConnectionError: If database connection fails
|
|
1041
|
+
InfraTimeoutError: If query times out
|
|
1042
|
+
RuntimeHostError: For other database errors
|
|
1043
|
+
|
|
1044
|
+
Example:
|
|
1045
|
+
>>> topics = await reader.get_topics_by_contract("my-node:1.0.0")
|
|
1046
|
+
>>> for t in topics:
|
|
1047
|
+
... print(f"{t.direction}: {t.topic_suffix}")
|
|
1048
|
+
"""
|
|
1049
|
+
corr_id = correlation_id or uuid4()
|
|
1050
|
+
ctx = ModelInfraErrorContext(
|
|
1051
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
1052
|
+
operation="get_topics_by_contract",
|
|
1053
|
+
target_name="projection_reader.contract",
|
|
1054
|
+
correlation_id=corr_id,
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
async with self._circuit_breaker_lock:
|
|
1058
|
+
await self._check_circuit_breaker("get_topics_by_contract", corr_id)
|
|
1059
|
+
|
|
1060
|
+
# Use ? operator to check if JSONB array contains the contract_id
|
|
1061
|
+
query_sql = """
|
|
1062
|
+
SELECT * FROM topics
|
|
1063
|
+
WHERE contract_ids ? $1
|
|
1064
|
+
ORDER BY direction, topic_suffix
|
|
1065
|
+
"""
|
|
1066
|
+
|
|
1067
|
+
try:
|
|
1068
|
+
async with self._pool.acquire() as conn:
|
|
1069
|
+
rows = await conn.fetch(query_sql, contract_id)
|
|
1070
|
+
|
|
1071
|
+
async with self._circuit_breaker_lock:
|
|
1072
|
+
await self._reset_circuit_breaker()
|
|
1073
|
+
|
|
1074
|
+
return [self._row_to_topic_projection(row) for row in rows]
|
|
1075
|
+
|
|
1076
|
+
except asyncpg.PostgresConnectionError as e:
|
|
1077
|
+
async with self._circuit_breaker_lock:
|
|
1078
|
+
await self._record_circuit_failure("get_topics_by_contract", corr_id)
|
|
1079
|
+
raise InfraConnectionError(
|
|
1080
|
+
"Failed to connect to database for topics by contract query",
|
|
1081
|
+
context=ctx,
|
|
1082
|
+
) from e
|
|
1083
|
+
|
|
1084
|
+
except asyncpg.QueryCanceledError as e:
|
|
1085
|
+
async with self._circuit_breaker_lock:
|
|
1086
|
+
await self._record_circuit_failure("get_topics_by_contract", corr_id)
|
|
1087
|
+
raise InfraTimeoutError(
|
|
1088
|
+
"Topics by contract query timed out",
|
|
1089
|
+
context=ModelTimeoutErrorContext(
|
|
1090
|
+
transport_type=ctx.transport_type,
|
|
1091
|
+
operation=ctx.operation,
|
|
1092
|
+
target_name=ctx.target_name,
|
|
1093
|
+
correlation_id=ctx.correlation_id,
|
|
1094
|
+
),
|
|
1095
|
+
) from e
|
|
1096
|
+
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
async with self._circuit_breaker_lock:
|
|
1099
|
+
await self._record_circuit_failure("get_topics_by_contract", corr_id)
|
|
1100
|
+
raise RuntimeHostError(
|
|
1101
|
+
f"Failed to get topics by contract: {type(e).__name__}",
|
|
1102
|
+
context=ctx,
|
|
1103
|
+
) from e
|
|
1104
|
+
|
|
1105
|
+
async def get_topics_for_contracts(
|
|
1106
|
+
self,
|
|
1107
|
+
contract_ids: list[str],
|
|
1108
|
+
correlation_id: UUID | None = None,
|
|
1109
|
+
) -> dict[str, list[ModelTopicProjection]]:
|
|
1110
|
+
"""Get all topics for multiple contracts in a single query.
|
|
1111
|
+
|
|
1112
|
+
Batch method to avoid N+1 query pattern when fetching topics for
|
|
1113
|
+
multiple contracts. Uses JSONB ?| operator for efficient lookup
|
|
1114
|
+
with GIN index.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
contract_ids: List of contract IDs to search for
|
|
1118
|
+
correlation_id: Optional correlation ID for tracing
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
Dict mapping contract_id to list of topic projections.
|
|
1122
|
+
Contracts with no topics will have empty lists.
|
|
1123
|
+
|
|
1124
|
+
Raises:
|
|
1125
|
+
InfraConnectionError: If database connection fails
|
|
1126
|
+
InfraTimeoutError: If query times out
|
|
1127
|
+
RuntimeHostError: For other database errors
|
|
1128
|
+
|
|
1129
|
+
Example:
|
|
1130
|
+
>>> topics_map = await reader.get_topics_for_contracts(
|
|
1131
|
+
... ["my-node:1.0.0", "other-node:2.0.0"]
|
|
1132
|
+
... )
|
|
1133
|
+
>>> for contract_id, topics in topics_map.items():
|
|
1134
|
+
... print(f"{contract_id}: {len(topics)} topics")
|
|
1135
|
+
"""
|
|
1136
|
+
# Handle empty input
|
|
1137
|
+
if not contract_ids:
|
|
1138
|
+
return {}
|
|
1139
|
+
|
|
1140
|
+
corr_id = correlation_id or uuid4()
|
|
1141
|
+
ctx = ModelInfraErrorContext(
|
|
1142
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
1143
|
+
operation="get_topics_for_contracts",
|
|
1144
|
+
target_name="projection_reader.contract",
|
|
1145
|
+
correlation_id=corr_id,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
async with self._circuit_breaker_lock:
|
|
1149
|
+
await self._check_circuit_breaker("get_topics_for_contracts", corr_id)
|
|
1150
|
+
|
|
1151
|
+
# Use ?| operator to check if JSONB array contains ANY of the contract_ids
|
|
1152
|
+
# This performs a single query instead of N queries
|
|
1153
|
+
query_sql = """
|
|
1154
|
+
SELECT * FROM topics
|
|
1155
|
+
WHERE contract_ids ?| $1
|
|
1156
|
+
ORDER BY direction, topic_suffix
|
|
1157
|
+
"""
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
async with self._pool.acquire() as conn:
|
|
1161
|
+
rows = await conn.fetch(query_sql, contract_ids)
|
|
1162
|
+
|
|
1163
|
+
async with self._circuit_breaker_lock:
|
|
1164
|
+
await self._reset_circuit_breaker()
|
|
1165
|
+
|
|
1166
|
+
# Initialize result dict with empty lists for all requested contracts
|
|
1167
|
+
result: dict[str, list[ModelTopicProjection]] = {
|
|
1168
|
+
cid: [] for cid in contract_ids
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
# Group topics by contract_id
|
|
1172
|
+
# Each topic may reference multiple contracts, so we add it to
|
|
1173
|
+
# each matching contract's list
|
|
1174
|
+
for row in rows:
|
|
1175
|
+
topic = self._row_to_topic_projection(row)
|
|
1176
|
+
for contract_id in topic.contract_ids:
|
|
1177
|
+
if contract_id in result:
|
|
1178
|
+
result[contract_id].append(topic)
|
|
1179
|
+
|
|
1180
|
+
return result
|
|
1181
|
+
|
|
1182
|
+
except asyncpg.PostgresConnectionError as e:
|
|
1183
|
+
async with self._circuit_breaker_lock:
|
|
1184
|
+
await self._record_circuit_failure("get_topics_for_contracts", corr_id)
|
|
1185
|
+
raise InfraConnectionError(
|
|
1186
|
+
"Failed to connect to database for batch topics query",
|
|
1187
|
+
context=ctx,
|
|
1188
|
+
) from e
|
|
1189
|
+
|
|
1190
|
+
except asyncpg.QueryCanceledError as e:
|
|
1191
|
+
async with self._circuit_breaker_lock:
|
|
1192
|
+
await self._record_circuit_failure("get_topics_for_contracts", corr_id)
|
|
1193
|
+
raise InfraTimeoutError(
|
|
1194
|
+
"Batch topics query timed out",
|
|
1195
|
+
context=ModelTimeoutErrorContext(
|
|
1196
|
+
transport_type=ctx.transport_type,
|
|
1197
|
+
operation=ctx.operation,
|
|
1198
|
+
target_name=ctx.target_name,
|
|
1199
|
+
correlation_id=ctx.correlation_id,
|
|
1200
|
+
),
|
|
1201
|
+
) from e
|
|
1202
|
+
|
|
1203
|
+
except Exception as e:
|
|
1204
|
+
async with self._circuit_breaker_lock:
|
|
1205
|
+
await self._record_circuit_failure("get_topics_for_contracts", corr_id)
|
|
1206
|
+
raise RuntimeHostError(
|
|
1207
|
+
f"Failed to get topics for contracts: {type(e).__name__}",
|
|
1208
|
+
context=ctx,
|
|
1209
|
+
) from e
|
|
1210
|
+
|
|
1211
|
+
async def get_contracts_by_topic(
|
|
1212
|
+
self,
|
|
1213
|
+
topic_suffix: str,
|
|
1214
|
+
correlation_id: UUID | None = None,
|
|
1215
|
+
) -> list[ModelContractProjection]:
|
|
1216
|
+
"""Get all contracts that reference a topic.
|
|
1217
|
+
|
|
1218
|
+
Finds all contracts that publish to or subscribe from a given topic.
|
|
1219
|
+
Uses the topics table to get contract IDs, then fetches full contracts.
|
|
1220
|
+
|
|
1221
|
+
Args:
|
|
1222
|
+
topic_suffix: Topic suffix to search for
|
|
1223
|
+
correlation_id: Optional correlation ID for tracing
|
|
1224
|
+
|
|
1225
|
+
Returns:
|
|
1226
|
+
List of contract projections that reference the topic
|
|
1227
|
+
|
|
1228
|
+
Raises:
|
|
1229
|
+
InfraConnectionError: If database connection fails
|
|
1230
|
+
InfraTimeoutError: If query times out
|
|
1231
|
+
RuntimeHostError: For other database errors
|
|
1232
|
+
|
|
1233
|
+
Example:
|
|
1234
|
+
>>> contracts = await reader.get_contracts_by_topic(
|
|
1235
|
+
... "onex.evt.platform.contract-registered.v1"
|
|
1236
|
+
... )
|
|
1237
|
+
>>> for c in contracts:
|
|
1238
|
+
... print(f"{c.contract_id}: {c.node_name}")
|
|
1239
|
+
"""
|
|
1240
|
+
corr_id = correlation_id or uuid4()
|
|
1241
|
+
ctx = ModelInfraErrorContext(
|
|
1242
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
1243
|
+
operation="get_contracts_by_topic",
|
|
1244
|
+
target_name="projection_reader.contract",
|
|
1245
|
+
correlation_id=corr_id,
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
async with self._circuit_breaker_lock:
|
|
1249
|
+
await self._check_circuit_breaker("get_contracts_by_topic", corr_id)
|
|
1250
|
+
|
|
1251
|
+
# Join topics with contracts using JSONB array unnest
|
|
1252
|
+
# This gets all contracts referenced by any direction of the topic
|
|
1253
|
+
query_sql = """
|
|
1254
|
+
SELECT DISTINCT c.*
|
|
1255
|
+
FROM topics t
|
|
1256
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(t.contract_ids) AS cid
|
|
1257
|
+
JOIN contracts c ON c.contract_id = cid
|
|
1258
|
+
WHERE t.topic_suffix = $1
|
|
1259
|
+
ORDER BY c.contract_id
|
|
1260
|
+
"""
|
|
1261
|
+
|
|
1262
|
+
try:
|
|
1263
|
+
async with self._pool.acquire() as conn:
|
|
1264
|
+
rows = await conn.fetch(query_sql, topic_suffix)
|
|
1265
|
+
|
|
1266
|
+
async with self._circuit_breaker_lock:
|
|
1267
|
+
await self._reset_circuit_breaker()
|
|
1268
|
+
|
|
1269
|
+
return [self._row_to_contract_projection(row) for row in rows]
|
|
1270
|
+
|
|
1271
|
+
except asyncpg.PostgresConnectionError as e:
|
|
1272
|
+
async with self._circuit_breaker_lock:
|
|
1273
|
+
await self._record_circuit_failure("get_contracts_by_topic", corr_id)
|
|
1274
|
+
raise InfraConnectionError(
|
|
1275
|
+
"Failed to connect to database for contracts by topic query",
|
|
1276
|
+
context=ctx,
|
|
1277
|
+
) from e
|
|
1278
|
+
|
|
1279
|
+
except asyncpg.QueryCanceledError as e:
|
|
1280
|
+
async with self._circuit_breaker_lock:
|
|
1281
|
+
await self._record_circuit_failure("get_contracts_by_topic", corr_id)
|
|
1282
|
+
raise InfraTimeoutError(
|
|
1283
|
+
"Contracts by topic query timed out",
|
|
1284
|
+
context=ModelTimeoutErrorContext(
|
|
1285
|
+
transport_type=ctx.transport_type,
|
|
1286
|
+
operation=ctx.operation,
|
|
1287
|
+
target_name=ctx.target_name,
|
|
1288
|
+
correlation_id=ctx.correlation_id,
|
|
1289
|
+
),
|
|
1290
|
+
) from e
|
|
1291
|
+
|
|
1292
|
+
except Exception as e:
|
|
1293
|
+
async with self._circuit_breaker_lock:
|
|
1294
|
+
await self._record_circuit_failure("get_contracts_by_topic", corr_id)
|
|
1295
|
+
raise RuntimeHostError(
|
|
1296
|
+
f"Failed to get contracts by topic: {type(e).__name__}",
|
|
1297
|
+
context=ctx,
|
|
1298
|
+
) from e
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
__all__: list[str] = ["ProjectionReaderContract"]
|