accordsync 0.3.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.
- accordsync/__init__.py +103 -0
- accordsync/client.py +572 -0
- accordsync/memory.py +65 -0
- accordsync/py.typed +0 -0
- accordsync/sqlite.py +111 -0
- accordsync/storage.py +78 -0
- accordsync/transport.py +160 -0
- accordsync-0.3.0.dist-info/METADATA +118 -0
- accordsync-0.3.0.dist-info/RECORD +11 -0
- accordsync-0.3.0.dist-info/WHEEL +4 -0
- accordsync-0.3.0.dist-info/licenses/LICENSE +202 -0
accordsync/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The Accord client for Python: local-first writes, background sync, conflicts and refusals."""
|
|
2
|
+
|
|
3
|
+
from accordsync_core import (
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
AccordError,
|
|
6
|
+
AddOp,
|
|
7
|
+
AssignOp,
|
|
8
|
+
IncOp,
|
|
9
|
+
JsonValue,
|
|
10
|
+
Op,
|
|
11
|
+
OpId,
|
|
12
|
+
RecordSnapshot,
|
|
13
|
+
RemoveOp,
|
|
14
|
+
Schema,
|
|
15
|
+
SetElement,
|
|
16
|
+
WireOp,
|
|
17
|
+
conflict,
|
|
18
|
+
counter,
|
|
19
|
+
define_schema,
|
|
20
|
+
lww,
|
|
21
|
+
set_,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .client import (
|
|
25
|
+
EVENTS,
|
|
26
|
+
AccordClient,
|
|
27
|
+
ConflictInfo,
|
|
28
|
+
ConflictValue,
|
|
29
|
+
Event,
|
|
30
|
+
Listener,
|
|
31
|
+
Refusal,
|
|
32
|
+
SyncStatus,
|
|
33
|
+
random_device_id,
|
|
34
|
+
)
|
|
35
|
+
from .memory import MemoryStorage
|
|
36
|
+
from .sqlite import SqliteStorage
|
|
37
|
+
from .storage import StorageAdapter, StorageSnapshot, StorageTx, StoredMeta
|
|
38
|
+
from .transport import (
|
|
39
|
+
ExitItem,
|
|
40
|
+
HttpError,
|
|
41
|
+
HttpTransport,
|
|
42
|
+
OpItem,
|
|
43
|
+
PullItem,
|
|
44
|
+
PullPage,
|
|
45
|
+
PullResult,
|
|
46
|
+
PushResult,
|
|
47
|
+
RefusedOp,
|
|
48
|
+
ResyncRequired,
|
|
49
|
+
SnapshotItem,
|
|
50
|
+
Transport,
|
|
51
|
+
pull_item_from_json,
|
|
52
|
+
pull_result_from_json,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"EVENTS",
|
|
57
|
+
"PROTOCOL_VERSION",
|
|
58
|
+
"AccordClient",
|
|
59
|
+
"AccordError",
|
|
60
|
+
"AddOp",
|
|
61
|
+
"AssignOp",
|
|
62
|
+
"ConflictInfo",
|
|
63
|
+
"ConflictValue",
|
|
64
|
+
"Event",
|
|
65
|
+
"ExitItem",
|
|
66
|
+
"HttpError",
|
|
67
|
+
"HttpTransport",
|
|
68
|
+
"IncOp",
|
|
69
|
+
"JsonValue",
|
|
70
|
+
"Listener",
|
|
71
|
+
"MemoryStorage",
|
|
72
|
+
"Op",
|
|
73
|
+
"OpId",
|
|
74
|
+
"OpItem",
|
|
75
|
+
"PullItem",
|
|
76
|
+
"PullPage",
|
|
77
|
+
"PullResult",
|
|
78
|
+
"PushResult",
|
|
79
|
+
"RecordSnapshot",
|
|
80
|
+
"Refusal",
|
|
81
|
+
"RefusedOp",
|
|
82
|
+
"RemoveOp",
|
|
83
|
+
"ResyncRequired",
|
|
84
|
+
"Schema",
|
|
85
|
+
"SetElement",
|
|
86
|
+
"SnapshotItem",
|
|
87
|
+
"SqliteStorage",
|
|
88
|
+
"StorageAdapter",
|
|
89
|
+
"StorageSnapshot",
|
|
90
|
+
"StorageTx",
|
|
91
|
+
"StoredMeta",
|
|
92
|
+
"SyncStatus",
|
|
93
|
+
"Transport",
|
|
94
|
+
"WireOp",
|
|
95
|
+
"conflict",
|
|
96
|
+
"counter",
|
|
97
|
+
"define_schema",
|
|
98
|
+
"lww",
|
|
99
|
+
"pull_item_from_json",
|
|
100
|
+
"pull_result_from_json",
|
|
101
|
+
"random_device_id",
|
|
102
|
+
"set_",
|
|
103
|
+
]
|
accordsync/client.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""The Accord client: a device that writes locally first and syncs with a server.
|
|
2
|
+
|
|
3
|
+
Port of `@accordsync/client` (client.ts), following the Dart port. Synchronous (ADR-Y01): writes
|
|
4
|
+
and `sync()` run on the caller's thread; `start()` syncs on a daemon thread. See ADR-Y05 for the
|
|
5
|
+
threading rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import random as _random
|
|
13
|
+
import secrets
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from concurrent.futures import Future
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal, TypeAlias, TypeVar
|
|
20
|
+
|
|
21
|
+
from accordsync_core import (
|
|
22
|
+
MAX_SAFE_INTEGER,
|
|
23
|
+
AddOp,
|
|
24
|
+
AssignOp,
|
|
25
|
+
Hlc,
|
|
26
|
+
IncOp,
|
|
27
|
+
JsonValue,
|
|
28
|
+
LocalWriter,
|
|
29
|
+
Op,
|
|
30
|
+
OpId,
|
|
31
|
+
RecordSnapshot,
|
|
32
|
+
RemoveOp,
|
|
33
|
+
Schema,
|
|
34
|
+
SetElement,
|
|
35
|
+
decode_op,
|
|
36
|
+
encode_op,
|
|
37
|
+
parse_op_id,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from .storage import StorageAdapter, StorageTx, StoredMeta
|
|
41
|
+
from .transport import ExitItem, OpItem, PullItem, PullPage, SnapshotItem, Transport
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("accordsync")
|
|
44
|
+
|
|
45
|
+
Event: TypeAlias = Literal["change", "refused", "synced", "resync", "error"]
|
|
46
|
+
EVENTS: tuple[Event, ...] = ("change", "refused", "synced", "resync", "error")
|
|
47
|
+
Listener: TypeAlias = Callable[[Any], None]
|
|
48
|
+
|
|
49
|
+
_O = TypeVar("_O", AssignOp, IncOp, AddOp, RemoveOp)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class Refusal:
|
|
54
|
+
"""A local write the server refused. It has already been rolled back on this device."""
|
|
55
|
+
|
|
56
|
+
op_id: OpId
|
|
57
|
+
record: str
|
|
58
|
+
field: str
|
|
59
|
+
reason: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ConflictValue:
|
|
64
|
+
"""One of the values a conflicted field holds, and the op that wrote it."""
|
|
65
|
+
|
|
66
|
+
value: JsonValue
|
|
67
|
+
op_id: OpId
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class ConflictInfo:
|
|
72
|
+
"""A `conflict()` field holding more than one value. `values` are sorted by op id."""
|
|
73
|
+
|
|
74
|
+
record: str
|
|
75
|
+
field: str
|
|
76
|
+
values: tuple[ConflictValue, ...]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class SyncStatus:
|
|
81
|
+
pending: int
|
|
82
|
+
cursor: int
|
|
83
|
+
last_sync_at: int | None
|
|
84
|
+
last_error: BaseException | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def random_device_id() -> str:
|
|
88
|
+
"""A random device id from a secure source. Device ids must never collide."""
|
|
89
|
+
return "d" + secrets.token_hex(16)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _now_ms() -> int:
|
|
93
|
+
return time.time_ns() // 1_000_000
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AccordClient:
|
|
97
|
+
"""An Accord device. Writes apply locally at once and are saved to storage; sync pushes them
|
|
98
|
+
and pulls everyone else's, in the background (`start()`) or on demand (`sync()`).
|
|
99
|
+
|
|
100
|
+
Events (`on(event, listener)`), with the listener's argument:
|
|
101
|
+
|
|
102
|
+
- ``"change"``: ``list[str]``, records whose local state changed;
|
|
103
|
+
- ``"refused"``: `Refusal`, a local write the server refused, already rolled back;
|
|
104
|
+
- ``"synced"``: ``int``, the cursor after a full round;
|
|
105
|
+
- ``"resync"``: ``None``, the server asked for a resync and local data was reloaded;
|
|
106
|
+
- ``"error"``: the exception of a failed background round (retried with backoff).
|
|
107
|
+
|
|
108
|
+
Listeners run on the thread that caused the event, without the client's lock held. An
|
|
109
|
+
exception in a listener is logged and ignored.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
schema: Schema,
|
|
116
|
+
storage: StorageAdapter,
|
|
117
|
+
transport: Transport,
|
|
118
|
+
device_id: str,
|
|
119
|
+
meta: StoredMeta | None,
|
|
120
|
+
now: Callable[[], int],
|
|
121
|
+
random: Callable[[], float],
|
|
122
|
+
push_batch: int,
|
|
123
|
+
pull_limit: int,
|
|
124
|
+
sync_interval: float,
|
|
125
|
+
min_backoff: float,
|
|
126
|
+
max_backoff: float,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Use `AccordClient.open`."""
|
|
129
|
+
self.device_id = device_id
|
|
130
|
+
self._schema = schema
|
|
131
|
+
self._storage = storage
|
|
132
|
+
self._transport = transport
|
|
133
|
+
self._now = now
|
|
134
|
+
self._random = random
|
|
135
|
+
self._push_batch = push_batch
|
|
136
|
+
self._pull_limit = pull_limit
|
|
137
|
+
self._sync_interval = sync_interval
|
|
138
|
+
self._min_backoff = min_backoff
|
|
139
|
+
self._max_backoff = max_backoff
|
|
140
|
+
|
|
141
|
+
# All state below is guarded by `_lock`, never held during network I/O.
|
|
142
|
+
self._lock = threading.RLock()
|
|
143
|
+
self._cursor = meta.cursor if meta else 0
|
|
144
|
+
self._writer = self._new_writer((Hlc.decode(meta.hlc), meta.seq) if meta else None)
|
|
145
|
+
self._outbox: dict[OpId, Op] = {} # unacknowledged local ops, in write order
|
|
146
|
+
self._last_sync_at: int | None = None
|
|
147
|
+
self._last_error: BaseException | None = None
|
|
148
|
+
self._failures = 0
|
|
149
|
+
|
|
150
|
+
# One sync round at a time; concurrent callers share it.
|
|
151
|
+
self._round_guard = threading.Lock()
|
|
152
|
+
self._round: Future[None] | None = None
|
|
153
|
+
self._round_thread: int | None = None
|
|
154
|
+
|
|
155
|
+
# Background sync.
|
|
156
|
+
self._cond = threading.Condition()
|
|
157
|
+
self._running = False
|
|
158
|
+
self._generation = 0
|
|
159
|
+
self._next_at = 0.0
|
|
160
|
+
self._thread: threading.Thread | None = None
|
|
161
|
+
|
|
162
|
+
self._listeners: dict[str, list[Listener]] = {e: [] for e in EVENTS}
|
|
163
|
+
self._listeners_lock = threading.Lock()
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def open(
|
|
167
|
+
cls,
|
|
168
|
+
*,
|
|
169
|
+
schema: Schema,
|
|
170
|
+
storage: StorageAdapter,
|
|
171
|
+
transport: Transport,
|
|
172
|
+
device_id: str | None = None,
|
|
173
|
+
now: Callable[[], int] | None = None,
|
|
174
|
+
random: Callable[[], float] | None = None,
|
|
175
|
+
push_batch: int = 200,
|
|
176
|
+
pull_limit: int = 500,
|
|
177
|
+
sync_interval: float = 30.0,
|
|
178
|
+
min_backoff: float = 1.0,
|
|
179
|
+
max_backoff: float = 60.0,
|
|
180
|
+
) -> AccordClient:
|
|
181
|
+
"""Opens the device: loads its stored ops, outbox and cursor.
|
|
182
|
+
|
|
183
|
+
`device_id` is used only the first time; afterwards the stored id is kept. Generated when
|
|
184
|
+
absent. `now` returns physical time in ms. `push_batch` is the ops per push request and
|
|
185
|
+
`pull_limit` the items per pull page. Background sync pauses `sync_interval` seconds
|
|
186
|
+
between successful rounds and backs off between `min_backoff` and `max_backoff` seconds on
|
|
187
|
+
errors (with jitter from `random`).
|
|
188
|
+
"""
|
|
189
|
+
snap = storage.load()
|
|
190
|
+
meta = snap.meta
|
|
191
|
+
did = meta.device_id if meta else (device_id or random_device_id())
|
|
192
|
+
client = cls(
|
|
193
|
+
schema=schema,
|
|
194
|
+
storage=storage,
|
|
195
|
+
transport=transport,
|
|
196
|
+
device_id=did,
|
|
197
|
+
meta=meta,
|
|
198
|
+
now=now or _now_ms,
|
|
199
|
+
random=random or _random.random,
|
|
200
|
+
push_batch=push_batch,
|
|
201
|
+
pull_limit=pull_limit,
|
|
202
|
+
sync_interval=sync_interval,
|
|
203
|
+
min_backoff=min_backoff,
|
|
204
|
+
max_backoff=max_backoff,
|
|
205
|
+
)
|
|
206
|
+
for base in snap.snapshots:
|
|
207
|
+
client._writer.replica.load_snapshot(base)
|
|
208
|
+
by_id: dict[OpId, Op] = {}
|
|
209
|
+
for raw in snap.ops:
|
|
210
|
+
op = decode_op(raw)
|
|
211
|
+
by_id[op.op_id] = op
|
|
212
|
+
client._writer.receive(op)
|
|
213
|
+
for oid in sorted(snap.outbox, key=lambda i: parse_op_id(i).seq):
|
|
214
|
+
found = by_id.get(oid)
|
|
215
|
+
if found is not None:
|
|
216
|
+
client._outbox[oid] = found
|
|
217
|
+
if meta is None:
|
|
218
|
+
client._persist(StorageTx())
|
|
219
|
+
return client
|
|
220
|
+
|
|
221
|
+
# ── events ────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def on(self, event: Event, listener: Listener) -> Callable[[], None]:
|
|
224
|
+
"""Subscribes to an event; returns a function that unsubscribes."""
|
|
225
|
+
if event not in self._listeners:
|
|
226
|
+
raise ValueError(f"unknown event {event!r}; expected one of {EVENTS}")
|
|
227
|
+
with self._listeners_lock:
|
|
228
|
+
self._listeners[event].append(listener)
|
|
229
|
+
|
|
230
|
+
def off() -> None:
|
|
231
|
+
with self._listeners_lock:
|
|
232
|
+
if listener in self._listeners[event]:
|
|
233
|
+
self._listeners[event].remove(listener)
|
|
234
|
+
|
|
235
|
+
return off
|
|
236
|
+
|
|
237
|
+
def _emit(self, event: Event, payload: Any) -> None:
|
|
238
|
+
with self._listeners_lock:
|
|
239
|
+
listeners = list(self._listeners[event])
|
|
240
|
+
for fn in listeners:
|
|
241
|
+
try:
|
|
242
|
+
fn(payload)
|
|
243
|
+
except Exception:
|
|
244
|
+
log.exception('accord: a "%s" listener raised', event)
|
|
245
|
+
|
|
246
|
+
# ── reading ───────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def read(self, record: str) -> dict[str, object] | None:
|
|
249
|
+
"""A record's fields, or None if this device has never seen it. Fields with no value are
|
|
250
|
+
left out."""
|
|
251
|
+
with self._lock:
|
|
252
|
+
return self._writer.replica.read(record)
|
|
253
|
+
|
|
254
|
+
def records(self, type: str | None = None) -> list[str]:
|
|
255
|
+
with self._lock:
|
|
256
|
+
everything = self._writer.replica.records()
|
|
257
|
+
return everything if type is None else [r for r in everything if r.startswith(f"{type}:")]
|
|
258
|
+
|
|
259
|
+
def conflicts(self) -> list[ConflictInfo]:
|
|
260
|
+
"""Every `conflict()` field holding more than one value, with the values."""
|
|
261
|
+
with self._lock:
|
|
262
|
+
out: list[ConflictInfo] = []
|
|
263
|
+
for ref in self._writer.replica.conflicts():
|
|
264
|
+
fields = self._writer.replica.read(ref.record) or {}
|
|
265
|
+
shown: Any = fields[ref.field]
|
|
266
|
+
values = tuple(ConflictValue(v["value"], v["opId"]) for v in shown["conflicted"])
|
|
267
|
+
out.append(ConflictInfo(ref.record, ref.field, values))
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
def status(self) -> SyncStatus:
|
|
271
|
+
with self._lock:
|
|
272
|
+
return SyncStatus(
|
|
273
|
+
pending=len(self._outbox),
|
|
274
|
+
cursor=self._cursor,
|
|
275
|
+
last_sync_at=self._last_sync_at,
|
|
276
|
+
last_error=self._last_error,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# ── writing (local-first) ─────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def assign(self, record: str, field: str, value: JsonValue) -> AssignOp:
|
|
282
|
+
"""Sets a `lww` or `conflict` field. Returns once the write is saved on this device."""
|
|
283
|
+
return self._write(lambda w: w.assign(record, field, value))
|
|
284
|
+
|
|
285
|
+
def resolve(self, record: str, field: str, value: JsonValue) -> AssignOp:
|
|
286
|
+
"""Resolves a conflicted field: writes `value`, superseding every value currently shown."""
|
|
287
|
+
return self.assign(record, field, value)
|
|
288
|
+
|
|
289
|
+
def inc(self, record: str, field: str, by: int) -> IncOp:
|
|
290
|
+
return self._write(lambda w: w.inc(record, field, by))
|
|
291
|
+
|
|
292
|
+
def add(self, record: str, field: str, element: SetElement) -> AddOp:
|
|
293
|
+
return self._write(lambda w: w.add(record, field, element))
|
|
294
|
+
|
|
295
|
+
def remove(self, record: str, field: str, element: SetElement) -> RemoveOp:
|
|
296
|
+
return self._write(lambda w: w.remove(record, field, element))
|
|
297
|
+
|
|
298
|
+
def _write(self, make: Callable[[LocalWriter], _O]) -> _O:
|
|
299
|
+
error: BaseException | None = None
|
|
300
|
+
with self._lock:
|
|
301
|
+
op = make(self._writer)
|
|
302
|
+
self._outbox[op.op_id] = op
|
|
303
|
+
try:
|
|
304
|
+
self._persist(StorageTx(put_ops=[encode_op(op)], outbox_add=[op.op_id]))
|
|
305
|
+
except BaseException as e: # reported after the change event, like client.ts
|
|
306
|
+
error = e
|
|
307
|
+
self._emit("change", [op.record])
|
|
308
|
+
if error is not None:
|
|
309
|
+
raise error
|
|
310
|
+
self._soon()
|
|
311
|
+
return op
|
|
312
|
+
|
|
313
|
+
# ── sync ──────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def sync(self) -> None:
|
|
316
|
+
"""One full round: push the outbox, then pull every page. A call made while a round is
|
|
317
|
+
running waits for that round and shares its outcome."""
|
|
318
|
+
with self._round_guard:
|
|
319
|
+
running = self._round
|
|
320
|
+
if running is None:
|
|
321
|
+
mine: Future[None] = Future()
|
|
322
|
+
self._round = mine
|
|
323
|
+
self._round_thread = threading.get_ident()
|
|
324
|
+
if running is not None:
|
|
325
|
+
if self._round_thread == threading.get_ident():
|
|
326
|
+
raise RuntimeError("sync() called from inside its own round (from a listener?)")
|
|
327
|
+
running.result()
|
|
328
|
+
return
|
|
329
|
+
try:
|
|
330
|
+
self._run_round()
|
|
331
|
+
except BaseException as e:
|
|
332
|
+
self._finish_round(mine, e)
|
|
333
|
+
raise
|
|
334
|
+
self._finish_round(mine, None)
|
|
335
|
+
|
|
336
|
+
def _finish_round(self, fut: Future[None], error: BaseException | None) -> None:
|
|
337
|
+
with self._round_guard:
|
|
338
|
+
self._round = None
|
|
339
|
+
self._round_thread = None
|
|
340
|
+
if error is None:
|
|
341
|
+
fut.set_result(None)
|
|
342
|
+
else:
|
|
343
|
+
fut.set_exception(error)
|
|
344
|
+
|
|
345
|
+
def start(self) -> None:
|
|
346
|
+
"""Syncs in the background, on a daemon thread: soon after each write, every
|
|
347
|
+
`sync_interval`, and with backoff on errors."""
|
|
348
|
+
with self._cond:
|
|
349
|
+
if self._running:
|
|
350
|
+
return
|
|
351
|
+
self._running = True
|
|
352
|
+
self._generation += 1
|
|
353
|
+
self._next_at = time.monotonic()
|
|
354
|
+
self._thread = threading.Thread(
|
|
355
|
+
target=self._loop, args=(self._generation,), name="accord-sync", daemon=True
|
|
356
|
+
)
|
|
357
|
+
self._thread.start()
|
|
358
|
+
|
|
359
|
+
def stop(self) -> None:
|
|
360
|
+
"""Stops background sync. A round in flight finishes."""
|
|
361
|
+
with self._cond:
|
|
362
|
+
self._running = False
|
|
363
|
+
self._cond.notify_all()
|
|
364
|
+
|
|
365
|
+
def flush(self) -> None:
|
|
366
|
+
"""Waits for a local save in progress. Saves are synchronous, so this returns at once
|
|
367
|
+
unless another thread is writing."""
|
|
368
|
+
with self._lock:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
def close(self) -> None:
|
|
372
|
+
"""Stops background sync, waits for a round in flight, and closes the storage."""
|
|
373
|
+
self.stop()
|
|
374
|
+
with self._round_guard:
|
|
375
|
+
running, owner = self._round, self._round_thread
|
|
376
|
+
if running is not None and owner != threading.get_ident():
|
|
377
|
+
# The round's error, if any, was reported to its caller.
|
|
378
|
+
with contextlib.suppress(BaseException):
|
|
379
|
+
running.result()
|
|
380
|
+
thread = self._thread
|
|
381
|
+
if thread is not None and thread is not threading.current_thread():
|
|
382
|
+
thread.join()
|
|
383
|
+
self.flush()
|
|
384
|
+
self._storage.close()
|
|
385
|
+
|
|
386
|
+
def _run_round(self) -> None:
|
|
387
|
+
self._push_all()
|
|
388
|
+
while True:
|
|
389
|
+
with self._lock:
|
|
390
|
+
cursor = self._cursor
|
|
391
|
+
page = self._transport.pull(self.device_id, cursor, self._pull_limit)
|
|
392
|
+
if not isinstance(page, PullPage):
|
|
393
|
+
self._push_all()
|
|
394
|
+
self._resync()
|
|
395
|
+
continue
|
|
396
|
+
with self._lock:
|
|
397
|
+
# The server's count of this device's ops: never reuse an op id, even after lost
|
|
398
|
+
# storage.
|
|
399
|
+
if page.device_seq is not None:
|
|
400
|
+
self._writer.advance_seq(page.device_seq)
|
|
401
|
+
changed = self._apply_page(page.items, page.cursor)
|
|
402
|
+
if changed:
|
|
403
|
+
self._emit("change", changed)
|
|
404
|
+
if not page.has_more:
|
|
405
|
+
break
|
|
406
|
+
with self._lock:
|
|
407
|
+
self._last_sync_at = self._now()
|
|
408
|
+
self._last_error = None
|
|
409
|
+
self._failures = 0
|
|
410
|
+
cursor = self._cursor
|
|
411
|
+
self._emit("synced", cursor)
|
|
412
|
+
|
|
413
|
+
def _push_all(self) -> None:
|
|
414
|
+
while True:
|
|
415
|
+
with self._lock:
|
|
416
|
+
if not self._outbox:
|
|
417
|
+
return
|
|
418
|
+
batch = [encode_op(o) for o in list(self._outbox.values())[: self._push_batch]]
|
|
419
|
+
res = self._transport.push(self.device_id, batch)
|
|
420
|
+
if not res.acked and not res.refused:
|
|
421
|
+
raise RuntimeError("server neither acknowledged nor refused a non-empty push")
|
|
422
|
+
refusals: list[Refusal] = []
|
|
423
|
+
with self._lock:
|
|
424
|
+
for oid in res.acked:
|
|
425
|
+
self._outbox.pop(oid, None)
|
|
426
|
+
for r in res.refused:
|
|
427
|
+
op = self._outbox.pop(r.op_id, None)
|
|
428
|
+
if op is not None:
|
|
429
|
+
refusals.append(Refusal(op.op_id, op.record, op.field, r.reason))
|
|
430
|
+
if refusals:
|
|
431
|
+
self._writer.discard(r.op_id for r in refusals)
|
|
432
|
+
self._persist(
|
|
433
|
+
StorageTx(
|
|
434
|
+
outbox_delete=[*res.acked, *(r.op_id for r in res.refused)],
|
|
435
|
+
delete_ops=[r.op_id for r in refusals],
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
if refusals:
|
|
439
|
+
self._emit("change", list(dict.fromkeys(r.record for r in refusals)))
|
|
440
|
+
for refusal in refusals:
|
|
441
|
+
self._emit("refused", refusal)
|
|
442
|
+
|
|
443
|
+
def _apply_page(self, items: Sequence[PullItem], cursor: int) -> list[str]:
|
|
444
|
+
"""Applies one pull page and saves it. Caller holds the lock. Returns changed records."""
|
|
445
|
+
put: dict[OpId, Op] = {}
|
|
446
|
+
forgotten: list[OpId] = []
|
|
447
|
+
snapshots: list[RecordSnapshot] = []
|
|
448
|
+
drop_snapshots: list[str] = []
|
|
449
|
+
changed: dict[str, None] = {}
|
|
450
|
+
pending = set(self._outbox)
|
|
451
|
+
|
|
452
|
+
def not_pending(record: str) -> list[OpId]:
|
|
453
|
+
return [
|
|
454
|
+
o.op_id
|
|
455
|
+
for o in self._writer.replica.ops()
|
|
456
|
+
if o.record == record and o.op_id not in pending
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
for item in items:
|
|
460
|
+
if isinstance(item, OpItem):
|
|
461
|
+
op = decode_op(item.op)
|
|
462
|
+
if self._writer.receive(op) == "applied":
|
|
463
|
+
put[op.op_id] = op
|
|
464
|
+
changed[op.record] = None
|
|
465
|
+
elif isinstance(item, SnapshotItem):
|
|
466
|
+
# A compacted record: its snapshot replaces the ops it folded; our unpushed edits
|
|
467
|
+
# stay on top. The server sends a snapshot before any later op of that record.
|
|
468
|
+
snap = item.snapshot
|
|
469
|
+
for oid in not_pending(snap.record):
|
|
470
|
+
put.pop(oid, None)
|
|
471
|
+
forgotten.append(oid)
|
|
472
|
+
self._writer.replica.load_snapshot(snap, pending)
|
|
473
|
+
snapshots.append(snap)
|
|
474
|
+
changed[snap.record] = None
|
|
475
|
+
elif isinstance(item, ExitItem):
|
|
476
|
+
# The record left our scope: forget it, except our own unpushed edits, which will
|
|
477
|
+
# be pushed, refused and rolled back like any other refused write.
|
|
478
|
+
ids = not_pending(item.record)
|
|
479
|
+
for oid in ids:
|
|
480
|
+
put.pop(oid, None)
|
|
481
|
+
self._writer.forget(item.record, pending)
|
|
482
|
+
forgotten.extend(ids)
|
|
483
|
+
drop_snapshots.append(item.record)
|
|
484
|
+
changed[item.record] = None
|
|
485
|
+
self._cursor = max(self._cursor, cursor)
|
|
486
|
+
snapshotted = {s.record for s in snapshots}
|
|
487
|
+
self._persist(
|
|
488
|
+
StorageTx(
|
|
489
|
+
delete_ops=forgotten,
|
|
490
|
+
delete_snapshots=[r for r in drop_snapshots if r not in snapshotted],
|
|
491
|
+
put_snapshots=[s for s in snapshots if s.record not in drop_snapshots],
|
|
492
|
+
put_ops=[encode_op(o) for o in put.values()],
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
return list(changed)
|
|
496
|
+
|
|
497
|
+
def _resync(self) -> None:
|
|
498
|
+
"""Read scopes changed: keep only unpushed local ops and pull everything again from 0."""
|
|
499
|
+
with self._lock:
|
|
500
|
+
keep = list(self._outbox.values())
|
|
501
|
+
before = self._writer.replica.records()
|
|
502
|
+
self._writer = self._new_writer((self._writer.clock, self._writer.seq))
|
|
503
|
+
for op in keep:
|
|
504
|
+
self._writer.receive(op)
|
|
505
|
+
self._cursor = 0
|
|
506
|
+
self._persist(StorageTx(clear_ops=True, put_ops=[encode_op(o) for o in keep]))
|
|
507
|
+
self._emit("resync", None)
|
|
508
|
+
self._emit("change", before)
|
|
509
|
+
|
|
510
|
+
# ── background sync ───────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
def _schedule(self, delay: float) -> None:
|
|
513
|
+
with self._cond:
|
|
514
|
+
self._next_at = time.monotonic() + delay
|
|
515
|
+
self._cond.notify_all()
|
|
516
|
+
|
|
517
|
+
def _soon(self) -> None:
|
|
518
|
+
"""After a write, sync shortly (writes in a burst share one round)."""
|
|
519
|
+
with self._lock:
|
|
520
|
+
failures = self._failures
|
|
521
|
+
with self._cond:
|
|
522
|
+
running = self._running
|
|
523
|
+
if running and failures == 0:
|
|
524
|
+
self._schedule(0.05)
|
|
525
|
+
|
|
526
|
+
def _loop(self, generation: int) -> None:
|
|
527
|
+
while True:
|
|
528
|
+
with self._cond:
|
|
529
|
+
while True:
|
|
530
|
+
if not self._running or self._generation != generation:
|
|
531
|
+
return
|
|
532
|
+
wait = self._next_at - time.monotonic()
|
|
533
|
+
if wait <= 0:
|
|
534
|
+
break
|
|
535
|
+
self._cond.wait(wait)
|
|
536
|
+
self._next_at = float("inf")
|
|
537
|
+
try:
|
|
538
|
+
self.sync()
|
|
539
|
+
except Exception as error:
|
|
540
|
+
with self._lock:
|
|
541
|
+
self._failures += 1
|
|
542
|
+
self._last_error = error
|
|
543
|
+
failures = self._failures
|
|
544
|
+
self._emit("error", error)
|
|
545
|
+
base = min(self._max_backoff, self._min_backoff * 2 ** (failures - 1))
|
|
546
|
+
# Jitter: devices don't retry in lockstep.
|
|
547
|
+
self._schedule(base * (0.5 + self._random() / 2))
|
|
548
|
+
else:
|
|
549
|
+
self._schedule(self._sync_interval)
|
|
550
|
+
|
|
551
|
+
# ── internals ─────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
def _new_writer(self, resume: tuple[Hlc, int] | None) -> LocalWriter:
|
|
554
|
+
return LocalWriter(
|
|
555
|
+
self._schema,
|
|
556
|
+
self.device_id,
|
|
557
|
+
self._now,
|
|
558
|
+
# The server already refused ops with absurd clocks; a device with a wrong clock of
|
|
559
|
+
# its own must still accept everything the server sends.
|
|
560
|
+
max_skew_ms=MAX_SAFE_INTEGER,
|
|
561
|
+
resume=resume,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def _persist(self, tx: StorageTx) -> None:
|
|
565
|
+
"""Commits `tx` with the current meta. Caller holds the lock (or is `open`)."""
|
|
566
|
+
meta = StoredMeta(
|
|
567
|
+
device_id=self.device_id,
|
|
568
|
+
cursor=self._cursor,
|
|
569
|
+
hlc=self._writer.clock.encode(),
|
|
570
|
+
seq=self._writer.seq,
|
|
571
|
+
)
|
|
572
|
+
self._storage.commit(tx.with_meta(meta))
|