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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
  4. omnibase_infra/enums/enum_postgres_error_code.py +188 -0
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_infra.py +60 -0
  7. omnibase_infra/handlers/__init__.py +3 -0
  8. omnibase_infra/handlers/handler_slack_webhook.py +426 -0
  9. omnibase_infra/handlers/models/__init__.py +14 -0
  10. omnibase_infra/handlers/models/enum_alert_severity.py +36 -0
  11. omnibase_infra/handlers/models/model_slack_alert.py +24 -0
  12. omnibase_infra/handlers/models/model_slack_alert_payload.py +77 -0
  13. omnibase_infra/handlers/models/model_slack_alert_result.py +73 -0
  14. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
  15. omnibase_infra/mixins/__init__.py +14 -0
  16. omnibase_infra/mixins/mixin_node_introspection.py +42 -20
  17. omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
  18. omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
  19. omnibase_infra/models/__init__.py +3 -0
  20. omnibase_infra/models/discovery/model_dependency_spec.py +1 -0
  21. omnibase_infra/models/discovery/model_discovered_capabilities.py +1 -1
  22. omnibase_infra/models/discovery/model_introspection_config.py +28 -1
  23. omnibase_infra/models/discovery/model_introspection_performance_metrics.py +1 -0
  24. omnibase_infra/models/discovery/model_introspection_task_config.py +1 -0
  25. omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
  26. omnibase_infra/models/projection/__init__.py +11 -0
  27. omnibase_infra/models/projection/model_contract_projection.py +170 -0
  28. omnibase_infra/models/projection/model_topic_projection.py +148 -0
  29. omnibase_infra/models/runtime/__init__.py +4 -0
  30. omnibase_infra/models/runtime/model_resolved_dependencies.py +116 -0
  31. omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
  32. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +6 -5
  33. omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
  34. omnibase_infra/nodes/contract_registry_reducer/reducer.py +9 -26
  35. omnibase_infra/nodes/effects/__init__.py +1 -1
  36. omnibase_infra/nodes/effects/models/__init__.py +6 -4
  37. omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
  38. omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
  39. omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
  40. omnibase_infra/nodes/effects/registry_effect.py +1 -1
  41. omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
  42. omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
  43. omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
  44. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
  45. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
  46. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
  47. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
  48. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
  49. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
  50. omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
  51. omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
  52. omnibase_infra/nodes/node_contract_persistence_effect/node.py +131 -0
  53. omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
  54. omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +251 -0
  55. omnibase_infra/nodes/node_registration_orchestrator/models/model_postgres_intent_payload.py +8 -12
  56. omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
  57. omnibase_infra/nodes/node_slack_alerter_effect/__init__.py +33 -0
  58. omnibase_infra/nodes/node_slack_alerter_effect/contract.yaml +291 -0
  59. omnibase_infra/nodes/node_slack_alerter_effect/node.py +106 -0
  60. omnibase_infra/projectors/__init__.py +6 -0
  61. omnibase_infra/projectors/projection_reader_contract.py +1301 -0
  62. omnibase_infra/runtime/__init__.py +12 -0
  63. omnibase_infra/runtime/baseline_subscriptions.py +13 -6
  64. omnibase_infra/runtime/contract_dependency_resolver.py +455 -0
  65. omnibase_infra/runtime/contract_registration_event_router.py +500 -0
  66. omnibase_infra/runtime/db/__init__.py +4 -0
  67. omnibase_infra/runtime/db/models/__init__.py +15 -10
  68. omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
  69. omnibase_infra/runtime/db/models/model_db_param.py +24 -0
  70. omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
  71. omnibase_infra/runtime/db/models/model_db_return.py +26 -0
  72. omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
  73. omnibase_infra/runtime/emit_daemon/event_registry.py +34 -22
  74. omnibase_infra/runtime/event_bus_subcontract_wiring.py +63 -23
  75. omnibase_infra/runtime/intent_execution_router.py +430 -0
  76. omnibase_infra/runtime/models/__init__.py +6 -0
  77. omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
  78. omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
  79. omnibase_infra/runtime/models/model_runtime_config.py +8 -0
  80. omnibase_infra/runtime/protocols/__init__.py +16 -0
  81. omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
  82. omnibase_infra/runtime/publisher_topic_scoped.py +16 -11
  83. omnibase_infra/runtime/registry_policy.py +29 -15
  84. omnibase_infra/runtime/request_response_wiring.py +793 -0
  85. omnibase_infra/runtime/service_kernel.py +295 -8
  86. omnibase_infra/runtime/service_runtime_host_process.py +149 -5
  87. omnibase_infra/runtime/util_version.py +5 -1
  88. omnibase_infra/schemas/schema_latency_baseline.sql +135 -0
  89. omnibase_infra/services/contract_publisher/config.py +4 -4
  90. omnibase_infra/services/contract_publisher/service.py +8 -5
  91. omnibase_infra/services/observability/injection_effectiveness/__init__.py +67 -0
  92. omnibase_infra/services/observability/injection_effectiveness/config.py +295 -0
  93. omnibase_infra/services/observability/injection_effectiveness/consumer.py +1461 -0
  94. omnibase_infra/services/observability/injection_effectiveness/models/__init__.py +32 -0
  95. omnibase_infra/services/observability/injection_effectiveness/models/model_agent_match.py +79 -0
  96. omnibase_infra/services/observability/injection_effectiveness/models/model_context_utilization.py +118 -0
  97. omnibase_infra/services/observability/injection_effectiveness/models/model_latency_breakdown.py +107 -0
  98. omnibase_infra/services/observability/injection_effectiveness/models/model_pattern_utilization.py +46 -0
  99. omnibase_infra/services/observability/injection_effectiveness/writer_postgres.py +596 -0
  100. omnibase_infra/services/registry_api/models/__init__.py +25 -0
  101. omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
  102. omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
  103. omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
  104. omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
  105. omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
  106. omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
  107. omnibase_infra/services/registry_api/routes.py +205 -6
  108. omnibase_infra/services/registry_api/service.py +528 -1
  109. omnibase_infra/utils/__init__.py +7 -0
  110. omnibase_infra/utils/util_db_error_context.py +292 -0
  111. omnibase_infra/validation/infra_validators.py +3 -1
  112. omnibase_infra/validation/validation_exemptions.yaml +65 -0
  113. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/METADATA +3 -3
  114. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/RECORD +117 -58
  115. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/WHEEL +0 -0
  116. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/entry_points.txt +0 -0
  117. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -35,12 +35,16 @@ from omnibase_infra.nodes.node_service_discovery_effect.models.enum_health_statu
