offline-sync-sdk 0.2.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.
- offline_sync_sdk-0.2.0.dist-info/METADATA +447 -0
- offline_sync_sdk-0.2.0.dist-info/RECORD +22 -0
- offline_sync_sdk-0.2.0.dist-info/WHEEL +4 -0
- offline_sync_sdk-0.2.0.dist-info/entry_points.txt +3 -0
- sync_sdk/__init__.py +39 -0
- sync_sdk/adapters/__init__.py +1 -0
- sync_sdk/adapters/direct.py +15 -0
- sync_sdk/adapters/http.py +71 -0
- sync_sdk/adapters/mongodb.py +473 -0
- sync_sdk/adapters/opentelemetry.py +231 -0
- sync_sdk/adapters/sqlalchemy.py +691 -0
- sync_sdk/adapters/sqlalchemy_config.py +145 -0
- sync_sdk/client.py +198 -0
- sync_sdk/gateway.py +163 -0
- sync_sdk/integrations/__init__.py +1 -0
- sync_sdk/integrations/fastapi.py +32 -0
- sync_sdk/models.py +41 -0
- sync_sdk/ports.py +94 -0
- sync_sdk/py.typed +1 -0
- sync_sdk/registry.py +44 -0
- sync_sdk/store.py +147 -0
- sync_sdk/telemetry.py +131 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""SQLAlchemy adapter for developer-owned tables and SDK-owned sync metadata.
|
|
2
|
+
|
|
3
|
+
All participating tables must use the same transactional database. A singleton
|
|
4
|
+
metadata row serializes gateway writes, including sequence allocation, so a pull
|
|
5
|
+
cursor can never skip a late-committing change. This prioritizes correctness over
|
|
6
|
+
write throughput. Schema initialization is an explicit application/setup action.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from pydantic import TypeAdapter, ValidationError
|
|
20
|
+
from sqlalchemy import (
|
|
21
|
+
JSON,
|
|
22
|
+
Column,
|
|
23
|
+
DateTime,
|
|
24
|
+
Integer,
|
|
25
|
+
MetaData,
|
|
26
|
+
String,
|
|
27
|
+
Table,
|
|
28
|
+
and_,
|
|
29
|
+
delete,
|
|
30
|
+
insert,
|
|
31
|
+
select,
|
|
32
|
+
text,
|
|
33
|
+
update,
|
|
34
|
+
)
|
|
35
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
36
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
37
|
+
|
|
38
|
+
from ..models import Change, ChangeBatch, Mutation, MutationResult
|
|
39
|
+
from ..ports import MutationRejected
|
|
40
|
+
from ..telemetry import PipelineTelemetry, safe_telemetry
|
|
41
|
+
|
|
42
|
+
sync_metadata = MetaData()
|
|
43
|
+
clock = Table(
|
|
44
|
+
"sync_clock",
|
|
45
|
+
sync_metadata,
|
|
46
|
+
Column("id", Integer, primary_key=True),
|
|
47
|
+
Column("sequence", Integer, nullable=False),
|
|
48
|
+
)
|
|
49
|
+
versions = Table(
|
|
50
|
+
"sync_versions",
|
|
51
|
+
sync_metadata,
|
|
52
|
+
Column("entity", String(191), primary_key=True),
|
|
53
|
+
Column("entity_id", String(191), primary_key=True),
|
|
54
|
+
Column("version", Integer, nullable=False),
|
|
55
|
+
)
|
|
56
|
+
processed = Table(
|
|
57
|
+
"sync_processed_mutations",
|
|
58
|
+
sync_metadata,
|
|
59
|
+
Column("mutation_id", String(36), primary_key=True),
|
|
60
|
+
Column("client_id", String(191), nullable=False),
|
|
61
|
+
Column("result", JSON, nullable=False),
|
|
62
|
+
Column("processed_at", DateTime(timezone=True), nullable=False),
|
|
63
|
+
)
|
|
64
|
+
changes = Table(
|
|
65
|
+
"sync_change_log",
|
|
66
|
+
sync_metadata,
|
|
67
|
+
Column("sequence", Integer, primary_key=True, autoincrement=False),
|
|
68
|
+
Column("entity", String(191), nullable=False),
|
|
69
|
+
Column("entity_id", String(191), nullable=False),
|
|
70
|
+
Column("operation", String(16), nullable=False),
|
|
71
|
+
Column("payload", JSON, nullable=False),
|
|
72
|
+
Column("version", Integer, nullable=False),
|
|
73
|
+
Column("created_at", DateTime(timezone=True), nullable=False),
|
|
74
|
+
)
|
|
75
|
+
capture_checkpoints = Table(
|
|
76
|
+
"sync_capture_checkpoints",
|
|
77
|
+
sync_metadata,
|
|
78
|
+
Column("entity", String(191), primary_key=True),
|
|
79
|
+
Column("scanned_at", DateTime(timezone=True), nullable=False),
|
|
80
|
+
)
|
|
81
|
+
captured_rows = Table(
|
|
82
|
+
"sync_captured_rows",
|
|
83
|
+
sync_metadata,
|
|
84
|
+
Column("entity", String(191), primary_key=True),
|
|
85
|
+
Column("entity_id", String(191), primary_key=True),
|
|
86
|
+
Column("payload", JSON, nullable=False),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def initialize_sync_metadata(engine: AsyncEngine) -> None:
|
|
91
|
+
"""Create only SDK tables and the lock row. Run once before serving traffic.
|
|
92
|
+
|
|
93
|
+
Production applications may include sync_metadata in their own migrations.
|
|
94
|
+
This function never creates or alters business tables.
|
|
95
|
+
"""
|
|
96
|
+
async with engine.begin() as connection:
|
|
97
|
+
await connection.run_sync(sync_metadata.create_all)
|
|
98
|
+
if (await connection.scalar(select(clock.c.id).where(clock.c.id == 1))) is None:
|
|
99
|
+
await connection.execute(insert(clock).values(id=1, sequence=0))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SQLAlchemyBinding(Protocol):
|
|
103
|
+
"""Custom entity mapping; MUST use this session and MUST NOT commit."""
|
|
104
|
+
|
|
105
|
+
async def write(
|
|
106
|
+
self, session: AsyncSession, mutation: Mutation, version: int
|
|
107
|
+
) -> None: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TableBinding:
|
|
111
|
+
"""Map explicitly selected payload fields to application-owned columns.
|
|
112
|
+
|
|
113
|
+
Scalar keys use canonical strings. Composite keys use a canonical JSON object
|
|
114
|
+
of primary-key column names and string values, generated by encode_key().
|
|
115
|
+
Complete mapped payloads are required; nonmapped columns remain untouched.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
table: Table,
|
|
121
|
+
*,
|
|
122
|
+
fields: Mapping[str, str],
|
|
123
|
+
parse_key: Callable[[str], Any] | None = None,
|
|
124
|
+
key_parsers: Mapping[str, Callable[[str], Any]] | None = None,
|
|
125
|
+
value_decoders: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
126
|
+
):
|
|
127
|
+
self.keys = list(table.primary_key.columns)
|
|
128
|
+
if not self.keys:
|
|
129
|
+
raise ValueError("A synced table needs a primary key")
|
|
130
|
+
if parse_key is not None and (len(self.keys) != 1 or key_parsers is not None):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"parse_key is for scalar keys; use key_parsers for composite keys"
|
|
133
|
+
)
|
|
134
|
+
self.table = table
|
|
135
|
+
self.fields = dict(fields)
|
|
136
|
+
names = {column.name for column in self.keys}
|
|
137
|
+
if key_parsers is not None and set(key_parsers) != names:
|
|
138
|
+
raise ValueError("key_parsers must cover exactly the primary-key columns")
|
|
139
|
+
self.key_parsers = (
|
|
140
|
+
dict(key_parsers)
|
|
141
|
+
if key_parsers is not None
|
|
142
|
+
else {
|
|
143
|
+
column.name: parse_key or column.type.python_type
|
|
144
|
+
for column in self.keys
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
if len(set(self.fields.values())) != len(self.fields):
|
|
148
|
+
raise ValueError("Payload fields must map to distinct columns")
|
|
149
|
+
if any(
|
|
150
|
+
column not in table.c or column in names for column in self.fields.values()
|
|
151
|
+
):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Mapped columns must exist and cannot include the primary key"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
overrides = dict(value_decoders or {})
|
|
157
|
+
if not set(overrides).issubset(self.fields):
|
|
158
|
+
raise ValueError("value_decoders must refer to registered payload fields")
|
|
159
|
+
self.value_decoders = {}
|
|
160
|
+
for field, column_name in self.fields.items():
|
|
161
|
+
column = table.c[column_name]
|
|
162
|
+
if field in overrides:
|
|
163
|
+
self.value_decoders[field] = overrides[field]
|
|
164
|
+
elif isinstance(column.type, JSON):
|
|
165
|
+
self.value_decoders[field] = lambda value: value
|
|
166
|
+
else:
|
|
167
|
+
self.value_decoders[field] = TypeAdapter(
|
|
168
|
+
column.type.python_type
|
|
169
|
+
).validate_python
|
|
170
|
+
|
|
171
|
+
def encode_key(self, value: Any) -> str:
|
|
172
|
+
"""Generate a stable wire ID from a scalar or a composite-key dictionary."""
|
|
173
|
+
if len(self.keys) == 1:
|
|
174
|
+
encoded = str(value)
|
|
175
|
+
else:
|
|
176
|
+
if not isinstance(value, Mapping) or set(value) != set(self.key_parsers):
|
|
177
|
+
raise ValueError("Supply exactly the composite-key columns")
|
|
178
|
+
encoded = json.dumps(
|
|
179
|
+
{name: str(value[name]) for name in self.key_parsers},
|
|
180
|
+
sort_keys=True,
|
|
181
|
+
separators=(",", ":"),
|
|
182
|
+
)
|
|
183
|
+
self._decode_key(encoded)
|
|
184
|
+
return encoded
|
|
185
|
+
|
|
186
|
+
def _decode_key(self, entity_id: str) -> dict[str, Any]:
|
|
187
|
+
try:
|
|
188
|
+
if len(self.keys) == 1:
|
|
189
|
+
raw = {self.keys[0].name: entity_id}
|
|
190
|
+
else:
|
|
191
|
+
raw = json.loads(entity_id)
|
|
192
|
+
if not isinstance(raw, dict) or set(raw) != set(self.key_parsers):
|
|
193
|
+
raise ValueError("Invalid composite key")
|
|
194
|
+
if json.dumps(raw, sort_keys=True, separators=(",", ":")) != entity_id:
|
|
195
|
+
raise ValueError("Noncanonical composite key")
|
|
196
|
+
values = {}
|
|
197
|
+
for name, parser in self.key_parsers.items():
|
|
198
|
+
if not isinstance(raw[name], str):
|
|
199
|
+
raise TypeError("Key parts must be strings")
|
|
200
|
+
value = parser(raw[name])
|
|
201
|
+
if str(value) != raw[name]:
|
|
202
|
+
raise ValueError("Noncanonical key part")
|
|
203
|
+
values[name] = value
|
|
204
|
+
if len(entity_id) > 191:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"Encoded key exceeds SQL metadata limit of 191 characters"
|
|
207
|
+
)
|
|
208
|
+
return values
|
|
209
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
210
|
+
raise MutationRejected(
|
|
211
|
+
"Invalid entity ID; use the binding's encode_key() for canonical keys"
|
|
212
|
+
) from exc
|
|
213
|
+
|
|
214
|
+
async def write(
|
|
215
|
+
self, session: AsyncSession, mutation: Mutation, version: int
|
|
216
|
+
) -> None:
|
|
217
|
+
keys = self._decode_key(mutation.entity_id)
|
|
218
|
+
predicate = and_(*(self.table.c[name] == value for name, value in keys.items()))
|
|
219
|
+
if mutation.operation == "delete":
|
|
220
|
+
await session.execute(delete(self.table).where(predicate))
|
|
221
|
+
return
|
|
222
|
+
if set(mutation.payload) != set(self.fields):
|
|
223
|
+
raise MutationRejected(
|
|
224
|
+
"Payload must contain exactly the registered table fields; configure an Entity schema"
|
|
225
|
+
)
|
|
226
|
+
values: dict[str, Any] = {}
|
|
227
|
+
try:
|
|
228
|
+
for field, value in mutation.payload.items():
|
|
229
|
+
column = self.table.c[self.fields[field]]
|
|
230
|
+
if value is None:
|
|
231
|
+
if not column.nullable:
|
|
232
|
+
raise ValueError("Null is not allowed")
|
|
233
|
+
values[column.name] = None
|
|
234
|
+
else:
|
|
235
|
+
values[column.name] = self.value_decoders[field](value)
|
|
236
|
+
except (ValueError, TypeError, OverflowError, ValidationError) as exc:
|
|
237
|
+
raise MutationRejected(
|
|
238
|
+
"Payload value is incompatible with the registered column"
|
|
239
|
+
) from exc
|
|
240
|
+
exists = await session.scalar(select(self.keys[0]).where(predicate))
|
|
241
|
+
if exists is None:
|
|
242
|
+
await session.execute(insert(self.table).values({**keys, **values}))
|
|
243
|
+
elif values:
|
|
244
|
+
await session.execute(update(self.table).where(predicate).values(values))
|
|
245
|
+
|
|
246
|
+
async def read_rows(
|
|
247
|
+
self,
|
|
248
|
+
session: AsyncSession,
|
|
249
|
+
normalize: Callable[[dict[str, Any]], dict[str, Any]],
|
|
250
|
+
) -> dict[str, dict[str, Any]]:
|
|
251
|
+
"""Read the sync-visible projection used by change capture."""
|
|
252
|
+
selected = [*self.keys]
|
|
253
|
+
selected.extend(self.table.c[name] for name in self.fields.values())
|
|
254
|
+
rows = (
|
|
255
|
+
(await session.execute(select(*selected).order_by(*self.keys)))
|
|
256
|
+
.mappings()
|
|
257
|
+
.all()
|
|
258
|
+
)
|
|
259
|
+
result: dict[str, dict[str, Any]] = {}
|
|
260
|
+
for row in rows:
|
|
261
|
+
if len(self.keys) == 1:
|
|
262
|
+
key: Any = row[self.keys[0].name]
|
|
263
|
+
else:
|
|
264
|
+
key = {column.name: row[column.name] for column in self.keys}
|
|
265
|
+
entity_id = self.encode_key(key)
|
|
266
|
+
payload = {
|
|
267
|
+
field: row[column_name] for field, column_name in self.fields.items()
|
|
268
|
+
}
|
|
269
|
+
result[entity_id] = normalize(payload)
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class CaptureReport:
|
|
275
|
+
"""Work completed by one database change-capture scan."""
|
|
276
|
+
|
|
277
|
+
scanned_rows: int = 0
|
|
278
|
+
captured_changes: int = 0
|
|
279
|
+
initialized_entities: int = 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class SQLAlchemyChangeCapture:
|
|
283
|
+
"""Poll registered SQL tables and append outside writes to the sync feed.
|
|
284
|
+
|
|
285
|
+
An entity's first scan stores a baseline without publishing its existing rows.
|
|
286
|
+
Later scans publish creates, updates, and deletes. The durable snapshot and feed
|
|
287
|
+
entries are committed together under the same clock lock as gateway writes.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
session_factory: Callable[[], AsyncSession],
|
|
293
|
+
bindings: Mapping[str, TableBinding],
|
|
294
|
+
normalizers: Mapping[str, Callable[[dict[str, Any]], dict[str, Any]]],
|
|
295
|
+
):
|
|
296
|
+
self.session_factory = session_factory
|
|
297
|
+
self.bindings = dict(bindings)
|
|
298
|
+
self.normalizers = dict(normalizers)
|
|
299
|
+
|
|
300
|
+
async def capture_once(self) -> CaptureReport:
|
|
301
|
+
scanned_rows = captured_count = initialized_count = 0
|
|
302
|
+
async with self.session_factory() as session, session.begin():
|
|
303
|
+
sequence = await _lock_clock(session)
|
|
304
|
+
transaction = _Transaction(session, self.bindings, sequence)
|
|
305
|
+
for entity, binding in self.bindings.items():
|
|
306
|
+
current = await binding.read_rows(session, self.normalizers[entity])
|
|
307
|
+
scanned_rows += len(current)
|
|
308
|
+
initialized = await session.scalar(
|
|
309
|
+
select(capture_checkpoints.c.entity).where(
|
|
310
|
+
capture_checkpoints.c.entity == entity
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
if initialized is None:
|
|
314
|
+
await session.execute(
|
|
315
|
+
delete(captured_rows).where(captured_rows.c.entity == entity)
|
|
316
|
+
)
|
|
317
|
+
await self._save_rows(session, entity, current)
|
|
318
|
+
await session.execute(
|
|
319
|
+
insert(capture_checkpoints).values(
|
|
320
|
+
entity=entity, scanned_at=datetime.now(UTC)
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
initialized_count += 1
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
previous_rows = (
|
|
327
|
+
(
|
|
328
|
+
await session.execute(
|
|
329
|
+
select(
|
|
330
|
+
captured_rows.c.entity_id,
|
|
331
|
+
captured_rows.c.payload,
|
|
332
|
+
).where(captured_rows.c.entity == entity)
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
.mappings()
|
|
336
|
+
.all()
|
|
337
|
+
)
|
|
338
|
+
previous = {row["entity_id"]: row["payload"] for row in previous_rows}
|
|
339
|
+
for entity_id in sorted(current.keys() | previous.keys()):
|
|
340
|
+
payload = current.get(entity_id)
|
|
341
|
+
old_payload = previous.get(entity_id)
|
|
342
|
+
if payload is None:
|
|
343
|
+
await transaction.append_captured_change(
|
|
344
|
+
entity, entity_id, "delete", {}
|
|
345
|
+
)
|
|
346
|
+
await session.execute(
|
|
347
|
+
delete(captured_rows).where(
|
|
348
|
+
captured_rows.c.entity == entity,
|
|
349
|
+
captured_rows.c.entity_id == entity_id,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
elif old_payload is None:
|
|
353
|
+
await transaction.append_captured_change(
|
|
354
|
+
entity, entity_id, "create", payload
|
|
355
|
+
)
|
|
356
|
+
await self._save_row(session, entity, entity_id, payload)
|
|
357
|
+
elif payload != old_payload:
|
|
358
|
+
await transaction.append_captured_change(
|
|
359
|
+
entity, entity_id, "update", payload
|
|
360
|
+
)
|
|
361
|
+
await session.execute(
|
|
362
|
+
update(captured_rows)
|
|
363
|
+
.where(
|
|
364
|
+
captured_rows.c.entity == entity,
|
|
365
|
+
captured_rows.c.entity_id == entity_id,
|
|
366
|
+
)
|
|
367
|
+
.values(payload=payload)
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
continue
|
|
371
|
+
captured_count += 1
|
|
372
|
+
await session.execute(
|
|
373
|
+
update(capture_checkpoints)
|
|
374
|
+
.where(capture_checkpoints.c.entity == entity)
|
|
375
|
+
.values(scanned_at=datetime.now(UTC))
|
|
376
|
+
)
|
|
377
|
+
return CaptureReport(scanned_rows, captured_count, initialized_count)
|
|
378
|
+
|
|
379
|
+
async def run(self, *, interval: float = 1.0) -> None:
|
|
380
|
+
"""Continuously capture changes until the task is cancelled."""
|
|
381
|
+
if interval <= 0:
|
|
382
|
+
raise ValueError("Capture interval must be greater than zero")
|
|
383
|
+
while True:
|
|
384
|
+
await self.capture_once()
|
|
385
|
+
await asyncio.sleep(interval)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
async def _save_row(
|
|
389
|
+
session: AsyncSession, entity: str, entity_id: str, payload: dict[str, Any]
|
|
390
|
+
) -> None:
|
|
391
|
+
await session.execute(
|
|
392
|
+
insert(captured_rows).values(
|
|
393
|
+
entity=entity, entity_id=entity_id, payload=payload
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
async def _save_rows(
|
|
399
|
+
cls,
|
|
400
|
+
session: AsyncSession,
|
|
401
|
+
entity: str,
|
|
402
|
+
rows: Mapping[str, dict[str, Any]],
|
|
403
|
+
) -> None:
|
|
404
|
+
for entity_id, payload in rows.items():
|
|
405
|
+
await cls._save_row(session, entity, entity_id, payload)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def _lock_clock(session: AsyncSession) -> int:
|
|
409
|
+
backend = session.get_bind().dialect.name
|
|
410
|
+
if backend == "sqlite":
|
|
411
|
+
await session.execute(text("BEGIN IMMEDIATE"))
|
|
412
|
+
sequence = await session.scalar(
|
|
413
|
+
select(clock.c.sequence).where(clock.c.id == 1).with_for_update()
|
|
414
|
+
)
|
|
415
|
+
if sequence is None:
|
|
416
|
+
raise RuntimeError("Sync metadata is not initialized")
|
|
417
|
+
return sequence
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class SQLAlchemyStore:
|
|
421
|
+
def __init__(
|
|
422
|
+
self,
|
|
423
|
+
session_factory: Callable[[], AsyncSession],
|
|
424
|
+
bindings: Mapping[str, SQLAlchemyBinding],
|
|
425
|
+
*,
|
|
426
|
+
telemetry: PipelineTelemetry | None = None,
|
|
427
|
+
):
|
|
428
|
+
self.session_factory, self.bindings = session_factory, dict(bindings)
|
|
429
|
+
self.telemetry = safe_telemetry(telemetry)
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _metric_attributes(
|
|
433
|
+
backend: str, *, operation: str, outcome: str
|
|
434
|
+
) -> dict[str, str]:
|
|
435
|
+
return {
|
|
436
|
+
"db.system.name": backend,
|
|
437
|
+
"sync.storage.operation": operation,
|
|
438
|
+
"sync.outcome": outcome,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
@asynccontextmanager
|
|
442
|
+
async def transaction(self):
|
|
443
|
+
started = time.perf_counter()
|
|
444
|
+
async with self.session_factory() as session:
|
|
445
|
+
backend = session.get_bind().dialect.name
|
|
446
|
+
span_attributes = {"db.system.name": backend}
|
|
447
|
+
outcome = "ok"
|
|
448
|
+
with self.telemetry.span(
|
|
449
|
+
"sync.storage.transaction", span_attributes
|
|
450
|
+
) as span:
|
|
451
|
+
try:
|
|
452
|
+
async with session.begin():
|
|
453
|
+
sequence = await _lock_clock(session)
|
|
454
|
+
yield _Transaction(session, self.bindings, sequence)
|
|
455
|
+
except BaseException:
|
|
456
|
+
outcome = "error"
|
|
457
|
+
self.telemetry.count(
|
|
458
|
+
"sync.storage.failures",
|
|
459
|
+
attributes=self._metric_attributes(
|
|
460
|
+
backend, operation="transaction", outcome=outcome
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
raise
|
|
464
|
+
finally:
|
|
465
|
+
attributes = self._metric_attributes(
|
|
466
|
+
backend, operation="transaction", outcome=outcome
|
|
467
|
+
)
|
|
468
|
+
self.telemetry.count(
|
|
469
|
+
"sync.storage.transactions", attributes=attributes
|
|
470
|
+
)
|
|
471
|
+
self.telemetry.record(
|
|
472
|
+
"sync.storage.transaction.duration",
|
|
473
|
+
(time.perf_counter() - started) * 1000,
|
|
474
|
+
attributes,
|
|
475
|
+
)
|
|
476
|
+
span.set_attribute("sync.outcome", outcome)
|
|
477
|
+
|
|
478
|
+
async def get_changes(self, cursor: int, limit: int) -> ChangeBatch:
|
|
479
|
+
started = time.perf_counter()
|
|
480
|
+
async with self.session_factory() as session:
|
|
481
|
+
backend = session.get_bind().dialect.name
|
|
482
|
+
outcome = "ok"
|
|
483
|
+
try:
|
|
484
|
+
rows = (
|
|
485
|
+
(
|
|
486
|
+
await session.execute(
|
|
487
|
+
select(changes)
|
|
488
|
+
.where(changes.c.sequence > cursor)
|
|
489
|
+
.order_by(changes.c.sequence)
|
|
490
|
+
.limit(limit + 1)
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
.mappings()
|
|
494
|
+
.all()
|
|
495
|
+
)
|
|
496
|
+
batch = [Change.model_validate(dict(row)) for row in rows[:limit]]
|
|
497
|
+
return ChangeBatch(
|
|
498
|
+
cursor=batch[-1].sequence if batch else cursor,
|
|
499
|
+
has_more=len(rows) > limit,
|
|
500
|
+
changes=batch,
|
|
501
|
+
)
|
|
502
|
+
except BaseException:
|
|
503
|
+
outcome = "error"
|
|
504
|
+
self.telemetry.count(
|
|
505
|
+
"sync.storage.failures",
|
|
506
|
+
attributes=self._metric_attributes(
|
|
507
|
+
backend, operation="pull", outcome=outcome
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
raise
|
|
511
|
+
finally:
|
|
512
|
+
self.telemetry.record(
|
|
513
|
+
"sync.storage.pull.duration",
|
|
514
|
+
(time.perf_counter() - started) * 1000,
|
|
515
|
+
self._metric_attributes(backend, operation="pull", outcome=outcome),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
async def health_check(self) -> bool:
|
|
519
|
+
started = time.perf_counter()
|
|
520
|
+
async with self.session_factory() as session:
|
|
521
|
+
backend = session.get_bind().dialect.name
|
|
522
|
+
outcome = "ok"
|
|
523
|
+
try:
|
|
524
|
+
healthy = (
|
|
525
|
+
await session.scalar(select(clock.c.id).where(clock.c.id == 1)) == 1
|
|
526
|
+
)
|
|
527
|
+
if not healthy:
|
|
528
|
+
outcome = "error"
|
|
529
|
+
return healthy
|
|
530
|
+
except (SQLAlchemyError, OSError):
|
|
531
|
+
outcome = "error"
|
|
532
|
+
return False
|
|
533
|
+
finally:
|
|
534
|
+
self.telemetry.record(
|
|
535
|
+
"sync.storage.health.duration",
|
|
536
|
+
(time.perf_counter() - started) * 1000,
|
|
537
|
+
self._metric_attributes(
|
|
538
|
+
backend, operation="health", outcome=outcome
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class _Transaction:
|
|
544
|
+
def __init__(
|
|
545
|
+
self,
|
|
546
|
+
session: AsyncSession,
|
|
547
|
+
bindings: Mapping[str, SQLAlchemyBinding],
|
|
548
|
+
sequence: int,
|
|
549
|
+
):
|
|
550
|
+
self.session, self.bindings, self.sequence = session, bindings, sequence
|
|
551
|
+
|
|
552
|
+
async def get_result(self, mutation_id: UUID) -> MutationResult | None:
|
|
553
|
+
result = await self.session.scalar(
|
|
554
|
+
select(processed.c.result).where(
|
|
555
|
+
processed.c.mutation_id == str(mutation_id)
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
return MutationResult.model_validate(result) if result is not None else None
|
|
559
|
+
|
|
560
|
+
async def get_version(self, entity: str, entity_id: str) -> int:
|
|
561
|
+
return (
|
|
562
|
+
await self.session.scalar(
|
|
563
|
+
select(versions.c.version).where(
|
|
564
|
+
versions.c.entity == entity, versions.c.entity_id == entity_id
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
or 0
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def write(self, mutation: Mutation, version: int) -> None:
|
|
571
|
+
binding = self.bindings.get(mutation.entity)
|
|
572
|
+
if binding is None:
|
|
573
|
+
raise ValueError(
|
|
574
|
+
f"No storage binding for registered entity: {mutation.entity}"
|
|
575
|
+
)
|
|
576
|
+
await binding.write(self.session, mutation, version)
|
|
577
|
+
predicate = (versions.c.entity == mutation.entity) & (
|
|
578
|
+
versions.c.entity_id == mutation.entity_id
|
|
579
|
+
)
|
|
580
|
+
existing = await self.session.scalar(
|
|
581
|
+
select(versions.c.version).where(predicate)
|
|
582
|
+
)
|
|
583
|
+
if existing is None:
|
|
584
|
+
await self.session.execute(
|
|
585
|
+
insert(versions).values(
|
|
586
|
+
entity=mutation.entity,
|
|
587
|
+
entity_id=mutation.entity_id,
|
|
588
|
+
version=version,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
await self.session.execute(
|
|
593
|
+
update(versions).where(predicate).values(version=version)
|
|
594
|
+
)
|
|
595
|
+
if isinstance(binding, TableBinding):
|
|
596
|
+
await self._sync_capture_snapshot(mutation)
|
|
597
|
+
|
|
598
|
+
async def _sync_capture_snapshot(self, mutation: Mutation) -> None:
|
|
599
|
+
initialized = await self.session.scalar(
|
|
600
|
+
select(capture_checkpoints.c.entity).where(
|
|
601
|
+
capture_checkpoints.c.entity == mutation.entity
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
if initialized is None:
|
|
605
|
+
return
|
|
606
|
+
predicate = (captured_rows.c.entity == mutation.entity) & (
|
|
607
|
+
captured_rows.c.entity_id == mutation.entity_id
|
|
608
|
+
)
|
|
609
|
+
existing = await self.session.scalar(
|
|
610
|
+
select(captured_rows.c.entity_id).where(predicate)
|
|
611
|
+
)
|
|
612
|
+
if mutation.operation == "delete":
|
|
613
|
+
if existing is not None:
|
|
614
|
+
await self.session.execute(delete(captured_rows).where(predicate))
|
|
615
|
+
elif existing is None:
|
|
616
|
+
await self.session.execute(
|
|
617
|
+
insert(captured_rows).values(
|
|
618
|
+
entity=mutation.entity,
|
|
619
|
+
entity_id=mutation.entity_id,
|
|
620
|
+
payload=mutation.payload,
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
await self.session.execute(
|
|
625
|
+
update(captured_rows).where(predicate).values(payload=mutation.payload)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
async def append_change(self, mutation: Mutation, version: int) -> None:
|
|
629
|
+
self.sequence += 1
|
|
630
|
+
await self.session.execute(
|
|
631
|
+
insert(changes).values(
|
|
632
|
+
sequence=self.sequence,
|
|
633
|
+
entity=mutation.entity,
|
|
634
|
+
entity_id=mutation.entity_id,
|
|
635
|
+
operation=mutation.operation,
|
|
636
|
+
payload=mutation.payload,
|
|
637
|
+
version=version,
|
|
638
|
+
created_at=datetime.now(UTC),
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
await self.session.execute(
|
|
642
|
+
update(clock).where(clock.c.id == 1).values(sequence=self.sequence)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
async def append_captured_change(
|
|
646
|
+
self,
|
|
647
|
+
entity: str,
|
|
648
|
+
entity_id: str,
|
|
649
|
+
operation: str,
|
|
650
|
+
payload: dict[str, Any],
|
|
651
|
+
) -> None:
|
|
652
|
+
version = await self.get_version(entity, entity_id) + 1
|
|
653
|
+
predicate = (versions.c.entity == entity) & (versions.c.entity_id == entity_id)
|
|
654
|
+
existing = await self.session.scalar(
|
|
655
|
+
select(versions.c.version).where(predicate)
|
|
656
|
+
)
|
|
657
|
+
if existing is None:
|
|
658
|
+
await self.session.execute(
|
|
659
|
+
insert(versions).values(
|
|
660
|
+
entity=entity, entity_id=entity_id, version=version
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
await self.session.execute(
|
|
665
|
+
update(versions).where(predicate).values(version=version)
|
|
666
|
+
)
|
|
667
|
+
self.sequence += 1
|
|
668
|
+
await self.session.execute(
|
|
669
|
+
insert(changes).values(
|
|
670
|
+
sequence=self.sequence,
|
|
671
|
+
entity=entity,
|
|
672
|
+
entity_id=entity_id,
|
|
673
|
+
operation=operation,
|
|
674
|
+
payload=payload,
|
|
675
|
+
version=version,
|
|
676
|
+
created_at=datetime.now(UTC),
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
await self.session.execute(
|
|
680
|
+
update(clock).where(clock.c.id == 1).values(sequence=self.sequence)
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
async def save_result(self, mutation: Mutation, result: MutationResult) -> None:
|
|
684
|
+
await self.session.execute(
|
|
685
|
+
insert(processed).values(
|
|
686
|
+
mutation_id=str(mutation.mutation_id),
|
|
687
|
+
client_id=mutation.client_id,
|
|
688
|
+
result=result.model_dump(mode="json"),
|
|
689
|
+
processed_at=datetime.now(UTC),
|
|
690
|
+
)
|
|
691
|
+
)
|