topos-node 0.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.
- shared/__init__.py +59 -0
- shared/filtering.py +640 -0
- shared/schema_registry.py +229 -0
- topos/__init__.py +5 -0
- topos/__version__.py +6 -0
- topos/analytics/__init__.py +15 -0
- topos/analytics/duckdb_adapter.py +48 -0
- topos/analytics/messenger_communities.py +349 -0
- topos/analytics/messenger_graph.py +522 -0
- topos/analytics/messenger_labels.py +321 -0
- topos/analytics/profiles.py +22 -0
- topos/analytics/query_engine.py +64 -0
- topos/analytics/raw_queries.py +174 -0
- topos/api/__init__.py +1 -0
- topos/api/analytics.py +52 -0
- topos/api/app_registry.py +31 -0
- topos/api/backup.py +15 -0
- topos/api/compute_remote.py +175 -0
- topos/api/data_commit.py +158 -0
- topos/api/data_explorer_table_prefs.py +81 -0
- topos/api/db.py +10 -0
- topos/api/device.py +25 -0
- topos/api/enrichment.py +959 -0
- topos/api/filter_lab.py +195 -0
- topos/api/health.py +61 -0
- topos/api/ingestion_api.py +37 -0
- topos/api/ingestion_compat.py +21 -0
- topos/api/ingestion_sources.py +600 -0
- topos/api/llm.py +76 -0
- topos/api/local_mcp.py +46 -0
- topos/api/messenger_analytics.py +385 -0
- topos/api/query_api.py +13 -0
- topos/api/sanitization_ollama_config.py +64 -0
- topos/api/source_install.py +324 -0
- topos/api/sources.py +13 -0
- topos/api/sync.py +10 -0
- topos/api/ui_config.py +83 -0
- topos/api/uma_data.py +311 -0
- topos/api/usage.py +49 -0
- topos/api/user_identity.py +46 -0
- topos/app.py +239 -0
- topos/auth.py +17 -0
- topos/canonicalization/__init__.py +1 -0
- topos/canonicalization/mappers/__init__.py +22 -0
- topos/canonicalization/mappers/base.py +26 -0
- topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
- topos/canonicalization/mappers/grok_mapper.py +17 -0
- topos/canonicalization/mappers/messenger_mapper.py +58 -0
- topos/canonicalization/models.py +31 -0
- topos/canonicalization/resolver.py +23 -0
- topos/cli/__init__.py +1 -0
- topos/cli/__main__.py +6 -0
- topos/cli/commands.py +132 -0
- topos/config/__init__.py +1 -0
- topos/config/sanitization_ollama.py +189 -0
- topos/config/settings.py +310 -0
- topos/contacts/__init__.py +5 -0
- topos/contacts/identity.py +24 -0
- topos/control_plane_client.py +300 -0
- topos/core/__init__.py +1 -0
- topos/core/api_models.py +128 -0
- topos/core/connection_resilience.py +99 -0
- topos/core/device_helpers.py +8 -0
- topos/core/errors.py +13 -0
- topos/core/events.py +12 -0
- topos/core/handlers.py +5625 -0
- topos/core/logging.py +175 -0
- topos/core/metrics.py +21 -0
- topos/core/startup_banner.py +62 -0
- topos/core/state.py +682 -0
- topos/core/table_layers.py +45 -0
- topos/core/types.py +13 -0
- topos/data_explorer_table_prefs.py +150 -0
- topos/engine/__init__.py +29 -0
- topos/engine/backends/__init__.py +50 -0
- topos/engine/backends/base.py +21 -0
- topos/engine/backends/huggingface.py +151 -0
- topos/engine/backends/ollama.py +181 -0
- topos/engine/backends/stub.py +22 -0
- topos/engine/engine.py +165 -0
- topos/engine/intake.py +32 -0
- topos/engine/queue_manager.py +112 -0
- topos/engine/registration.py +126 -0
- topos/engine/result_formatter.py +38 -0
- topos/engine/router.py +19 -0
- topos/engine/scoped_token.py +82 -0
- topos/engine/tasks.py +154 -0
- topos/engine/transport.py +44 -0
- topos/engine/usage_guard.py +100 -0
- topos/engine/usage_observation.py +129 -0
- topos/engine/validator.py +23 -0
- topos/enrichment/__init__.py +1 -0
- topos/enrichment/derived_tables.py +214 -0
- topos/enrichment/jobs/__init__.py +30 -0
- topos/enrichment/jobs/base.py +54 -0
- topos/enrichment/jobs/canonical/__init__.py +1 -0
- topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
- topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
- topos/enrichment/jobs/canonical/entities_job.py +27 -0
- topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
- topos/enrichment/jobs/canonical/topics_job.py +27 -0
- topos/enrichment/jobs/raw/__init__.py +1 -0
- topos/enrichment/jobs/raw/attachments_job.py +12 -0
- topos/enrichment/jobs/raw/language_job.py +12 -0
- topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
- topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
- topos/enrichment/models/__init__.py +1 -0
- topos/enrichment/models/manager.py +8 -0
- topos/enrichment/models/registry.py +71 -0
- topos/enrichment/models/versioning.py +8 -0
- topos/enrichment/orchestrator.py +177 -0
- topos/enrichment/processor.py +17 -0
- topos/enrichment/progress_bar.py +122 -0
- topos/enrichment/website_classifier.py +31 -0
- topos/filter_lab/__init__.py +1 -0
- topos/filter_lab/bundles.py +300 -0
- topos/filter_lab/schema.py +86 -0
- topos/filter_lab/service.py +167 -0
- topos/filter_lab/store.py +374 -0
- topos/filter_lab/worker.py +250 -0
- topos/hosted_pool_lease.py +153 -0
- topos/ingestion/__init__.py +1 -0
- topos/ingestion/checkpoints/__init__.py +6 -0
- topos/ingestion/checkpoints/checkpoint_store.py +24 -0
- topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
- topos/ingestion/ingest_helpers.py +504 -0
- topos/ingestion/jobs.py +91 -0
- topos/ingestion/local_sync.py +823 -0
- topos/ingestion/log_preview.py +21 -0
- topos/ingestion/manager.py +1100 -0
- topos/ingestion/parser.py +174 -0
- topos/ingestion/parsers/__init__.py +32 -0
- topos/ingestion/parsers/base.py +24 -0
- topos/ingestion/parsers/browser_parser.py +171 -0
- topos/ingestion/parsers/calendar_parser.py +21 -0
- topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
- topos/ingestion/parsers/chatgpt_parser.py +67 -0
- topos/ingestion/parsers/grok_parser.py +21 -0
- topos/ingestion/parsers/messenger_parser.py +97 -0
- topos/ingestion/progress.py +54 -0
- topos/ingestion/sources/__init__.py +20 -0
- topos/ingestion/sources/base.py +39 -0
- topos/ingestion/sources/calendar.py +29 -0
- topos/ingestion/sources/chatgpt.py +29 -0
- topos/ingestion/sources/contact_importers.py +274 -0
- topos/ingestion/sources/grok.py +29 -0
- topos/ingestion/sources/imessage_reader.py +479 -0
- topos/ingestion/sources/signal_export_parser.py +132 -0
- topos/ingestion/sources/signal_reader.py +491 -0
- topos/ingestion/state_machine.py +70 -0
- topos/ingestion/triggers/__init__.py +1 -0
- topos/ingestion/triggers/file_trigger.py +36 -0
- topos/ingestion/triggers/sqlite_trigger.py +18 -0
- topos/ingestion/validation/__init__.py +1 -0
- topos/ingestion/validation/base.py +27 -0
- topos/ingestion/validation/schema_registry.py +111 -0
- topos/ingestion/validation/schema_validator.py +13 -0
- topos/lineage/__init__.py +1 -0
- topos/lineage/provenance.py +9 -0
- topos/lineage/tracker.py +9 -0
- topos/mcp_stdio_proxy.py +83 -0
- topos/observability/__init__.py +1 -0
- topos/observability/alerts.py +7 -0
- topos/observability/metrics.py +25 -0
- topos/observability/tracing.py +18 -0
- topos/openai_client.py +69 -0
- topos/projections/__init__.py +1 -0
- topos/projections/vector_index/__init__.py +1 -0
- topos/projections/vector_index/base.py +21 -0
- topos/projections/vector_index/builders.py +11 -0
- topos/projections/vector_index/health_checks.py +5 -0
- topos/rate_limit.py +43 -0
- topos/sanitization/__init__.py +16 -0
- topos/sanitization/ollama_transforms.py +276 -0
- topos/scope_resolution.py +89 -0
- topos/services/__init__.py +1 -0
- topos/services/container.py +46 -0
- topos/services/embeddings/__init__.py +1 -0
- topos/services/embeddings/base.py +7 -0
- topos/services/embeddings/local.py +9 -0
- topos/services/embeddings/remote.py +9 -0
- topos/services/interfaces.py +40 -0
- topos/services/llm/__init__.py +1 -0
- topos/services/llm/base.py +7 -0
- topos/services/llm/openai.py +126 -0
- topos/services/local.py +123 -0
- topos/services/postgres.py +385 -0
- topos/sources/__init__.py +6 -0
- topos/sources/definitions.py +114 -0
- topos/sources/install_service.py +836 -0
- topos/sources/registry.py +263 -0
- topos/sources/runtime_install.py +427 -0
- topos/storage/__init__.py +1 -0
- topos/storage/canonical/__init__.py +18 -0
- topos/storage/canonical/ai_chat/__init__.py +22 -0
- topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
- topos/storage/canonical/ai_chat/mapper.py +168 -0
- topos/storage/canonical/ai_chat/model.py +87 -0
- topos/storage/canonical/ai_chat/tables.py +179 -0
- topos/storage/canonical/canonical_store.py +24 -0
- topos/storage/canonical/conversations_tables.py +1020 -0
- topos/storage/canonical/mapping_store.py +30 -0
- topos/storage/canonical/postgres.py +10 -0
- topos/storage/db/__init__.py +1 -0
- topos/storage/db/client.py +8 -0
- topos/storage/db/migrations/__init__.py +1 -0
- topos/storage/db/migrations/stage9_column_renames.py +78 -0
- topos/storage/db/paths.py +122 -0
- topos/storage/db/postgres.py +240 -0
- topos/storage/db/schema.py +6 -0
- topos/storage/enrichment/__init__.py +1 -0
- topos/storage/enrichment/canonical_enrichment_store.py +7 -0
- topos/storage/enrichment/raw_enrichment_store.py +18 -0
- topos/storage/normalized/__init__.py +1 -0
- topos/storage/normalized/normalized_store.py +24 -0
- topos/storage/oplog/__init__.py +1 -0
- topos/storage/oplog/decision.py +6 -0
- topos/storage/oplog/oplog_store.py +17 -0
- topos/storage/oplog/postgres.py +10 -0
- topos/storage/projections/__init__.py +1 -0
- topos/storage/projections/index_ops_store.py +6 -0
- topos/storage/projections/vector_index_store.py +6 -0
- topos/storage/raw/__init__.py +1 -0
- topos/storage/raw/browser_flat_tables.py +303 -0
- topos/storage/raw/file_store.py +100 -0
- topos/storage/raw/raw_store.py +29 -0
- topos/storage/raw/raw_tables_manager.py +295 -0
- topos/storage/raw/sqlite_raw_store.py +17 -0
- topos/storage/security/encryption.py +21 -0
- topos/storage/signal_identity.py +71 -0
- topos/storage/source_settings.py +116 -0
- topos/storage/user_identity.py +69 -0
- topos/sync/__init__.py +5 -0
- topos/sync/client.py +272 -0
- topos/sync_handlers.py +70 -0
- topos/testing/__init__.py +1 -0
- topos/testing/lifespan.py +7 -0
- topos/uma_contact_enrichment.py +1032 -0
- topos/uma_filters.py +669 -0
- topos/uma_resource_id.py +24 -0
- topos/uma_rpt.py +69 -0
- topos/utils/base_object.py +61 -0
- topos/websocket_client.py +21 -0
- topos_node-0.1.0.dist-info/METADATA +199 -0
- topos_node-0.1.0.dist-info/RECORD +249 -0
- topos_node-0.1.0.dist-info/WHEEL +5 -0
- topos_node-0.1.0.dist-info/entry_points.txt +2 -0
- topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
- topos_node-0.1.0.dist-info/top_level.txt +2 -0
topos/sync/client.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Sync client for connecting to control plane sync relay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import ssl
|
|
10
|
+
from typing import Any, Callable, Dict, Optional
|
|
11
|
+
|
|
12
|
+
import certifi
|
|
13
|
+
from websockets.asyncio.client import connect
|
|
14
|
+
from websockets.exceptions import ConnectionClosed
|
|
15
|
+
|
|
16
|
+
from ..config.settings import settings
|
|
17
|
+
from ..core.connection_resilience import (
|
|
18
|
+
ConnectionSnapshot,
|
|
19
|
+
ConnectionState,
|
|
20
|
+
ExponentialBackoff,
|
|
21
|
+
FailureCategory,
|
|
22
|
+
ResilienceConfig,
|
|
23
|
+
classify_connection_error,
|
|
24
|
+
is_fatal_connection_category,
|
|
25
|
+
utc_now_iso,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("topos.sync")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncClient:
|
|
32
|
+
"""Client for syncing encrypted ops with the control plane relay."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
sync_url: str,
|
|
37
|
+
api_key: str,
|
|
38
|
+
user_id: str,
|
|
39
|
+
dataset_id: str,
|
|
40
|
+
on_op_received: Callable[[Dict[str, Any]], Any],
|
|
41
|
+
verify_ssl: bool = True,
|
|
42
|
+
):
|
|
43
|
+
self.sync_url = sync_url
|
|
44
|
+
self.api_key = api_key
|
|
45
|
+
self.user_id = user_id
|
|
46
|
+
self.dataset_id = dataset_id
|
|
47
|
+
self.on_op_received = on_op_received
|
|
48
|
+
self.verify_ssl = verify_ssl
|
|
49
|
+
|
|
50
|
+
self._ws = None
|
|
51
|
+
self._task: Optional[asyncio.Task] = None
|
|
52
|
+
self._stop = asyncio.Event()
|
|
53
|
+
self._connected = False
|
|
54
|
+
self._last_op_ts: Optional[str] = None
|
|
55
|
+
self._state: ConnectionState = "idle"
|
|
56
|
+
self._state_changed_at: str | None = utc_now_iso()
|
|
57
|
+
self._last_connected_at: str | None = None
|
|
58
|
+
self._last_disconnected_at: str | None = None
|
|
59
|
+
self._last_failure_category: FailureCategory = "none"
|
|
60
|
+
self._last_failure_reason: str = ""
|
|
61
|
+
self._attempt = 0
|
|
62
|
+
self._consecutive_failures = 0
|
|
63
|
+
self._ready = asyncio.Event()
|
|
64
|
+
self._backoff = ExponentialBackoff(
|
|
65
|
+
ResilienceConfig(
|
|
66
|
+
initial_backoff_s=max(0.1, float(settings.connection_retry_initial_seconds)),
|
|
67
|
+
max_backoff_s=max(1.0, float(settings.connection_retry_max_seconds)),
|
|
68
|
+
jitter_ratio=max(0.0, float(settings.connection_retry_jitter_ratio)),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _set_state(self, state: ConnectionState) -> None:
|
|
73
|
+
if self._state == state:
|
|
74
|
+
return
|
|
75
|
+
self._state = state
|
|
76
|
+
self._state_changed_at = utc_now_iso()
|
|
77
|
+
|
|
78
|
+
def get_connection_status(self) -> dict[str, Any]:
|
|
79
|
+
snapshot = ConnectionSnapshot(
|
|
80
|
+
state=self._state,
|
|
81
|
+
connected=self._connected,
|
|
82
|
+
attempt=self._attempt,
|
|
83
|
+
consecutive_failures=self._consecutive_failures,
|
|
84
|
+
last_failure_category=self._last_failure_category,
|
|
85
|
+
last_failure_reason=self._last_failure_reason,
|
|
86
|
+
last_state_change_at=self._state_changed_at,
|
|
87
|
+
last_connected_at=self._last_connected_at,
|
|
88
|
+
last_disconnected_at=self._last_disconnected_at,
|
|
89
|
+
)
|
|
90
|
+
return snapshot.to_dict()
|
|
91
|
+
|
|
92
|
+
def start(self) -> None:
|
|
93
|
+
if self._task and not self._task.done():
|
|
94
|
+
logger.warning("Sync client already started")
|
|
95
|
+
return
|
|
96
|
+
self._stop.clear()
|
|
97
|
+
self._task = asyncio.create_task(self._run())
|
|
98
|
+
logger.info("Sync client started")
|
|
99
|
+
|
|
100
|
+
async def wait_until_connected(self, timeout_s: float | None = None) -> bool:
|
|
101
|
+
timeout = float(timeout_s) if timeout_s is not None else float(settings.connection_readiness_timeout_seconds)
|
|
102
|
+
try:
|
|
103
|
+
await asyncio.wait_for(self._ready.wait(), timeout=max(0.1, timeout))
|
|
104
|
+
return True
|
|
105
|
+
except TimeoutError:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
async def stop(self) -> None:
|
|
109
|
+
self._set_state("stopping")
|
|
110
|
+
self._stop.set()
|
|
111
|
+
if self._ws:
|
|
112
|
+
await self._ws.close()
|
|
113
|
+
if self._task:
|
|
114
|
+
try:
|
|
115
|
+
await asyncio.wait_for(self._task, timeout=5.0)
|
|
116
|
+
except asyncio.TimeoutError:
|
|
117
|
+
self._task.cancel()
|
|
118
|
+
try:
|
|
119
|
+
await self._task
|
|
120
|
+
except asyncio.CancelledError:
|
|
121
|
+
pass
|
|
122
|
+
self._connected = False
|
|
123
|
+
self._ready.clear()
|
|
124
|
+
self._set_state("idle")
|
|
125
|
+
logger.info("Sync client stopped")
|
|
126
|
+
|
|
127
|
+
async def _run(self) -> None:
|
|
128
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
129
|
+
ssl_context = None
|
|
130
|
+
if self.sync_url.startswith("wss://"):
|
|
131
|
+
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
132
|
+
if not self.verify_ssl:
|
|
133
|
+
ssl_context.check_hostname = False
|
|
134
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
135
|
+
|
|
136
|
+
while not self._stop.is_set():
|
|
137
|
+
self._set_state("connecting")
|
|
138
|
+
self._attempt += 1
|
|
139
|
+
try:
|
|
140
|
+
async with connect(self.sync_url, additional_headers=headers, ssl=ssl_context) as ws:
|
|
141
|
+
self._ws = ws
|
|
142
|
+
self._connected = True
|
|
143
|
+
self._set_state("connected")
|
|
144
|
+
self._ready.set()
|
|
145
|
+
self._backoff.reset()
|
|
146
|
+
self._last_failure_category = "none"
|
|
147
|
+
self._last_failure_reason = ""
|
|
148
|
+
self._consecutive_failures = 0
|
|
149
|
+
self._last_connected_at = utc_now_iso()
|
|
150
|
+
logger.info("Sync client connected to relay")
|
|
151
|
+
|
|
152
|
+
await self._send_connect()
|
|
153
|
+
|
|
154
|
+
async for raw in ws:
|
|
155
|
+
if self._stop.is_set():
|
|
156
|
+
break
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(raw)
|
|
159
|
+
await self._handle_message(data)
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
logger.warning("Failed to handle sync message: %s", exc)
|
|
162
|
+
except ConnectionClosed as exc:
|
|
163
|
+
self._record_failure(exc)
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
self._record_failure(exc)
|
|
166
|
+
finally:
|
|
167
|
+
self._ws = None
|
|
168
|
+
self._connected = False
|
|
169
|
+
self._ready.clear()
|
|
170
|
+
if not self._stop.is_set() and self._state != "stopping":
|
|
171
|
+
self._last_disconnected_at = utc_now_iso()
|
|
172
|
+
if self._stop.is_set():
|
|
173
|
+
break
|
|
174
|
+
delay = self._backoff.next_delay()
|
|
175
|
+
self._set_state("degraded" if is_fatal_connection_category(self._last_failure_category) else "backing_off")
|
|
176
|
+
logger.warning(
|
|
177
|
+
"Sync reconnect scheduled state=%s attempt=%d failures=%d category=%s delay_s=%.2f reason=%s",
|
|
178
|
+
self._state,
|
|
179
|
+
self._attempt,
|
|
180
|
+
self._consecutive_failures,
|
|
181
|
+
self._last_failure_category,
|
|
182
|
+
delay,
|
|
183
|
+
self._last_failure_reason,
|
|
184
|
+
)
|
|
185
|
+
await self._wait_for_stop_or_timeout(delay)
|
|
186
|
+
self._set_state("idle")
|
|
187
|
+
|
|
188
|
+
def _record_failure(self, exc: BaseException) -> None:
|
|
189
|
+
category, reason = classify_connection_error(exc)
|
|
190
|
+
self._last_failure_category = category
|
|
191
|
+
self._last_failure_reason = reason
|
|
192
|
+
self._consecutive_failures += 1
|
|
193
|
+
if not self._stop.is_set():
|
|
194
|
+
logger.warning(
|
|
195
|
+
"Sync connection failed category=%s failures=%d reason=%s",
|
|
196
|
+
category,
|
|
197
|
+
self._consecutive_failures,
|
|
198
|
+
reason,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def _wait_for_stop_or_timeout(self, timeout_s: float) -> None:
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.wait_for(self._stop.wait(), timeout=timeout_s)
|
|
204
|
+
except TimeoutError:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
async def _send_connect(self) -> None:
|
|
208
|
+
message = {
|
|
209
|
+
"type": "sync_connect",
|
|
210
|
+
"user_id": self.user_id,
|
|
211
|
+
"dataset_id": self.dataset_id,
|
|
212
|
+
"last_op_ts": self._last_op_ts,
|
|
213
|
+
}
|
|
214
|
+
await self._ws.send(json.dumps(message))
|
|
215
|
+
logger.debug("Sent sync_connect for user: %s, dataset: %s", self.user_id, self.dataset_id)
|
|
216
|
+
|
|
217
|
+
async def _handle_message(self, data: Dict[str, Any]) -> None:
|
|
218
|
+
msg_type = data.get("type")
|
|
219
|
+
|
|
220
|
+
if msg_type == "sync_connected":
|
|
221
|
+
logger.info("Sync connected for dataset: %s", data.get("dataset_id"))
|
|
222
|
+
elif msg_type == "sync_op":
|
|
223
|
+
op = data.get("op")
|
|
224
|
+
if op:
|
|
225
|
+
result = self.on_op_received(op)
|
|
226
|
+
if asyncio.iscoroutine(result):
|
|
227
|
+
await result
|
|
228
|
+
self._last_op_ts = op.get("hlc_ts")
|
|
229
|
+
try:
|
|
230
|
+
await self._send_json_with_retry({"type": "sync_cursor", "op": op})
|
|
231
|
+
except Exception as exc: # noqa: BLE001
|
|
232
|
+
logger.debug("Failed to send sync_cursor: %s", exc)
|
|
233
|
+
elif msg_type == "sync_ack":
|
|
234
|
+
logger.debug("Op acknowledged: %s", data.get("op_id"))
|
|
235
|
+
elif msg_type == "error":
|
|
236
|
+
logger.error("Sync error: %s", data.get("error"))
|
|
237
|
+
|
|
238
|
+
async def _send_json_with_retry(self, payload: Dict[str, Any]) -> None:
|
|
239
|
+
attempts = max(1, int(settings.sync_cursor_retry_attempts))
|
|
240
|
+
delay = max(0.0, float(settings.sync_cursor_retry_delay_seconds))
|
|
241
|
+
last_exc: Exception | None = None
|
|
242
|
+
for attempt in range(1, attempts + 1):
|
|
243
|
+
if not self._ws:
|
|
244
|
+
break
|
|
245
|
+
try:
|
|
246
|
+
await self._ws.send(json.dumps(payload))
|
|
247
|
+
return
|
|
248
|
+
except Exception as exc: # noqa: BLE001
|
|
249
|
+
last_exc = exc
|
|
250
|
+
if attempt < attempts:
|
|
251
|
+
await asyncio.sleep(delay)
|
|
252
|
+
if last_exc:
|
|
253
|
+
raise last_exc
|
|
254
|
+
|
|
255
|
+
async def send_op(self, op: Dict[str, Any]) -> None:
|
|
256
|
+
if not self._connected or not self._ws:
|
|
257
|
+
logger.warning("Sync client not connected, cannot send op")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
op_copy = op.copy()
|
|
261
|
+
if "ciphertext" in op_copy and isinstance(op_copy["ciphertext"], bytes):
|
|
262
|
+
op_copy["ciphertext"] = base64.b64encode(op_copy["ciphertext"]).decode("utf-8")
|
|
263
|
+
|
|
264
|
+
message = {"type": "sync_op", "op": op_copy}
|
|
265
|
+
try:
|
|
266
|
+
await self._send_json_with_retry(message)
|
|
267
|
+
logger.debug("Sent op to relay: %s", op.get("op_id", "unknown")[:8])
|
|
268
|
+
except Exception as exc: # noqa: BLE001
|
|
269
|
+
logger.error("Failed to send op to relay: %s", exc)
|
|
270
|
+
|
|
271
|
+
def is_connected(self) -> bool:
|
|
272
|
+
return self._connected
|
topos/sync_handlers.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from .core import state
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("topos.sync_handlers")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def handle_sync_op(op: Dict[str, Any]) -> None:
|
|
14
|
+
if not state.oplog_manager or not state.projection_manager or not state.db_conn:
|
|
15
|
+
logger.error("Database not initialized, cannot handle sync op")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
op_id = op.get("op_id")
|
|
19
|
+
if not op_id:
|
|
20
|
+
logger.warning("Received sync op without op_id")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
existing = state.oplog_manager.get_op(op_id) if state.oplog_manager else None
|
|
24
|
+
if existing:
|
|
25
|
+
state.set_engine_config_value(state.db_conn, "last_sync_at", datetime.now(timezone.utc).isoformat())
|
|
26
|
+
state.set_engine_config_value(state.db_conn, "last_received_hlc_ts", op.get("hlc_ts", ""))
|
|
27
|
+
state.set_engine_config_value(state.db_conn, "last_received_op_id", op_id)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
ciphertext_b64 = op.get("ciphertext", "")
|
|
31
|
+
if isinstance(ciphertext_b64, str):
|
|
32
|
+
try:
|
|
33
|
+
ciphertext = base64.b64decode(ciphertext_b64)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
logger.error("Failed to decode ciphertext for op %s: %s", op_id[:8], exc)
|
|
36
|
+
return
|
|
37
|
+
else:
|
|
38
|
+
ciphertext = ciphertext_b64
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
state.db_conn.execute(
|
|
42
|
+
"""
|
|
43
|
+
INSERT OR IGNORE INTO oplog (
|
|
44
|
+
op_id, dataset_id, actor_id, hlc_ts,
|
|
45
|
+
protocol_version, op_type, payload_version, crypto_version, ciphertext
|
|
46
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
""",
|
|
48
|
+
(
|
|
49
|
+
op.get("op_id"),
|
|
50
|
+
op.get("dataset_id"),
|
|
51
|
+
op.get("actor_id"),
|
|
52
|
+
op.get("hlc_ts"),
|
|
53
|
+
op.get("protocol_version", 1),
|
|
54
|
+
op.get("op_type"),
|
|
55
|
+
op.get("payload_version", 1),
|
|
56
|
+
op.get("crypto_version", 1),
|
|
57
|
+
ciphertext,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
state.db_conn.commit()
|
|
61
|
+
|
|
62
|
+
decrypted_op = state.oplog_manager.get_op(op_id) if state.oplog_manager else None
|
|
63
|
+
if decrypted_op and state.projection_manager:
|
|
64
|
+
state.projection_manager.apply_op(decrypted_op)
|
|
65
|
+
state.set_engine_config_value(state.db_conn, "last_sync_at", datetime.now(timezone.utc).isoformat())
|
|
66
|
+
state.set_engine_config_value(state.db_conn, "last_received_hlc_ts", op.get("hlc_ts", ""))
|
|
67
|
+
state.set_engine_config_value(state.db_conn, "last_received_op_id", op_id)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
logger.error("Failed to apply sync op %s: %s", op_id[:8], exc)
|
|
70
|
+
state.db_conn.rollback()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Testing helpers for Topos."""
|