35
35
  )
36
36
  from omnibase_infra.services.registry_api.models import (
37
37
  ModelCapabilityWidgetMapping,
38
+ ModelContractRef,
39
+ ModelContractView,
38
40
  ModelPaginationInfo,
39
41
  ModelRegistryDiscoveryResponse,
40
42
  ModelRegistryHealthResponse,
41
43
  ModelRegistryInstanceView,
42
44
  ModelRegistryNodeView,
43
45
  ModelRegistrySummary,
46
+ ModelTopicSummary,
47
+ ModelTopicView,
44
48
  ModelWarning,
45
49
  ModelWidgetDefaults,
46
50
  ModelWidgetMapping,
@@ -49,7 +53,10 @@ from omnibase_infra.services.registry_api.models import (
49
53
  if TYPE_CHECKING:
50
54
  from omnibase_infra.handlers.service_discovery import HandlerServiceDiscoveryConsul
51
55
  from omnibase_infra.models.projection import ModelRegistrationProjection
52
- from omnibase_infra.projectors import ProjectionReaderRegistration
56
+ from omnibase_infra.projectors import (
57
+ ProjectionReaderContract,
58
+ ProjectionReaderRegistration,
59
+ )
53
60
 
54
61
  logger = logging.getLogger(__name__)
55
62
 
@@ -112,6 +119,7 @@ class ServiceRegistryDiscovery:
112
119
  container: ModelONEXContainer,
113
120
  projection_reader: ProjectionReaderRegistration | None = None,
114
121
  consul_handler: HandlerServiceDiscoveryConsul | None = None,
122
+ contract_reader: ProjectionReaderContract | None = None,
115
123
  widget_mapping_path: Path | None = None,
116
124
  ) -> None:
117
125
  """Initialize the registry discovery service.
@@ -123,6 +131,8 @@ class ServiceRegistryDiscovery:
123
131
  If not provided, node queries will return empty results with warnings.
124
132
  consul_handler: Optional Consul handler for live instances.
125
133
  If not provided, instance queries will return empty results with warnings.
134
+ contract_reader: Optional projection reader for contract registry.
135
+ If not provided, contract/topic queries will return empty results with warnings.
126
136
  widget_mapping_path: Path to widget mapping YAML file.
127
137
  Defaults to configs/widget_mapping.yaml relative to package.
128
138
  """
@@ -142,6 +152,9 @@ class ServiceRegistryDiscovery:
142
152
  # via the consul_handler parameter instead.
143
153
  self._consul_handler = consul_handler
144
154
 
155
+ # Contract reader for contract registry queries
156
+ self._contract_reader = contract_reader
157
+
145
158
  self._widget_mapping_path = widget_mapping_path or DEFAULT_WIDGET_MAPPING_PATH
146
159
  self._widget_mapping_cache: ModelWidgetMapping | None = None
147
160
  self._widget_mapping_mtime: float | None = None
@@ -151,6 +164,7 @@ class ServiceRegistryDiscovery:
151
164
  extra={
152
165
  "has_projection_reader": self._projection_reader is not None,
153
166
  "has_consul_handler": self._consul_handler is not None,
167
+ "has_contract_reader": self._contract_reader is not None,
154
168
  "widget_mapping_path": str(self._widget_mapping_path),
155
169
  },
156
170
  )
