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,403 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from topicgate.app.models.broker_snapshot import (
|
|
10
|
+
BrokerSnapshot,
|
|
11
|
+
SnapshotBrokerIdentity,
|
|
12
|
+
SnapshotCompleteness,
|
|
13
|
+
SnapshotFreshness,
|
|
14
|
+
SnapshotLimitation,
|
|
15
|
+
SnapshotPayload,
|
|
16
|
+
SnapshotPayloadEncoding,
|
|
17
|
+
SnapshotResultLimit,
|
|
18
|
+
SnapshotSettling,
|
|
19
|
+
SnapshotTopicState,
|
|
20
|
+
SnapshotTopicStatus,
|
|
21
|
+
)
|
|
22
|
+
from topicgate.app.services.broker_resolver import BrokerResolver
|
|
23
|
+
from topicgate.app.topicgate_runtime import TopicGateRuntime
|
|
24
|
+
from topicgate.core.models.broker_summary import BrokerSummary
|
|
25
|
+
from topicgate.core.models.connection_status import ConnectionStatus
|
|
26
|
+
from topicgate.core.models.mqtt_observation import MqttObservation, ObservationSource
|
|
27
|
+
from topicgate.core.models.subscription import Subscription
|
|
28
|
+
from topicgate.core.mqtt_topics import mqtt_filter_matches
|
|
29
|
+
from topicgate.core.observer_limits import MAX_OBSERVED_TOPICS
|
|
30
|
+
from topicgate.core.payload_limits import MAX_RENDERED_PAYLOAD_BYTES
|
|
31
|
+
|
|
32
|
+
DEFAULT_SNAPSHOT_RESULT_LIMIT = 100
|
|
33
|
+
MAX_SNAPSHOT_RESULT_LIMIT = MAX_OBSERVED_TOPICS
|
|
34
|
+
DEFAULT_SNAPSHOT_WAIT_SECONDS = 1.0
|
|
35
|
+
MAX_SNAPSHOT_WAIT_SECONDS = 5.0
|
|
36
|
+
|
|
37
|
+
Clock = Callable[[], datetime]
|
|
38
|
+
MonotonicClock = Callable[[], float]
|
|
39
|
+
Sleep = Callable[[float], Awaitable[None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BrokerSnapshotService:
|
|
43
|
+
"""Build bounded broker snapshots for any application presentation layer."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
runtime: TopicGateRuntime,
|
|
48
|
+
*,
|
|
49
|
+
resolver: BrokerResolver | None = None,
|
|
50
|
+
clock: Clock | None = None,
|
|
51
|
+
monotonic_clock: MonotonicClock | None = None,
|
|
52
|
+
sleep: Sleep | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._runtime = runtime
|
|
55
|
+
self._resolver = resolver or BrokerResolver(runtime)
|
|
56
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
57
|
+
self._monotonic_clock = monotonic_clock or time.monotonic
|
|
58
|
+
self._sleep = sleep or asyncio.sleep
|
|
59
|
+
|
|
60
|
+
async def build(
|
|
61
|
+
self,
|
|
62
|
+
broker: UUID | str,
|
|
63
|
+
*,
|
|
64
|
+
topic_filter: str = "#",
|
|
65
|
+
max_age_seconds: float | None = None,
|
|
66
|
+
result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
|
|
67
|
+
payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
|
|
68
|
+
) -> BrokerSnapshot:
|
|
69
|
+
return self.build_current(
|
|
70
|
+
broker,
|
|
71
|
+
topic_filter=topic_filter,
|
|
72
|
+
max_age_seconds=max_age_seconds,
|
|
73
|
+
result_limit=result_limit,
|
|
74
|
+
payload_limit_bytes=payload_limit_bytes,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def build_current(
|
|
78
|
+
self,
|
|
79
|
+
broker: UUID | str,
|
|
80
|
+
*,
|
|
81
|
+
topic_filter: str = "#",
|
|
82
|
+
max_age_seconds: float | None = None,
|
|
83
|
+
result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
|
|
84
|
+
payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
|
|
85
|
+
) -> BrokerSnapshot:
|
|
86
|
+
"""Build current broker state without activation, waiting, or I/O."""
|
|
87
|
+
resolved = self._resolver.resolve(broker)
|
|
88
|
+
validated_filter = self._validate_topic_filter(topic_filter)
|
|
89
|
+
max_age_seconds = self._validate_max_age(max_age_seconds)
|
|
90
|
+
result_limit = self._validate_result_limit(result_limit)
|
|
91
|
+
payload_limit_bytes = self._validate_payload_limit(payload_limit_bytes)
|
|
92
|
+
|
|
93
|
+
return self._capture(
|
|
94
|
+
resolved,
|
|
95
|
+
topic_filter=validated_filter,
|
|
96
|
+
max_age_seconds=max_age_seconds,
|
|
97
|
+
result_limit=result_limit,
|
|
98
|
+
payload_limit_bytes=payload_limit_bytes,
|
|
99
|
+
requested_wait_seconds=0.0,
|
|
100
|
+
actual_wait_seconds=0.0,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def observe(
|
|
104
|
+
self,
|
|
105
|
+
broker: UUID | str,
|
|
106
|
+
*,
|
|
107
|
+
topic_filter: str = "#",
|
|
108
|
+
max_age_seconds: float | None = None,
|
|
109
|
+
result_limit: int = DEFAULT_SNAPSHOT_RESULT_LIMIT,
|
|
110
|
+
payload_limit_bytes: int = MAX_RENDERED_PAYLOAD_BYTES,
|
|
111
|
+
wait_seconds: float = DEFAULT_SNAPSHOT_WAIT_SECONDS,
|
|
112
|
+
) -> BrokerSnapshot:
|
|
113
|
+
resolved = self._resolver.resolve(broker)
|
|
114
|
+
validated_filter = self._validate_topic_filter(topic_filter)
|
|
115
|
+
max_age_seconds = self._validate_max_age(max_age_seconds)
|
|
116
|
+
result_limit = self._validate_result_limit(result_limit)
|
|
117
|
+
payload_limit_bytes = self._validate_payload_limit(payload_limit_bytes)
|
|
118
|
+
wait_seconds = self._validate_wait_seconds(wait_seconds)
|
|
119
|
+
|
|
120
|
+
with self._runtime.control_operation("reconnect and observe"):
|
|
121
|
+
await self._runtime.activate_broker(resolved.id)
|
|
122
|
+
actual_wait_seconds = await self._wait(wait_seconds)
|
|
123
|
+
return self._capture(
|
|
124
|
+
resolved,
|
|
125
|
+
topic_filter=validated_filter,
|
|
126
|
+
max_age_seconds=max_age_seconds,
|
|
127
|
+
result_limit=result_limit,
|
|
128
|
+
payload_limit_bytes=payload_limit_bytes,
|
|
129
|
+
requested_wait_seconds=wait_seconds,
|
|
130
|
+
actual_wait_seconds=actual_wait_seconds,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _capture(
|
|
134
|
+
self,
|
|
135
|
+
resolved: BrokerSummary,
|
|
136
|
+
*,
|
|
137
|
+
topic_filter: str,
|
|
138
|
+
max_age_seconds: float | None,
|
|
139
|
+
result_limit: int,
|
|
140
|
+
payload_limit_bytes: int,
|
|
141
|
+
requested_wait_seconds: float,
|
|
142
|
+
actual_wait_seconds: float,
|
|
143
|
+
) -> BrokerSnapshot:
|
|
144
|
+
captured_at = self._as_utc(self._clock())
|
|
145
|
+
model = self._runtime.get_observer_model(resolved.id)
|
|
146
|
+
matching = sorted(
|
|
147
|
+
(
|
|
148
|
+
state
|
|
149
|
+
for state in model.topic_states.values()
|
|
150
|
+
if mqtt_filter_matches(topic_filter, state.topic)
|
|
151
|
+
),
|
|
152
|
+
key=lambda state: state.topic,
|
|
153
|
+
)
|
|
154
|
+
aged = tuple(
|
|
155
|
+
(state, self._age_seconds(captured_at, state.received_at))
|
|
156
|
+
for state in matching
|
|
157
|
+
)
|
|
158
|
+
fresh = tuple(
|
|
159
|
+
(state, age)
|
|
160
|
+
for state, age in aged
|
|
161
|
+
if max_age_seconds is None or age <= max_age_seconds
|
|
162
|
+
)
|
|
163
|
+
stale_count = len(aged) - len(fresh)
|
|
164
|
+
selected = fresh[:result_limit]
|
|
165
|
+
omitted_by_limit = len(fresh) - len(selected)
|
|
166
|
+
status = self._runtime.get_connection_status(resolved.id)
|
|
167
|
+
dropped_message_count = self._runtime.get_dropped_message_count(
|
|
168
|
+
resolved.id
|
|
169
|
+
)
|
|
170
|
+
connected_at = self._runtime.get_connected_at(resolved.id)
|
|
171
|
+
observation_started_at = self._runtime.get_observation_started_at(
|
|
172
|
+
resolved.id
|
|
173
|
+
)
|
|
174
|
+
topics = tuple(
|
|
175
|
+
self._topic_snapshot(
|
|
176
|
+
state,
|
|
177
|
+
age,
|
|
178
|
+
payload_limit_bytes,
|
|
179
|
+
observation_started_at=observation_started_at,
|
|
180
|
+
)
|
|
181
|
+
for state, age in selected
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
observed_for_seconds = (
|
|
185
|
+
None
|
|
186
|
+
if observation_started_at is None
|
|
187
|
+
else self._age_seconds(captured_at, observation_started_at)
|
|
188
|
+
)
|
|
189
|
+
limitations = self._limitations(
|
|
190
|
+
status=status,
|
|
191
|
+
matching=matching,
|
|
192
|
+
topics=topics,
|
|
193
|
+
observation_started_at=observation_started_at,
|
|
194
|
+
dropped_message_count=dropped_message_count,
|
|
195
|
+
stale_count=stale_count,
|
|
196
|
+
omitted_by_limit=omitted_by_limit,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return BrokerSnapshot(
|
|
200
|
+
broker=SnapshotBrokerIdentity(resolved.id, resolved.name),
|
|
201
|
+
connection_status=self._status_value(status),
|
|
202
|
+
captured_at=captured_at,
|
|
203
|
+
connected_at=(
|
|
204
|
+
None if connected_at is None else self._as_utc(connected_at)
|
|
205
|
+
),
|
|
206
|
+
observation_started_at=(
|
|
207
|
+
None
|
|
208
|
+
if observation_started_at is None
|
|
209
|
+
else self._as_utc(observation_started_at)
|
|
210
|
+
),
|
|
211
|
+
observed_for_seconds=observed_for_seconds,
|
|
212
|
+
topic_filter=topic_filter,
|
|
213
|
+
topics=topics,
|
|
214
|
+
dropped_message_count=dropped_message_count,
|
|
215
|
+
freshness=SnapshotFreshness(max_age_seconds, stale_count),
|
|
216
|
+
results=SnapshotResultLimit(
|
|
217
|
+
limit=result_limit,
|
|
218
|
+
total=len(aged),
|
|
219
|
+
returned=len(topics),
|
|
220
|
+
omitted=len(aged) - len(topics),
|
|
221
|
+
omitted_as_stale=stale_count,
|
|
222
|
+
omitted_by_limit=omitted_by_limit,
|
|
223
|
+
truncated=omitted_by_limit > 0,
|
|
224
|
+
),
|
|
225
|
+
settling=SnapshotSettling(
|
|
226
|
+
requested_seconds=requested_wait_seconds,
|
|
227
|
+
maximum_seconds=MAX_SNAPSHOT_WAIT_SECONDS,
|
|
228
|
+
actual_seconds=actual_wait_seconds,
|
|
229
|
+
),
|
|
230
|
+
completeness=SnapshotCompleteness(False, limitations),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
async def _wait(self, wait_seconds: float) -> float:
|
|
234
|
+
if wait_seconds == 0:
|
|
235
|
+
return 0.0
|
|
236
|
+
started_at = self._monotonic_clock()
|
|
237
|
+
await self._sleep(wait_seconds)
|
|
238
|
+
return max(0.0, self._monotonic_clock() - started_at)
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def _topic_snapshot(
|
|
242
|
+
cls,
|
|
243
|
+
state: MqttObservation,
|
|
244
|
+
age_seconds: float,
|
|
245
|
+
payload_limit_bytes: int,
|
|
246
|
+
*,
|
|
247
|
+
observation_started_at: datetime | None,
|
|
248
|
+
) -> SnapshotTopicState:
|
|
249
|
+
return SnapshotTopicState(
|
|
250
|
+
topic=state.topic,
|
|
251
|
+
payload=cls._render_payload(state, payload_limit_bytes),
|
|
252
|
+
qos=state.qos,
|
|
253
|
+
retain=state.retain,
|
|
254
|
+
received_at=cls._as_utc(state.received_at),
|
|
255
|
+
age_seconds=age_seconds,
|
|
256
|
+
message_count=state.message_count,
|
|
257
|
+
source=state.source,
|
|
258
|
+
status=cls._topic_status(state, observation_started_at),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def _topic_status(
|
|
263
|
+
cls,
|
|
264
|
+
state: MqttObservation,
|
|
265
|
+
observation_started_at: datetime | None,
|
|
266
|
+
) -> SnapshotTopicStatus:
|
|
267
|
+
if (
|
|
268
|
+
observation_started_at is not None
|
|
269
|
+
and cls._as_utc(state.received_at)
|
|
270
|
+
< cls._as_utc(observation_started_at)
|
|
271
|
+
):
|
|
272
|
+
return SnapshotTopicStatus.STALE
|
|
273
|
+
if state.source == ObservationSource.STORED:
|
|
274
|
+
return SnapshotTopicStatus.CACHED
|
|
275
|
+
return SnapshotTopicStatus.LIVE
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _render_payload(
|
|
279
|
+
state: MqttObservation,
|
|
280
|
+
payload_limit_bytes: int,
|
|
281
|
+
) -> SnapshotPayload:
|
|
282
|
+
available = bytes(state.payload)
|
|
283
|
+
original_size = max(state.payload_size or 0, len(available))
|
|
284
|
+
rendered = available[:payload_limit_bytes]
|
|
285
|
+
try:
|
|
286
|
+
available.decode("utf-8")
|
|
287
|
+
except UnicodeDecodeError:
|
|
288
|
+
encoding = SnapshotPayloadEncoding.BASE64
|
|
289
|
+
value = base64.b64encode(rendered).decode("ascii")
|
|
290
|
+
rendered_size = len(rendered)
|
|
291
|
+
else:
|
|
292
|
+
encoding = SnapshotPayloadEncoding.UTF8
|
|
293
|
+
value = rendered.decode("utf-8", errors="ignore")
|
|
294
|
+
rendered_size = len(value.encode("utf-8"))
|
|
295
|
+
|
|
296
|
+
ingestion_truncated = original_size > len(available)
|
|
297
|
+
rendering_truncated = rendered_size < len(available)
|
|
298
|
+
return SnapshotPayload(
|
|
299
|
+
encoding=encoding,
|
|
300
|
+
value=value,
|
|
301
|
+
original_size=original_size,
|
|
302
|
+
available_size=len(available),
|
|
303
|
+
rendered_size=rendered_size,
|
|
304
|
+
ingestion_truncated=ingestion_truncated,
|
|
305
|
+
rendering_truncated=rendering_truncated,
|
|
306
|
+
truncated=ingestion_truncated or rendering_truncated,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _limitations(
|
|
311
|
+
*,
|
|
312
|
+
status: object,
|
|
313
|
+
matching: list[MqttObservation],
|
|
314
|
+
topics: tuple[SnapshotTopicState, ...],
|
|
315
|
+
observation_started_at: datetime | None,
|
|
316
|
+
dropped_message_count: int,
|
|
317
|
+
stale_count: int,
|
|
318
|
+
omitted_by_limit: int,
|
|
319
|
+
) -> tuple[SnapshotLimitation, ...]:
|
|
320
|
+
limitations = [
|
|
321
|
+
SnapshotLimitation.CURRENT_STATE_ONLY,
|
|
322
|
+
SnapshotLimitation.RETAINED_DELIVERY_UNCONFIRMED,
|
|
323
|
+
]
|
|
324
|
+
if BrokerSnapshotService._status_value(status) != ConnectionStatus.CONNECTED:
|
|
325
|
+
limitations.append(SnapshotLimitation.BROKER_DISCONNECTED)
|
|
326
|
+
if observation_started_at is None:
|
|
327
|
+
limitations.append(SnapshotLimitation.OBSERVATION_NOT_STARTED)
|
|
328
|
+
if any(state.source == ObservationSource.STORED for state in matching):
|
|
329
|
+
limitations.append(
|
|
330
|
+
SnapshotLimitation.STORED_STATE_PREDATES_OBSERVATION
|
|
331
|
+
)
|
|
332
|
+
if dropped_message_count:
|
|
333
|
+
limitations.append(SnapshotLimitation.DROPPED_MESSAGES)
|
|
334
|
+
if stale_count:
|
|
335
|
+
limitations.append(SnapshotLimitation.STALE_STATES_OMITTED)
|
|
336
|
+
if omitted_by_limit:
|
|
337
|
+
limitations.append(SnapshotLimitation.RESULT_LIMIT_REACHED)
|
|
338
|
+
if any(topic.payload.truncated for topic in topics):
|
|
339
|
+
limitations.append(SnapshotLimitation.PAYLOAD_TRUNCATED)
|
|
340
|
+
return tuple(limitations)
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _validate_topic_filter(topic_filter: str) -> str:
|
|
344
|
+
return Subscription(topic_filter).topic_filter
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _validate_max_age(max_age_seconds: float | None) -> float | None:
|
|
348
|
+
if max_age_seconds is None:
|
|
349
|
+
return None
|
|
350
|
+
value = float(max_age_seconds)
|
|
351
|
+
if not math.isfinite(value) or value < 0:
|
|
352
|
+
raise ValueError("max_age_seconds must be a finite non-negative value.")
|
|
353
|
+
return value
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _validate_result_limit(result_limit: int) -> int:
|
|
357
|
+
if isinstance(result_limit, bool) or not isinstance(result_limit, int):
|
|
358
|
+
raise ValueError("result_limit must be an integer.")
|
|
359
|
+
if not 1 <= result_limit <= MAX_SNAPSHOT_RESULT_LIMIT:
|
|
360
|
+
raise ValueError(
|
|
361
|
+
f"result_limit must be between 1 and {MAX_SNAPSHOT_RESULT_LIMIT}."
|
|
362
|
+
)
|
|
363
|
+
return result_limit
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _validate_payload_limit(payload_limit_bytes: int) -> int:
|
|
367
|
+
if isinstance(payload_limit_bytes, bool) or not isinstance(
|
|
368
|
+
payload_limit_bytes, int
|
|
369
|
+
):
|
|
370
|
+
raise ValueError("payload_limit_bytes must be an integer.")
|
|
371
|
+
if not 0 <= payload_limit_bytes <= MAX_RENDERED_PAYLOAD_BYTES:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
"payload_limit_bytes must be between 0 and "
|
|
374
|
+
f"{MAX_RENDERED_PAYLOAD_BYTES}."
|
|
375
|
+
)
|
|
376
|
+
return payload_limit_bytes
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _validate_wait_seconds(wait_seconds: float) -> float:
|
|
380
|
+
value = float(wait_seconds)
|
|
381
|
+
if not math.isfinite(value) or not 0 <= value <= MAX_SNAPSHOT_WAIT_SECONDS:
|
|
382
|
+
raise ValueError(
|
|
383
|
+
"wait_seconds must be between 0 and "
|
|
384
|
+
f"{MAX_SNAPSHOT_WAIT_SECONDS}."
|
|
385
|
+
)
|
|
386
|
+
return value
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _age_seconds(captured_at: datetime, observed_at: datetime) -> float:
|
|
390
|
+
return max(
|
|
391
|
+
0.0,
|
|
392
|
+
(captured_at - BrokerSnapshotService._as_utc(observed_at)).total_seconds(),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _as_utc(value: datetime) -> datetime:
|
|
397
|
+
if value.tzinfo is None:
|
|
398
|
+
return value.replace(tzinfo=timezone.utc)
|
|
399
|
+
return value.astimezone(timezone.utc)
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _status_value(status: object) -> str:
|
|
403
|
+
return str(getattr(status, "value", status)).lower()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
|
|
10
|
+
from topicgate.infrastructure.database.database_context import DatabaseContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ControlOperationConflict(RuntimeError):
|
|
14
|
+
"""Raised when another TopicGate process owns the control lease."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ControlOperationService:
|
|
18
|
+
"""Coordinate state-changing desktop and MCP work across processes."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
database: DatabaseContext,
|
|
23
|
+
owner: str,
|
|
24
|
+
*,
|
|
25
|
+
lease_seconds: float = 30.0,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._database = database
|
|
28
|
+
self._owner = owner
|
|
29
|
+
self._lease_seconds = lease_seconds
|
|
30
|
+
self._token: ContextVar[str | None] = ContextVar(
|
|
31
|
+
"topicgate_control_lease_token",
|
|
32
|
+
default=None,
|
|
33
|
+
)
|
|
34
|
+
with self._database.session() as session:
|
|
35
|
+
self._seen_generation = int(
|
|
36
|
+
session.execute(
|
|
37
|
+
text(
|
|
38
|
+
"SELECT generation FROM control_operation_state WHERE id = 1"
|
|
39
|
+
)
|
|
40
|
+
).scalar_one()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def operation(self, name: str) -> Iterator[None]:
|
|
45
|
+
inherited_token = self._token.get()
|
|
46
|
+
if inherited_token is not None:
|
|
47
|
+
yield
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
token = str(uuid4())
|
|
51
|
+
self._acquire(name, token)
|
|
52
|
+
context_token = self._token.set(token)
|
|
53
|
+
stopped = threading.Event()
|
|
54
|
+
renewer = threading.Thread(
|
|
55
|
+
target=self._renew_until_stopped,
|
|
56
|
+
args=(name, token, stopped),
|
|
57
|
+
daemon=True,
|
|
58
|
+
name="topicgate-control-lease",
|
|
59
|
+
)
|
|
60
|
+
renewer.start()
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
stopped.set()
|
|
65
|
+
renewer.join(timeout=1.0)
|
|
66
|
+
self._release(token)
|
|
67
|
+
self._token.reset(context_token)
|
|
68
|
+
|
|
69
|
+
def _acquire(self, name: str, token: str) -> None:
|
|
70
|
+
now = time.time()
|
|
71
|
+
expires_at = now + self._lease_seconds
|
|
72
|
+
statement = text(
|
|
73
|
+
"INSERT INTO control_operation_lease "
|
|
74
|
+
"(id, owner, operation, token, expires_at) "
|
|
75
|
+
"VALUES (1, :owner, :operation, :token, :expires_at) "
|
|
76
|
+
"ON CONFLICT(id) DO UPDATE SET "
|
|
77
|
+
"owner = excluded.owner, operation = excluded.operation, "
|
|
78
|
+
"token = excluded.token, expires_at = excluded.expires_at "
|
|
79
|
+
"WHERE control_operation_lease.expires_at <= :now"
|
|
80
|
+
)
|
|
81
|
+
with self._database.transaction() as session:
|
|
82
|
+
generation = int(
|
|
83
|
+
session.execute(
|
|
84
|
+
text(
|
|
85
|
+
"SELECT generation FROM control_operation_state WHERE id = 1"
|
|
86
|
+
)
|
|
87
|
+
).scalar_one()
|
|
88
|
+
)
|
|
89
|
+
if generation != self._seen_generation:
|
|
90
|
+
raise ControlOperationConflict(
|
|
91
|
+
"TopicGate configuration changed in another desktop or MCP "
|
|
92
|
+
"process. Restart this process to reload the latest broker, "
|
|
93
|
+
"subscription, retention, and credential state before retrying."
|
|
94
|
+
)
|
|
95
|
+
result = session.execute(
|
|
96
|
+
statement,
|
|
97
|
+
{
|
|
98
|
+
"owner": self._owner,
|
|
99
|
+
"operation": name,
|
|
100
|
+
"token": token,
|
|
101
|
+
"expires_at": expires_at,
|
|
102
|
+
"now": now,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
if result.rowcount == 1:
|
|
106
|
+
return
|
|
107
|
+
conflict = session.execute(
|
|
108
|
+
text(
|
|
109
|
+
"SELECT owner, operation, expires_at "
|
|
110
|
+
"FROM control_operation_lease WHERE id = 1"
|
|
111
|
+
)
|
|
112
|
+
).one()
|
|
113
|
+
remaining = max(0.0, float(conflict.expires_at) - now)
|
|
114
|
+
raise ControlOperationConflict(
|
|
115
|
+
f"TopicGate {conflict.owner} is already running "
|
|
116
|
+
f"'{conflict.operation}'. Retry after it finishes "
|
|
117
|
+
f"(lease expires in at most {remaining:.0f} seconds)."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _renew_until_stopped(
|
|
121
|
+
self,
|
|
122
|
+
name: str,
|
|
123
|
+
token: str,
|
|
124
|
+
stopped: threading.Event,
|
|
125
|
+
) -> None:
|
|
126
|
+
interval = max(0.01, self._lease_seconds / 3)
|
|
127
|
+
while not stopped.wait(interval):
|
|
128
|
+
with self._database.transaction() as session:
|
|
129
|
+
session.execute(
|
|
130
|
+
text(
|
|
131
|
+
"UPDATE control_operation_lease "
|
|
132
|
+
"SET operation = :operation, expires_at = :expires_at "
|
|
133
|
+
"WHERE id = 1 AND token = :token"
|
|
134
|
+
),
|
|
135
|
+
{
|
|
136
|
+
"operation": name,
|
|
137
|
+
"expires_at": time.time() + self._lease_seconds,
|
|
138
|
+
"token": token,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _release(self, token: str) -> None:
|
|
143
|
+
with self._database.transaction() as session:
|
|
144
|
+
deleted = session.execute(
|
|
145
|
+
text(
|
|
146
|
+
"DELETE FROM control_operation_lease "
|
|
147
|
+
"WHERE id = 1 AND token = :token"
|
|
148
|
+
),
|
|
149
|
+
{"token": token},
|
|
150
|
+
)
|
|
151
|
+
if deleted.rowcount == 1:
|
|
152
|
+
session.execute(
|
|
153
|
+
text(
|
|
154
|
+
"UPDATE control_operation_state "
|
|
155
|
+
"SET generation = generation + 1 WHERE id = 1"
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
self._seen_generation += 1
|