topicgate 1.1.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 (148) hide show
  1. topicgate/__init__.py +0 -0
  2. topicgate/__main__.py +5 -0
  3. topicgate/app/__init__.py +0 -0
  4. topicgate/app/app_dependencies.py +148 -0
  5. topicgate/app/broker_runtime_state.py +43 -0
  6. topicgate/app/models/__init__.py +1 -0
  7. topicgate/app/models/broker_snapshot.py +107 -0
  8. topicgate/app/models/mcp_setup.py +20 -0
  9. topicgate/app/services/__init__.py +0 -0
  10. topicgate/app/services/broker_profile_service.py +245 -0
  11. topicgate/app/services/broker_resolver.py +58 -0
  12. topicgate/app/services/broker_snapshot_service.py +403 -0
  13. topicgate/app/services/control_operation_service.py +158 -0
  14. topicgate/app/services/mcp_setup_service.py +192 -0
  15. topicgate/app/services/observation_cache_service.py +256 -0
  16. topicgate/app/services/observation_retention_policy_service.py +33 -0
  17. topicgate/app/services/persistence_lifecycle.py +25 -0
  18. topicgate/app/services/service_container.py +20 -0
  19. topicgate/app/services/service_item.py +13 -0
  20. topicgate/app/topicgate_runtime.py +496 -0
  21. topicgate/assets/icon.png +0 -0
  22. topicgate/assets/icon.svg +37 -0
  23. topicgate/core/__init__.py +0 -0
  24. topicgate/core/config/__init__.py +0 -0
  25. topicgate/core/config/app_config.py +7 -0
  26. topicgate/core/config/mqtt_config.py +10 -0
  27. topicgate/core/interfaces/__init__.py +0 -0
  28. topicgate/core/interfaces/broker_profile_store.py +26 -0
  29. topicgate/core/interfaces/mqtt_message_processor.py +12 -0
  30. topicgate/core/interfaces/observer_repository.py +82 -0
  31. topicgate/core/interfaces/topic_message_store.py +13 -0
  32. topicgate/core/models/__init__.py +0 -0
  33. topicgate/core/models/broker_profile.py +15 -0
  34. topicgate/core/models/broker_profile_identity.py +14 -0
  35. topicgate/core/models/broker_summary.py +14 -0
  36. topicgate/core/models/connection_status.py +9 -0
  37. topicgate/core/models/message_filter.py +9 -0
  38. topicgate/core/models/mqtt_message.py +13 -0
  39. topicgate/core/models/mqtt_observation.py +36 -0
  40. topicgate/core/models/mqtt_state.py +5 -0
  41. topicgate/core/models/observation_cache_administration.py +135 -0
  42. topicgate/core/models/observation_deletion_preview.py +39 -0
  43. topicgate/core/models/observation_retention_policy.py +65 -0
  44. topicgate/core/models/observer_model.py +22 -0
  45. topicgate/core/models/observer_workspace.py +13 -0
  46. topicgate/core/models/subscription.py +29 -0
  47. topicgate/core/models/topic_message.py +16 -0
  48. topicgate/core/mqtt_topics.py +46 -0
  49. topicgate/core/observer_limits.py +8 -0
  50. topicgate/core/payload_limits.py +6 -0
  51. topicgate/gui/__init__.py +0 -0
  52. topicgate/gui/app.py +97 -0
  53. topicgate/gui/broker_state_reader.py +29 -0
  54. topicgate/gui/components/about_dialog.py +100 -0
  55. topicgate/gui/components/add_subscription_dialog.py +59 -0
  56. topicgate/gui/components/application_header.py +137 -0
  57. topicgate/gui/components/broker_settings_dialog.py +223 -0
  58. topicgate/gui/components/connection_controls.py +123 -0
  59. topicgate/gui/components/connection_status.py +7 -0
  60. topicgate/gui/components/log_console.py +26 -0
  61. topicgate/gui/components/mcp_setup_dialog.py +198 -0
  62. topicgate/gui/components/observer_tree.py +434 -0
  63. topicgate/gui/components/onboarding_panel.py +74 -0
  64. topicgate/gui/components/publish_pane.py +60 -0
  65. topicgate/gui/components/snapshot_panel.py +343 -0
  66. topicgate/gui/components/stored_observations_dialog.py +611 -0
  67. topicgate/gui/components/subscription_settings.py +151 -0
  68. topicgate/gui/components/topic_details.py +51 -0
  69. topicgate/gui/components/topic_metadata.py +79 -0
  70. topicgate/gui/components/workspace_pane.py +30 -0
  71. topicgate/gui/gui.py +5 -0
  72. topicgate/gui/main_view_model.py +814 -0
  73. topicgate/gui/main_window.py +986 -0
  74. topicgate/gui/observer_state_reader.py +47 -0
  75. topicgate/gui/settings_migration.py +32 -0
  76. topicgate/gui/theme.py +61 -0
  77. topicgate/infrastructure/__init__.py +0 -0
  78. topicgate/infrastructure/credentials/__init__.py +1 -0
  79. topicgate/infrastructure/credentials/credential_store.py +10 -0
  80. topicgate/infrastructure/credentials/os_credential_store.py +20 -0
  81. topicgate/infrastructure/database/__init__.py +0 -0
  82. topicgate/infrastructure/database/base.py +5 -0
  83. topicgate/infrastructure/database/database_context.py +56 -0
  84. topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
  85. topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
  86. topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
  87. topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
  88. topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
  89. topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
  90. topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
  91. topicgate/infrastructure/database/migrations.py +57 -0
  92. topicgate/infrastructure/database/models/__init__.py +25 -0
  93. topicgate/infrastructure/database/models/app_config_row.py +18 -0
  94. topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
  95. topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
  96. topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
  97. topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
  98. topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
  99. topicgate/infrastructure/database/models/subscription_row.py +18 -0
  100. topicgate/infrastructure/mqtt/__init__.py +0 -0
  101. topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
  102. topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
  103. topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
  104. topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
  105. topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
  106. topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
  107. topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
  108. topicgate/infrastructure/repository/__init__.py +0 -0
  109. topicgate/infrastructure/repository/broker_config_repository.py +68 -0
  110. topicgate/infrastructure/repository/broker_repository.py +170 -0
  111. topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
  112. topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
  113. topicgate/infrastructure/repository/subscription_repository.py +117 -0
  114. topicgate/infrastructure/repository/topic_message_repository.py +438 -0
  115. topicgate/mcp/__init__.py +0 -0
  116. topicgate/mcp/api/broker_api.py +48 -0
  117. topicgate/mcp/api/connection_api.py +83 -0
  118. topicgate/mcp/api/dashboard_api.py +790 -0
  119. topicgate/mcp/api/dashboard_snapshot.py +278 -0
  120. topicgate/mcp/api/mcp_api.py +18 -0
  121. topicgate/mcp/api/preview/dashboard_preview.py +6 -0
  122. topicgate/mcp/api/publish_api.py +62 -0
  123. topicgate/mcp/api/snapshot_api.py +78 -0
  124. topicgate/mcp/api/subscription_api.py +134 -0
  125. topicgate/mcp/api/topic_api.py +77 -0
  126. topicgate/mcp/capabilities.py +12 -0
  127. topicgate/mcp/instructions.py +3 -0
  128. topicgate/mcp/middleware/__init__.py +6 -0
  129. topicgate/mcp/middleware/error_handling_middleware.py +49 -0
  130. topicgate/mcp/middleware/logging_middleware.py +43 -0
  131. topicgate/mcp/models.py +28 -0
  132. topicgate/mcp/server.py +147 -0
  133. topicgate/paths.py +36 -0
  134. topicgate/presentation/__init__.py +39 -0
  135. topicgate/presentation/retention_presentation.py +187 -0
  136. topicgate/presentation/snapshot_presentation.py +130 -0
  137. topicgate/presentation/topic_presentation.py +482 -0
  138. topicgate/processors/__init__.py +0 -0
  139. topicgate/processors/observation_retention_processor.py +35 -0
  140. topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
  141. topicgate/processors/observer_model_processor.py +223 -0
  142. topicgate/processors/subscription_manager.py +90 -0
  143. topicgate-1.1.0.dist-info/METADATA +222 -0
  144. topicgate-1.1.0.dist-info/RECORD +148 -0
  145. topicgate-1.1.0.dist-info/WHEEL +5 -0
  146. topicgate-1.1.0.dist-info/entry_points.txt +3 -0
  147. topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
  148. topicgate-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,192 @@