@@ -170,6 +184,11 @@ class ServiceRegistryDiscovery:
170
184
  """Get the Consul handler for lifecycle management."""
171
185
  return self._consul_handler
172
186
 
187
+ @property
188
+ def has_contract_reader(self) -> bool:
189
+ """Check if contract reader is configured."""
190
+ return self._contract_reader is not None
191
+
173
192
  def invalidate_widget_mapping_cache(self) -> None:
174
193
  """Clear widget mapping cache, forcing reload on next access.
175
194
 
@@ -833,5 +852,513 @@ class ServiceRegistryDiscovery:
833
852
  version="1.0.0",
834
853
  )
835
854
 
855
+ # ============================================================
856
+ # Contract Registry Methods
857
+ # ============================================================
858
+
859
+ async def list_contracts(
860
+ self,
861
+ limit: int = 100,
862
+ offset: int = 0,
863
+ active_only: bool = True,
864
+ node_name: str | None = None,
865
+ correlation_id: UUID | None = None,
866
+ ) -> tuple[list[ModelContractView], ModelPaginationInfo, list[ModelWarning]]:
867
+ """List contracts from projection reader.
868
+
869
+ Retrieves registered contracts with optional filtering by node name
870
+ and active status. Supports pagination.
871
+
872
+ Args:
873
+ limit: Maximum number of contracts to return (1-1000).
874
+ offset: Number of contracts to skip for pagination.
875
+ active_only: If True, return only active contracts.
876
+ node_name: Optional filter by node name.
877
+ correlation_id: Optional correlation ID for tracing.
878
+
879
+ Returns:
880
+ Tuple of (contracts, pagination_info, warnings).
881
+ """
882
+ correlation_id = correlation_id or uuid4()
883
+ warnings: list[ModelWarning] = []
884
+ contracts: list[ModelContractView] = []
885
+ total = 0
886
+
887
+ if self._contract_reader is None:
888
+ warnings.append(
889
+ ModelWarning(
890
+ source="postgres",
891
+ message="Contract reader not configured",
892
+ code="NO_CONTRACT_READER",
893
+ timestamp=datetime.now(UTC),
894
+ )
895
+ )
896
+ else:
897
+ try:
898
+ # Fetch contracts based on filters
899
+ if node_name:
900
+ # Filter by node name
901
+ projections = (
902
+ await self._contract_reader.list_contracts_by_node_name(
903
+ node_name=node_name,
904
+ include_inactive=not active_only,
905
+ correlation_id=correlation_id,
906
+ )
907
+ )
908
+ elif active_only:
909
+ # Active contracts only
910
+ projections = await self._contract_reader.list_active_contracts(
911
+ limit=limit + offset + 1, # Fetch extra to detect has_more
912
+ offset=0,
913
+ correlation_id=correlation_id,
914
+ )
915
+ else:
916
+ # All contracts (active + inactive)
917
+ # Note: The reader doesn't have a single method for all contracts,
918
+ # so we query active and then could extend for inactive if needed.
919
+ # For now, we use list_active_contracts as a fallback.
920
+ projections = await self._contract_reader.list_active_contracts(
921
+ limit=limit + offset + 1,
922
+ offset=0,
923
+ correlation_id=correlation_id,
924
+ )
925
+
926
+ # Calculate total and apply pagination
927
+ total = len(projections)
928
+
929
+ # Apply pagination in-memory for node_name filter
930
+ # (the reader's list_by_node_name doesn't support offset/limit)
931
+ if node_name:
932
+ projections_slice = projections[offset : offset + limit]
933
+ else:
934
+ # Already paginated by the reader
935
+ projections_slice = projections[offset : offset + limit]
936
+ total = max(total, offset + len(projections_slice))
937
+
938
+ # Batch fetch topics for all contracts to avoid N+1 query pattern
939
+ # This uses a single query instead of O(N) queries
940
+ contract_ids = [proj.contract_id for proj in projections_slice]
941
+ topics_by_contract: dict[str, list] = {}
942
+
943
+ if contract_ids:
944
+ try:
945
+ topics_by_contract = (
946
+ await self._contract_reader.get_topics_for_contracts(
947
+ contract_ids=contract_ids,
948
+ correlation_id=correlation_id,
949
+ )
950
+ )
951
+ except Exception as e:
952
+ # Log but continue - partial success with empty topics
953
+ logger.warning(
954
+ "Failed to batch fetch topics for contracts",
955
+ extra={
956
+ "contract_count": len(contract_ids),
957
+ "error": str(e),
958
+ "correlation_id": str(correlation_id),
959
+ },
960
+ )
961
+
962
+ # Convert to view models
963
+ for proj in projections_slice:
964
+ # Get topics for this contract from batch result
965
+ topics_published: list[str] = []
966
+ topics_subscribed: list[str] = []
967
+
968
+ contract_topics = topics_by_contract.get(proj.contract_id, [])
969
+ for topic in contract_topics:
970
+ if topic.direction == "publish":
971
+ topics_published.append(topic.topic_suffix)
972
+ elif topic.direction == "subscribe":
973
+ topics_subscribed.append(topic.topic_suffix)
974
+
975
+ contracts.append(
976
+ ModelContractView(
977
+ contract_id=proj.contract_id,
978
+ node_name=proj.node_name,
979
+ version=f"{proj.version_major}.{proj.version_minor}.{proj.version_patch}",
980
+ contract_hash=proj.contract_hash,
981
+ is_active=proj.is_active,
982
+ registered_at=proj.registered_at,
983
+ last_seen_at=proj.last_seen_at,
984
+ deregistered_at=proj.deregistered_at,
985
+ topics_published=topics_published,
986
+ topics_subscribed=topics_subscribed,
987
+ )
988
+ )
989
+
990
+ except Exception as e:
991
+ logger.exception(
992
+ "Failed to query contracts",
993
+ extra={"correlation_id": str(correlation_id)},
994
+ )
995
+ warnings.append(
996
+ ModelWarning(
997
+ source="postgres",
998
+ message=f"Failed to query contracts: {type(e).__name__}",
999
+ code="CONTRACT_QUERY_FAILED",
1000
+ timestamp=datetime.now(UTC),
1001
+ )
1002
+ )
1003
+
1004
+ pagination = ModelPaginationInfo(
1005
+ total=total,
1006
+ limit=limit,
1007
+ offset=offset,
1008
+ has_more=offset + len(contracts) < total,
1009
+ )
1010
+
1011
+ return contracts, pagination, warnings
1012
+
1013
+ async def get_contract(
1014
+ self,
1015
+ contract_id: str,
1016
+ correlation_id: UUID | None = None,
1017
+ ) -> tuple[ModelContractView | None, list[ModelWarning]]:
1018
+ """Get contract detail with topic references.
1019
+
1020
+ Retrieves a single contract by ID along with its published and
1021
+ subscribed topics.
1022
+
1023
+ Args:
1024
+ contract_id: Contract ID (e.g., "my-node:1.0.0")
1025
+ correlation_id: Optional correlation ID for tracing.
1026
+
1027
+ Returns:
1028
+ Tuple of (contract or None, warnings).
1029
+ """
1030
+ correlation_id = correlation_id or uuid4()
1031
+ warnings: list[ModelWarning] = []
1032
+
1033
+ if self._contract_reader is None:
1034
+ warnings.append(
1035
+ ModelWarning(
1036
+ source="postgres",
1037
+ message="Contract reader not configured",
1038
+ code="NO_CONTRACT_READER",
1039
+ timestamp=datetime.now(UTC),
1040
+ )
1041
+ )
1042
+ return None, warnings
1043
+
1044
+ try:
1045
+ proj = await self._contract_reader.get_contract_by_id(
1046
+ contract_id=contract_id,
1047
+ correlation_id=correlation_id,
1048
+ )
1049
+
1050
+ if proj is None:
1051
+ return None, warnings
1052
+
1053
+ # Get topics for this contract
1054
+ topics_published: list[str] = []
1055
+ topics_subscribed: list[str] = []
1056
+
1057
+ try:
1058
+ topics = await self._contract_reader.get_topics_by_contract(
1059
+ contract_id=contract_id,
1060
+ correlation_id=correlation_id,
1061
+ )
1062
+ for topic in topics:
1063
+ if topic.direction == "publish":
1064
+ topics_published.append(topic.topic_suffix)
1065
+ elif topic.direction == "subscribe":
1066
+ topics_subscribed.append(topic.topic_suffix)
1067
+ except Exception as e:
1068
+ logger.warning(
1069
+ "Failed to get topics for contract",
1070
+ extra={
1071
+ "contract_id": contract_id,
1072
+ "error": str(e),
1073
+ "correlation_id": str(correlation_id),
1074
+ },
1075
+ )
1076
+ warnings.append(
1077
+ ModelWarning(
1078
+ source="postgres",
1079
+ message=f"Failed to get topics: {type(e).__name__}",
1080
+ code="TOPIC_QUERY_FAILED",
1081
+ timestamp=datetime.now(UTC),
1082
+ )
1083
+ )
1084
+
1085
+ contract = ModelContractView(
1086
+ contract_id=proj.contract_id,
1087
+ node_name=proj.node_name,
1088
+ version=f"{proj.version_major}.{proj.version_minor}.{proj.version_patch}",
1089
+ contract_hash=proj.contract_hash,
1090
+ is_active=proj.is_active,
1091
+ registered_at=proj.registered_at,
1092
+ last_seen_at=proj.last_seen_at,
1093
+ deregistered_at=proj.deregistered_at,
1094
+ topics_published=topics_published,
1095
+ topics_subscribed=topics_subscribed,
1096
+ )
1097
+
1098
+ return contract, warnings
1099
+
1100
+ except Exception as e:
1101
+ logger.exception(
1102
+ "Failed to get contract",
1103
+ extra={
1104
+ "contract_id": contract_id,
1105
+ "correlation_id": str(correlation_id),
1106
+ },
1107
+ )
1108
+ warnings.append(
1109
+ ModelWarning(
1110
+ source="postgres",
1111
+ message=f"Failed to get contract: {type(e).__name__}",
1112
+ code="CONTRACT_QUERY_FAILED",
1113
+ timestamp=datetime.now(UTC),
1114
+ )
1115
+ )
1116
+ return None, warnings
1117
+
1118
+ async def list_topics(
1119
+ self,
1120
+ direction: str | None = None,
1121
+ limit: int = 100,
1122
+ offset: int = 0,
1123
+ correlation_id: UUID | None = None,
1124
+ ) -> tuple[list[ModelTopicSummary], ModelPaginationInfo, list[ModelWarning]]:
1125
+ """List topics from projection reader.
1126
+
1127
+ Retrieves topics with optional filtering by direction (publish/subscribe).
1128
+ Returns summary view with contract counts.
1129
+
1130
+ Args:
1131
+ direction: Optional filter by direction ('publish' or 'subscribe').
1132
+ limit: Maximum number of topics to return (1-1000).
1133
+ offset: Number of topics to skip for pagination.
1134
+ correlation_id: Optional correlation ID for tracing.
1135
+
1136
+ Returns:
1137
+ Tuple of (topics, pagination_info, warnings).
1138
+ """
1139
+ correlation_id = correlation_id or uuid4()
1140
+ warnings: list[ModelWarning] = []
1141
+ topics: list[ModelTopicSummary] = []
1142
+ total = 0
1143
+
1144
+ if self._contract_reader is None:
1145
+ warnings.append(
1146
+ ModelWarning(
1147
+ source="postgres",
1148
+ message="Contract reader not configured",
1149
+ code="NO_CONTRACT_READER",
1150
+ timestamp=datetime.now(UTC),
1151
+ )
1152
+ )
1153
+ else:
1154
+ try:
1155
+ projections = await self._contract_reader.list_topics(
1156
+ direction=direction,
1157
+ limit=limit + 1, # Fetch extra to detect has_more
1158
+ offset=offset,
1159
+ correlation_id=correlation_id,
1160
+ )
1161
+
1162
+ # Detect has_more from fetching limit+1
1163
+ has_more = len(projections) > limit
1164
+ projections_slice = projections[:limit]
1165
+
1166
+ # Get accurate total count for pagination
1167
+ try:
1168
+ total = await self._contract_reader.count_topics(
1169
+ direction=direction,
1170
+ correlation_id=correlation_id,
1171
+ )
1172
+ except Exception as count_error:
1173
+ # Fall back to estimate if count query fails
1174
+ logger.warning(
1175
+ "Failed to get accurate topic count, using estimate",
1176
+ extra={
1177
+ "correlation_id": str(correlation_id),
1178
+ "error": str(count_error),
1179
+ },
1180
+ )
1181
+ total = offset + len(projections)
1182
+
1183
+ for proj in projections_slice:
1184
+ topics.append(
1185
+ ModelTopicSummary(
1186
+ topic_suffix=proj.topic_suffix,
1187
+ direction=proj.direction,
1188
+ contract_count=len(proj.contract_ids),
1189
+ last_seen_at=proj.last_seen_at,
1190
+ is_active=proj.is_active,
1191
+ )
1192
+ )
1193
+
1194
+ except Exception as e:
1195
+ logger.exception(
1196
+ "Failed to query topics",
1197
+ extra={"correlation_id": str(correlation_id)},
1198
+ )
1199
+ warnings.append(
1200
+ ModelWarning(
1201
+ source="postgres",
1202
+ message=f"Failed to query topics: {type(e).__name__}",
1203
+ code="TOPIC_QUERY_FAILED",
1204
+ timestamp=datetime.now(UTC),
1205
+ )
1206
+ )
1207
+
1208
+ pagination = ModelPaginationInfo(
1209
+ total=total,
1210
+ limit=limit,
1211
+ offset=offset,
1212
+ has_more=offset + len(topics) < total,
1213
+ )
1214
+
1215
+ return topics, pagination, warnings
1216
+
1217
+ async def get_topic_detail(
1218
+ self,
1219
+ topic_suffix: str,
1220
+ correlation_id: UUID | None = None,
1221
+ ) -> tuple[ModelTopicView | None, list[ModelWarning]]:
1222
+ """Get topic detail with publisher/subscriber contracts.
1223
+
1224
+ Retrieves a topic by suffix, combining both publish and subscribe
1225
+ directions into a unified view with contract references.
1226
+
1227
+ Args:
1228
+ topic_suffix: Topic suffix (without environment prefix)
1229
+ correlation_id: Optional correlation ID for tracing.
1230
+
1231
+ Returns:
1232
+ Tuple of (topic or None, warnings).
1233
+ """
1234
+ correlation_id = correlation_id or uuid4()
1235
+ warnings: list[ModelWarning] = []
1236
+
1237
+ if self._contract_reader is None:
1238
+ warnings.append(
1239
+ ModelWarning(
1240
+ source="postgres",
1241
+ message="Contract reader not configured",
1242
+ code="NO_CONTRACT_READER",
1243
+ timestamp=datetime.now(UTC),
1244
+ )
1245
+ )
1246
+ return None, warnings
1247
+
1248
+ try:
1249
+ # Get both directions for this topic
1250
+ publish_topic = await self._contract_reader.get_topic(
1251
+ topic_suffix=topic_suffix,
1252
+ direction="publish",
1253
+ correlation_id=correlation_id,
1254
+ )
1255
+ subscribe_topic = await self._contract_reader.get_topic(
1256
+ topic_suffix=topic_suffix,
1257
+ direction="subscribe",
1258
+ correlation_id=correlation_id,
1259
+ )
1260
+
1261
+ # If neither direction exists, topic not found
1262
+ if publish_topic is None and subscribe_topic is None:
1263
+ return None, warnings
1264
+
1265
+ # Get contract details for publishers
1266
+ publishers: list[ModelContractRef] = []
1267
+ if publish_topic:
1268
+ for contract_id in publish_topic.contract_ids:
1269
+ try:
1270
+ contract = await self._contract_reader.get_contract_by_id(
1271
+ contract_id=contract_id,
1272
+ correlation_id=correlation_id,
1273
+ )
1274
+ if contract:
1275
+ publishers.append(
1276
+ ModelContractRef(
1277
+ contract_id=contract.contract_id,
1278
+ node_name=contract.node_name,
1279
+ version=f"{contract.version_major}.{contract.version_minor}.{contract.version_patch}",
1280
+ )
1281
+ )
1282
+ except Exception as e:
1283
+ logger.warning(
1284
+ "Failed to get publisher contract",
1285
+ extra={
1286
+ "contract_id": contract_id,
1287
+ "error": str(e),
1288
+ "correlation_id": str(correlation_id),
1289
+ },
1290
+ )
1291
+
1292
+ # Get contract details for subscribers
1293
+ subscribers: list[ModelContractRef] = []
1294
+ if subscribe_topic:
1295
+ for contract_id in subscribe_topic.contract_ids:
1296
+ try:
1297
+ contract = await self._contract_reader.get_contract_by_id(
1298
+ contract_id=contract_id,
1299
+ correlation_id=correlation_id,
1300
+ )
1301
+ if contract:
1302
+ subscribers.append(
1303
+ ModelContractRef(
1304
+ contract_id=contract.contract_id,
1305
+ node_name=contract.node_name,
1306
+ version=f"{contract.version_major}.{contract.version_minor}.{contract.version_patch}",
1307
+ )
1308
+ )
1309
+ except Exception as e:
1310
+ logger.warning(
1311
+ "Failed to get subscriber contract",
1312
+ extra={
1313
+ "contract_id": contract_id,
1314
+ "error": str(e),
1315
+ "correlation_id": str(correlation_id),
1316
+ },
1317
+ )
1318
+
1319
+ # Combine timestamps from both directions
1320
+ first_seen_at = min(
1321
+ t.first_seen_at
1322
+ for t in [publish_topic, subscribe_topic]
1323
+ if t is not None
1324
+ )
1325
+ last_seen_at = max(
1326
+ t.last_seen_at
1327
+ for t in [publish_topic, subscribe_topic]
1328
+ if t is not None
1329
+ )
1330
+ is_active = any(
1331
+ t.is_active for t in [publish_topic, subscribe_topic] if t is not None
1332
+ )
1333
+
1334
+ topic = ModelTopicView(
1335
+ topic_suffix=topic_suffix,
1336
+ publishers=publishers,
1337
+ subscribers=subscribers,
1338
+ first_seen_at=first_seen_at,
1339
+ last_seen_at=last_seen_at,
1340
+ is_active=is_active,
1341
+ )
1342
+
1343
+ return topic, warnings
1344
+
1345
+ except Exception as e:
1346
+ logger.exception(
1347
+ "Failed to get topic",
1348
+ extra={
1349
+ "topic_suffix": topic_suffix,
1350
+ "correlation_id": str(correlation_id),
1351
+ },
1352
+ )
1353
+ warnings.append(
1354
+ ModelWarning(
1355
+ source="postgres",
1356
+ message=f"Failed to get topic: {type(e).__name__}",
1357
+ code="TOPIC_QUERY_FAILED",
1358
+ timestamp=datetime.now(UTC),
1359
+ )
1360
+ )
1361
+ return None, warnings
1362
+
836
1363
 
837
1364
  __all__ = ["ServiceRegistryDiscovery"]
@@ -7,6 +7,7 @@ This package provides common utilities used across the infrastructure:
7
7
  - util_atomic_file: Atomic file write primitives using temp-file-rename pattern
8
8
  - util_consumer_group: Kafka consumer group ID generation with deterministic hashing
9
9
  - util_datetime: Datetime validation and timezone normalization
10
+ - util_db_error_context: Database operation error handling context manager
10
11
  - util_db_transaction: Database transaction context manager for asyncpg
11
12
  - util_dsn_validation: PostgreSQL DSN validation and sanitization
12
13
  - util_env_parsing: Type-safe environment variable parsing with validation
@@ -38,6 +39,10 @@ from omnibase_infra.utils.util_datetime import (
38
39
  validate_timezone_aware_with_context,
39
40
  warn_if_naive_datetime,
40
41
  )
42
+
43
+ # Note: util_db_error_context is NOT imported here to avoid circular imports.
44
+ # Import directly: from omnibase_infra.utils.util_db_error_context import db_operation_error_context
45
+ # See: omnibase_infra.errors -> util_error_sanitization -> utils.__init__ -> util_db_error_context -> errors
41
46
  from omnibase_infra.utils.util_db_transaction import (
42
47
  transaction_context,
43
48
  )
@@ -80,6 +85,8 @@ __all__: list[str] = [
80
85
  "CorrelationContext",
81
86
  "KAFKA_CONSUMER_GROUP_MAX_LENGTH",
82
87
  "OptimisticConflictError",
88
+ # Note: ProtocolCircuitBreakerFailureRecorder and db_operation_error_context are NOT exported
89
+ # here to avoid circular imports. Import directly from util_db_error_context.
83
90
  "SAFE_ERROR_PATTERNS",
84
91
  "SEMVER_PATTERN",
85
92
  "SENSITIVE_PATTERNS",