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.
Files changed (72) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
  4. omnibase_infra/enums/enum_postgres_error_code.py +188 -0
  5. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
  6. omnibase_infra/mixins/__init__.py +14 -0
  7. omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
  8. omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
  9. omnibase_infra/models/__init__.py +3 -0
  10. omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
  11. omnibase_infra/models/projection/__init__.py +11 -0
  12. omnibase_infra/models/projection/model_contract_projection.py +170 -0
  13. omnibase_infra/models/projection/model_topic_projection.py +148 -0
  14. omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
  15. omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
  16. omnibase_infra/nodes/effects/__init__.py +1 -1
  17. omnibase_infra/nodes/effects/models/__init__.py +6 -4
  18. omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
  19. omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
  20. omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
  21. omnibase_infra/nodes/effects/registry_effect.py +1 -1
  22. omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
  23. omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
  24. omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
  25. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
  26. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
  27. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
  28. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
  29. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
  30. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
  31. omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
  32. omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
  33. omnibase_infra/nodes/node_contract_persistence_effect/node.py +114 -0
  34. omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
  35. omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +220 -0
  36. omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
  37. omnibase_infra/projectors/__init__.py +6 -0
  38. omnibase_infra/projectors/projection_reader_contract.py +1301 -0
  39. omnibase_infra/runtime/__init__.py +5 -0
  40. omnibase_infra/runtime/contract_registration_event_router.py +500 -0
  41. omnibase_infra/runtime/db/__init__.py +4 -0
  42. omnibase_infra/runtime/db/models/__init__.py +15 -10
  43. omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
  44. omnibase_infra/runtime/db/models/model_db_param.py +24 -0
  45. omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
  46. omnibase_infra/runtime/db/models/model_db_return.py +26 -0
  47. omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
  48. omnibase_infra/runtime/intent_execution_router.py +430 -0
  49. omnibase_infra/runtime/models/__init__.py +6 -0
  50. omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
  51. omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
  52. omnibase_infra/runtime/models/model_runtime_config.py +8 -0
  53. omnibase_infra/runtime/protocols/__init__.py +16 -0
  54. omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
  55. omnibase_infra/runtime/request_response_wiring.py +785 -0
  56. omnibase_infra/runtime/service_kernel.py +295 -8
  57. omnibase_infra/services/registry_api/models/__init__.py +25 -0
  58. omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
  59. omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
  60. omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
  61. omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
  62. omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
  63. omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
  64. omnibase_infra/services/registry_api/routes.py +205 -6
  65. omnibase_infra/services/registry_api/service.py +528 -1
  66. omnibase_infra/validation/infra_validators.py +3 -1
  67. omnibase_infra/validation/validation_exemptions.yaml +54 -0
  68. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/METADATA +3 -3
  69. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/RECORD +72 -34
  70. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/WHEEL +0 -0
  71. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/entry_points.txt +0 -0
  72. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.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"]