1
+ import importlib.util
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ import json
4
+ from pathlib import Path
5
+ import shutil
6
+ import sys
7
+
8
+ from sqlalchemy import text
9
+
10
+ from topicgate.app.models.mcp_setup import McpPreflightCheck, McpSetupInformation
11
+ from topicgate.app.services.broker_snapshot_service import BrokerSnapshotService
12
+ from topicgate.app.topicgate_runtime import TopicGateRuntime
13
+ from topicgate.infrastructure.credentials.credential_store import CredentialStore
14
+ from topicgate.infrastructure.database.database_context import DatabaseContext
15
+ from topicgate.infrastructure.database.migrations import EXPECTED_SCHEMA_REVISION
16
+
17
+
18
+ class McpSetupService:
19
+ """Provide desktop MCP configuration and non-mutating diagnostics."""
20
+
21
+ def __init__(
22
+ self,
23
+ runtime: TopicGateRuntime,
24
+ snapshot_service: BrokerSnapshotService,
25
+ database: DatabaseContext,
26
+ credential_store: CredentialStore,
27
+ data_path: Path,
28
+ database_path: Path,
29
+ ) -> None:
30
+ self._runtime = runtime
31
+ self._snapshots = snapshot_service
32
+ self._database = database
33
+ self._credential_store = credential_store
34
+ executable = shutil.which("topicgate")
35
+ if executable:
36
+ command = str(Path(executable).resolve())
37
+ prefix: tuple[str, ...] = ()
38
+ else:
39
+ command = str(Path(sys.executable).resolve())
40
+ prefix = ("-m", "topicgate")
41
+ try:
42
+ installed_version = version("topicgate")
43
+ except PackageNotFoundError:
44
+ installed_version = "development"
45
+ self.information = McpSetupInformation(
46
+ version=installed_version,
47
+ executable_path=Path(command),
48
+ data_path=data_path.resolve(),
49
+ database_path=database_path.resolve(),
50
+ command=command,
51
+ command_prefix_arguments=prefix,
52
+ )
53
+
54
+ def configuration(self, mode: str = "read-only") -> str:
55
+ if mode not in {"read-only", "control"}:
56
+ raise ValueError("MCP mode must be read-only or control.")
57
+ server = {
58
+ "type": "stdio",
59
+ "command": self.information.command,
60
+ "args": [
61
+ *self.information.command_prefix_arguments,
62
+ "--mode",
63
+ mode,
64
+ ],
65
+ "env": {"TOPICGATE_DATA_DIR": str(self.information.data_path)},
66
+ }
67
+ rendered = json.dumps({"mcpServers": {"topicgate": server}}, indent=2)
68
+ return rendered.replace(
69
+ f' "--mode",\n "{mode}"',
70
+ f' "--mode", "{mode}"',
71
+ )
72
+
73
+ def preflight(self) -> tuple[McpPreflightCheck, ...]:
74
+ checks = [self._database_check(), self._migration_check()]
75
+ checks.append(self._credential_check())
76
+ brokers = self._runtime.list_brokers()
77
+ checks.append(
78
+ McpPreflightCheck(
79
+ "Broker profiles",
80
+ "pass" if brokers else "fail",
81
+ f"{len(brokers)} configured profile(s)."
82
+ if brokers
83
+ else "Create a broker profile in TopicGate Desktop.",
84
+ )
85
+ )
86
+ usable = sum(
87
+ len(self._runtime.list_subscriptions(broker.id)) for broker in brokers
88
+ )
89
+ checks.append(
90
+ McpPreflightCheck(
91
+ "Subscriptions",
92
+ "pass" if usable else "fail",
93
+ f"{usable} usable subscription(s)."
94
+ if usable
95
+ else "Add at least one subscription in TopicGate Desktop.",
96
+ )
97
+ )
98
+ try:
99
+ self._snapshots.build_current(self._runtime.active_broker.id)
100
+ except Exception as error:
101
+ checks.append(
102
+ McpPreflightCheck(
103
+ "Snapshot service",
104
+ "fail",
105
+ f"Snapshot unavailable: {error}",
106
+ )
107
+ )
108
+ else:
109
+ checks.append(
110
+ McpPreflightCheck(
111
+ "Snapshot service",
112
+ "pass",
113
+ "A local broker snapshot can be built while disconnected.",
114
+ )
115
+ )
116
+ dashboard_available = all(
117
+ importlib.util.find_spec(module) is not None
118
+ for module in ("fastmcp", "prefab_ui")
119
+ )
120
+ checks.append(
121
+ McpPreflightCheck(
122
+ "Dashboard dependencies",
123
+ "pass" if dashboard_available else "warning",
124
+ "Optional dashboard dependencies are available."
125
+ if dashboard_available
126
+ else "Optional only: install TopicGate with the 'apps' extra for dashboards.",
127
+ )
128
+ )
129
+ return tuple(checks)
130
+
131
+ def _database_check(self) -> McpPreflightCheck:
132
+ try:
133
+ with self._database.session() as session:
134
+ session.execute(text("SELECT 1")).scalar_one()
135
+ journal_mode = session.execute(
136
+ text("PRAGMA journal_mode")
137
+ ).scalar_one()
138
+ busy_timeout = session.execute(
139
+ text("PRAGMA busy_timeout")
140
+ ).scalar_one()
141
+ except Exception as error:
142
+ return McpPreflightCheck(
143
+ "Database accessibility",
144
+ "fail",
145
+ f"Cannot open the TopicGate database: {error}",
146
+ )
147
+ if str(journal_mode).lower() != "wal" or int(busy_timeout) < 5000:
148
+ return McpPreflightCheck(
149
+ "Database accessibility",
150
+ "fail",
151
+ "SQLite coordination is incomplete; restart TopicGate to enable WAL and busy timeout.",
152
+ )
153
+ return McpPreflightCheck(
154
+ "Database accessibility",
155
+ "pass",
156
+ "SQLite is accessible with WAL and a 5-second busy timeout.",
157
+ )
158
+
159
+ def _migration_check(self) -> McpPreflightCheck:
160
+ try:
161
+ with self._database.session() as session:
162
+ revision = session.execute(
163
+ text("SELECT version_num FROM alembic_version")
164
+ ).scalar_one()
165
+ except Exception as error:
166
+ return McpPreflightCheck(
167
+ "Database migrations",
168
+ "fail",
169
+ f"Migration state cannot be read: {error}",
170
+ )
171
+ if revision != EXPECTED_SCHEMA_REVISION:
172
+ return McpPreflightCheck(
173
+ "Database migrations",
174
+ "fail",
175
+ f"Expected schema {EXPECTED_SCHEMA_REVISION}, found {revision}; restart TopicGate to migrate.",
176
+ )
177
+ return McpPreflightCheck(
178
+ "Database migrations",
179
+ "pass",
180
+ f"Schema {revision} is current.",
181
+ )
182
+
183
+ def _credential_check(self) -> McpPreflightCheck:
184
+ required = ("get_password", "set_password", "delete_password")
185
+ available = all(callable(getattr(self._credential_store, name, None)) for name in required)
186
+ return McpPreflightCheck(
187
+ "Credential store",
188
+ "pass" if available else "fail",
189
+ "Operating-system credential store interface is available."
190
+ if available
191
+ else "Configure a supported keyring backend before storing broker credentials.",
192
+ )
@@ -0,0 +1,256 @@
1
+ from collections.abc import Collection
2
+ from collections import defaultdict
3
+ from datetime import datetime, timedelta, timezone
4
+ from uuid import UUID
5
+
6
+ from topicgate.core.models.observation_cache_administration import (
7
+ CacheUsageSummary,
8
+ ObservationDeletionResult,
9
+ PersistedTopicSummary,
10
+ RetentionImpactGroup,
11
+ RetentionPolicyApplicationResult,
12
+ RetentionPolicyPreview,
13
+ RetentionRemovalReason,
14
+ )
15
+ from topicgate.core.models.observation_deletion_preview import (
16
+ ObservationDeletionEntry,
17
+ )
18
+ from topicgate.core.models.observation_deletion_preview import (
19
+ ObservationDeletionPreview,
20
+ )
21
+ from topicgate.core.models.observation_retention_policy import (
22
+ ObservationRetentionPolicy,
23
+ )
24
+ from topicgate.core.models.subscription import Subscription
25
+ from topicgate.core.mqtt_topics import mqtt_filter_matches
26
+ from topicgate.app.services.observation_retention_policy_service import (
27
+ ObservationRetentionPolicyService,
28
+ )
29
+ from topicgate.infrastructure.repository.topic_message_repository import (
30
+ TopicMessageRepository,
31
+ )
32
+
33
+
34
+ class ObservationCacheService:
35
+ """Coordinate retention settings and confirmed cache deletion."""
36
+
37
+ def __init__(
38
+ self,
39
+ messages: TopicMessageRepository,
40
+ policies: ObservationRetentionPolicyService,
41
+ ) -> None:
42
+ self._messages = messages
43
+ self._policies = policies
44
+
45
+ def get_retention_policy(self) -> ObservationRetentionPolicy:
46
+ return self._policies.get()
47
+
48
+ def update_retention_policy(
49
+ self,
50
+ policy: ObservationRetentionPolicy,
51
+ ) -> ObservationRetentionPolicy:
52
+ persisted = self._policies.update(policy)
53
+ self._messages.enforce_retention()
54
+ return persisted
55
+
56
+ def preview_clear_cache(
57
+ self,
58
+ broker_id: UUID,
59
+ topics: Collection[str] | None = None,
60
+ ) -> ObservationDeletionPreview:
61
+ return self._messages.preview_deletion(broker_id, topics)
62
+
63
+ def preview_unsubscribed(
64
+ self,
65
+ broker_id: UUID,
66
+ subscriptions: Collection[Subscription],
67
+ ) -> ObservationDeletionPreview:
68
+ preview = self._messages.preview_deletion(broker_id)
69
+ entries = tuple(
70
+ entry
71
+ for entry in preview.entries
72
+ if not any(
73
+ mqtt_filter_matches(subscription.topic_filter, entry.topic)
74
+ for subscription in subscriptions
75
+ )
76
+ )
77
+ return ObservationDeletionPreview(broker_id, entries)
78
+
79
+ def get_cache_usage(self) -> CacheUsageSummary:
80
+ return self._messages.cache_usage()
81
+
82
+ def get_persisted_topics(
83
+ self,
84
+ broker_id: UUID,
85
+ subscriptions: Collection[Subscription],
86
+ ) -> tuple[PersistedTopicSummary, ...]:
87
+ preview = self._messages.preview_deletion(broker_id)
88
+ return tuple(
89
+ PersistedTopicSummary(
90
+ broker_id=entry.broker_id,
91
+ topic=entry.topic,
92
+ observation_id=entry.observation_id,
93
+ stored_payload_bytes=entry.stored_payload_bytes,
94
+ received_at=entry.received_at,
95
+ is_subscribed=any(
96
+ mqtt_filter_matches(item.topic_filter, entry.topic)
97
+ for item in subscriptions
98
+ ),
99
+ )
100
+ for entry in preview.entries
101
+ )
102
+
103
+ def preview_all(self) -> ObservationDeletionPreview:
104
+ return self._messages.preview_all_deletion()
105
+
106
+ def confirm_deletion_detailed(
107
+ self,
108
+ preview: ObservationDeletionPreview,
109
+ ) -> ObservationDeletionResult:
110
+ return self._messages.delete_previewed_detailed(preview)
111
+
112
+ def preview_retention_policy(
113
+ self,
114
+ policy: ObservationRetentionPolicy,
115
+ subscriptions: dict[UUID, Collection[Subscription]],
116
+ *,
117
+ now: datetime | None = None,
118
+ ) -> RetentionPolicyPreview:
119
+ current = self._policies.get()
120
+ all_entries = self._messages.preview_all_deletion().entries
121
+ grouped = self._retention_impact(
122
+ all_entries,
123
+ policy,
124
+ subscriptions,
125
+ now=now or datetime.now(timezone.utc),
126
+ )
127
+ entries = tuple(
128
+ entry
129
+ for group in grouped
130
+ for entry in group.entries
131
+ )
132
+ return RetentionPolicyPreview(
133
+ current,
134
+ policy,
135
+ ObservationDeletionPreview(None, entries, "policy_enforcement"),
136
+ grouped,
137
+ )
138
+
139
+ def confirm_retention_policy(
140
+ self,
141
+ preview: RetentionPolicyPreview,
142
+ subscriptions: dict[UUID, Collection[Subscription]],
143
+ ) -> RetentionPolicyApplicationResult:
144
+ if self._policies.get() != preview.previous_policy:
145
+ raise ValueError("The retention policy changed after preview.")
146
+ current = self.preview_retention_policy(
147
+ preview.proposed_policy,
148
+ subscriptions,
149
+ )
150
+ if current.deletion.entries != preview.deletion.entries:
151
+ raise ValueError(
152
+ "Persisted observations changed after preview; preview again."
153
+ )
154
+ enforcement = self.confirm_deletion_detailed(preview.deletion)
155
+ persisted = self._policies.update(preview.proposed_policy)
156
+ return RetentionPolicyApplicationResult(persisted, enforcement)
157
+
158
+ def confirm_deletion(self, preview: ObservationDeletionPreview) -> int:
159
+ return self.confirm_deletion_detailed(preview).deleted_count
160
+
161
+ def flush_pending_writes(self) -> None:
162
+ self._messages.flush()
163
+
164
+ @classmethod
165
+ def _retention_impact(
166
+ cls,
167
+ entries: tuple[ObservationDeletionEntry, ...],
168
+ policy: ObservationRetentionPolicy,
169
+ subscriptions: dict[UUID, Collection[Subscription]],
170
+ *,
171
+ now: datetime,
172
+ ) -> tuple[RetentionImpactGroup, ...]:
173
+ ordered = sorted(
174
+ entries,
175
+ key=lambda item: (
176
+ cls._utc(item.received_at),
177
+ str(item.broker_id),
178
+ item.topic,
179
+ str(item.observation_id),
180
+ ),
181
+ )
182
+ reasons: dict[UUID, RetentionRemovalReason] = {}
183
+ if policy.auto_remove_expired and policy.max_age_seconds is not None:
184
+ cutoff = cls._utc(now) - timedelta(seconds=policy.max_age_seconds)
185
+ for entry in ordered:
186
+ if cls._utc(entry.received_at) < cutoff:
187
+ reasons[entry.observation_id] = RetentionRemovalReason.EXPIRED
188
+
189
+ if policy.auto_remove_unsubscribed:
190
+ for entry in ordered:
191
+ if entry.observation_id in reasons:
192
+ continue
193
+ if not any(
194
+ mqtt_filter_matches(item.topic_filter, entry.topic)
195
+ for item in subscriptions.get(entry.broker_id, ())
196
+ ):
197
+ reasons[entry.observation_id] = (
198
+ RetentionRemovalReason.UNSUBSCRIBED
199
+ )
200
+
201
+ if policy.auto_remove_excess:
202
+ by_broker: dict[UUID, list[ObservationDeletionEntry]] = defaultdict(list)
203
+ for entry in ordered:
204
+ if entry.observation_id not in reasons:
205
+ by_broker[entry.broker_id].append(entry)
206
+ for broker_entries in by_broker.values():
207
+ stored_bytes = sum(item.stored_payload_bytes for item in broker_entries)
208
+ while (
209
+ len(broker_entries) > policy.max_entries_per_broker
210
+ or stored_bytes > policy.max_payload_bytes_per_broker
211
+ ):
212
+ reason = (
213
+ RetentionRemovalReason.PER_BROKER_ENTRY_LIMIT
214
+ if len(broker_entries) > policy.max_entries_per_broker
215
+ else RetentionRemovalReason.PER_BROKER_BYTE_LIMIT
216
+ )
217
+ removed = broker_entries.pop(0)
218
+ stored_bytes -= removed.stored_payload_bytes
219
+ reasons[removed.observation_id] = reason
220
+
221
+ remaining = [
222
+ entry for entry in ordered if entry.observation_id not in reasons
223
+ ]
224
+ stored_bytes = sum(item.stored_payload_bytes for item in remaining)
225
+ while (
226
+ len(remaining) > policy.max_entries_total
227
+ or stored_bytes
228
+ > policy.max_persisted_payload_database_bytes_total
229
+ ):
230
+ reason = (
231
+ RetentionRemovalReason.GLOBAL_ENTRY_LIMIT
232
+ if len(remaining) > policy.max_entries_total
233
+ else RetentionRemovalReason.GLOBAL_BYTE_LIMIT
234
+ )
235
+ removed = remaining.pop(0)
236
+ stored_bytes -= removed.stored_payload_bytes
237
+ reasons[removed.observation_id] = reason
238
+
239
+ groups: dict[
240
+ tuple[UUID, RetentionRemovalReason],
241
+ list[ObservationDeletionEntry],
242
+ ] = defaultdict(list)
243
+ for entry in ordered:
244
+ reason = reasons.get(entry.observation_id)
245
+ if reason is not None:
246
+ groups[(entry.broker_id, reason)].append(entry)
247
+ return tuple(
248
+ RetentionImpactGroup(broker_id, reason, tuple(items))
249
+ for (broker_id, reason), items in sorted(
250
+ groups.items(), key=lambda item: (str(item[0][0]), item[0][1].value)
251
+ )
252
+ )
253
+
254
+ @staticmethod
255
+ def _utc(value: datetime) -> datetime:
256
+ return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
@@ -0,0 +1,33 @@
1
+ from threading import Lock
2
+
3
+ from topicgate.core.models.observation_retention_policy import (
4
+ ObservationRetentionPolicy,
5
+ )
6
+ from topicgate.infrastructure.repository.observation_retention_policy_repository import (
7
+ ObservationRetentionPolicyRepository,
8
+ )
9
+
10
+
11
+ class ObservationRetentionPolicyService:
12
+ """Keep the persisted retention policy available without callback I/O."""
13
+
14
+ def __init__(
15
+ self,
16
+ repository: ObservationRetentionPolicyRepository,
17
+ ) -> None:
18
+ self._repository = repository
19
+ self._lock = Lock()
20
+ self._policy = repository.get()
21
+
22
+ def get(self) -> ObservationRetentionPolicy:
23
+ with self._lock:
24
+ return self._policy
25
+
26
+ def update(
27
+ self,
28
+ policy: ObservationRetentionPolicy,
29
+ ) -> ObservationRetentionPolicy:
30
+ persisted = self._repository.update(policy)
31
+ with self._lock:
32
+ self._policy = persisted
33
+ return persisted
@@ -0,0 +1,25 @@
1
+ from topicgate.app.services.service_item import ServiceItem
2
+ from topicgate.infrastructure.database.database_context import DatabaseContext
3
+ from topicgate.infrastructure.repository.topic_message_repository import (
4
+ TopicMessageRepository,
5
+ )
6
+
7
+
8
+ class PersistenceLifecycle(ServiceItem):
9
+ """Close application-owned persistence resources after runtime shutdown."""
10
+
11
+ def __init__(
12
+ self,
13
+ topic_messages: TopicMessageRepository,
14
+ database: DatabaseContext,
15
+ ) -> None:
16
+ self._topic_messages = topic_messages
17
+ self._database = database
18
+
19
+ async def start(self) -> None:
20
+ """Persistence resources are ready immediately after construction."""
21
+
22
+ async def stop(self) -> None:
23
+ """Drain queued messages before disposing database connections."""
24
+ self._topic_messages.close()
25
+ self._database.dispose()
@@ -0,0 +1,20 @@
1
+ from topicgate.app.app_dependencies import AppDependencies
2
+ from topicgate.app.services.service_item import ServiceItem
3
+
4
+
5
+ class ServiceContainer:
6
+ """Starts registered services in order and stops them in reverse order."""
7
+
8
+ def __init__(self, dependencies: AppDependencies) -> None:
9
+ self._service_items: tuple[ServiceItem, ...] = dependencies.service_items
10
+ self._started_items: list[ServiceItem] = []
11
+
12
+ async def start_services(self) -> None:
13
+ for service_item in self._service_items:
14
+ await service_item.start()
15
+ self._started_items.append(service_item)
16
+
17
+ async def stop_services(self) -> None:
18
+ while self._started_items:
19
+ service_item = self._started_items.pop()
20
+ await service_item.stop()
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class ServiceItem(ABC):
5
+ """A component whose lifetime is managed by the application."""
6
+
7
+ @abstractmethod
8
+ async def start(self) -> None:
9
+ """Start the service."""
10
+
11
+ @abstractmethod
12
+ async def stop(self) -> None:
13
+ """Stop the service."""