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.
- topicgate/__init__.py +0 -0
- topicgate/__main__.py +5 -0
- topicgate/app/__init__.py +0 -0
- topicgate/app/app_dependencies.py +148 -0
- topicgate/app/broker_runtime_state.py +43 -0
- topicgate/app/models/__init__.py +1 -0
- topicgate/app/models/broker_snapshot.py +107 -0
- topicgate/app/models/mcp_setup.py +20 -0
- topicgate/app/services/__init__.py +0 -0
- topicgate/app/services/broker_profile_service.py +245 -0
- topicgate/app/services/broker_resolver.py +58 -0
- topicgate/app/services/broker_snapshot_service.py +403 -0
- topicgate/app/services/control_operation_service.py +158 -0
- topicgate/app/services/mcp_setup_service.py +192 -0
- topicgate/app/services/observation_cache_service.py +256 -0
- topicgate/app/services/observation_retention_policy_service.py +33 -0
- topicgate/app/services/persistence_lifecycle.py +25 -0
- topicgate/app/services/service_container.py +20 -0
- topicgate/app/services/service_item.py +13 -0
- topicgate/app/topicgate_runtime.py +496 -0
- topicgate/assets/icon.png +0 -0
- topicgate/assets/icon.svg +37 -0
- topicgate/core/__init__.py +0 -0
- topicgate/core/config/__init__.py +0 -0
- topicgate/core/config/app_config.py +7 -0
- topicgate/core/config/mqtt_config.py +10 -0
- topicgate/core/interfaces/__init__.py +0 -0
- topicgate/core/interfaces/broker_profile_store.py +26 -0
- topicgate/core/interfaces/mqtt_message_processor.py +12 -0
- topicgate/core/interfaces/observer_repository.py +82 -0
- topicgate/core/interfaces/topic_message_store.py +13 -0
- topicgate/core/models/__init__.py +0 -0
- topicgate/core/models/broker_profile.py +15 -0
- topicgate/core/models/broker_profile_identity.py +14 -0
- topicgate/core/models/broker_summary.py +14 -0
- topicgate/core/models/connection_status.py +9 -0
- topicgate/core/models/message_filter.py +9 -0
- topicgate/core/models/mqtt_message.py +13 -0
- topicgate/core/models/mqtt_observation.py +36 -0
- topicgate/core/models/mqtt_state.py +5 -0
- topicgate/core/models/observation_cache_administration.py +135 -0
- topicgate/core/models/observation_deletion_preview.py +39 -0
- topicgate/core/models/observation_retention_policy.py +65 -0
- topicgate/core/models/observer_model.py +22 -0
- topicgate/core/models/observer_workspace.py +13 -0
- topicgate/core/models/subscription.py +29 -0
- topicgate/core/models/topic_message.py +16 -0
- topicgate/core/mqtt_topics.py +46 -0
- topicgate/core/observer_limits.py +8 -0
- topicgate/core/payload_limits.py +6 -0
- topicgate/gui/__init__.py +0 -0
- topicgate/gui/app.py +97 -0
- topicgate/gui/broker_state_reader.py +29 -0
- topicgate/gui/components/about_dialog.py +100 -0
- topicgate/gui/components/add_subscription_dialog.py +59 -0
- topicgate/gui/components/application_header.py +137 -0
- topicgate/gui/components/broker_settings_dialog.py +223 -0
- topicgate/gui/components/connection_controls.py +123 -0
- topicgate/gui/components/connection_status.py +7 -0
- topicgate/gui/components/log_console.py +26 -0
- topicgate/gui/components/mcp_setup_dialog.py +198 -0
- topicgate/gui/components/observer_tree.py +434 -0
- topicgate/gui/components/onboarding_panel.py +74 -0
- topicgate/gui/components/publish_pane.py +60 -0
- topicgate/gui/components/snapshot_panel.py +343 -0
- topicgate/gui/components/stored_observations_dialog.py +611 -0
- topicgate/gui/components/subscription_settings.py +151 -0
- topicgate/gui/components/topic_details.py +51 -0
- topicgate/gui/components/topic_metadata.py +79 -0
- topicgate/gui/components/workspace_pane.py +30 -0
- topicgate/gui/gui.py +5 -0
- topicgate/gui/main_view_model.py +814 -0
- topicgate/gui/main_window.py +986 -0
- topicgate/gui/observer_state_reader.py +47 -0
- topicgate/gui/settings_migration.py +32 -0
- topicgate/gui/theme.py +61 -0
- topicgate/infrastructure/__init__.py +0 -0
- topicgate/infrastructure/credentials/__init__.py +1 -0
- topicgate/infrastructure/credentials/credential_store.py +10 -0
- topicgate/infrastructure/credentials/os_credential_store.py +20 -0
- topicgate/infrastructure/database/__init__.py +0 -0
- topicgate/infrastructure/database/base.py +5 -0
- topicgate/infrastructure/database/database_context.py +56 -0
- topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
- topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
- topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
- topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
- topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
- topicgate/infrastructure/database/migrations.py +57 -0
- topicgate/infrastructure/database/models/__init__.py +25 -0
- topicgate/infrastructure/database/models/app_config_row.py +18 -0
- topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
- topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
- topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
- topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
- topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
- topicgate/infrastructure/database/models/subscription_row.py +18 -0
- topicgate/infrastructure/mqtt/__init__.py +0 -0
- topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
- topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
- topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
- topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
- topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
- topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
- topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
- topicgate/infrastructure/repository/__init__.py +0 -0
- topicgate/infrastructure/repository/broker_config_repository.py +68 -0
- topicgate/infrastructure/repository/broker_repository.py +170 -0
- topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
- topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
- topicgate/infrastructure/repository/subscription_repository.py +117 -0
- topicgate/infrastructure/repository/topic_message_repository.py +438 -0
- topicgate/mcp/__init__.py +0 -0
- topicgate/mcp/api/broker_api.py +48 -0
- topicgate/mcp/api/connection_api.py +83 -0
- topicgate/mcp/api/dashboard_api.py +790 -0
- topicgate/mcp/api/dashboard_snapshot.py +278 -0
- topicgate/mcp/api/mcp_api.py +18 -0
- topicgate/mcp/api/preview/dashboard_preview.py +6 -0
- topicgate/mcp/api/publish_api.py +62 -0
- topicgate/mcp/api/snapshot_api.py +78 -0
- topicgate/mcp/api/subscription_api.py +134 -0
- topicgate/mcp/api/topic_api.py +77 -0
- topicgate/mcp/capabilities.py +12 -0
- topicgate/mcp/instructions.py +3 -0
- topicgate/mcp/middleware/__init__.py +6 -0
- topicgate/mcp/middleware/error_handling_middleware.py +49 -0
- topicgate/mcp/middleware/logging_middleware.py +43 -0
- topicgate/mcp/models.py +28 -0
- topicgate/mcp/server.py +147 -0
- topicgate/paths.py +36 -0
- topicgate/presentation/__init__.py +39 -0
- topicgate/presentation/retention_presentation.py +187 -0
- topicgate/presentation/snapshot_presentation.py +130 -0
- topicgate/presentation/topic_presentation.py +482 -0
- topicgate/processors/__init__.py +0 -0
- topicgate/processors/observation_retention_processor.py +35 -0
- topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
- topicgate/processors/observer_model_processor.py +223 -0
- topicgate/processors/subscription_manager.py +90 -0
- topicgate-1.1.0.dist-info/METADATA +222 -0
- topicgate-1.1.0.dist-info/RECORD +148 -0
- topicgate-1.1.0.dist-info/WHEEL +5 -0
- topicgate-1.1.0.dist-info/entry_points.txt +3 -0
- topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
- 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."""
|