accordsync-core 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_core/__init__.py +90 -0
- accordsync_core/_text.py +15 -0
- accordsync_core/canonical.py +122 -0
- accordsync_core/errors.py +9 -0
- accordsync_core/hlc.py +100 -0
- accordsync_core/op.py +142 -0
- accordsync_core/py.typed +0 -0
- accordsync_core/replica.py +196 -0
- accordsync_core/schema.py +90 -0
- accordsync_core/strategies.py +220 -0
- accordsync_core/wire.py +113 -0
- accordsync_core/writer.py +137 -0
- accordsync_core-0.3.0.dist-info/METADATA +70 -0
- accordsync_core-0.3.0.dist-info/RECORD +16 -0
- accordsync_core-0.3.0.dist-info/WHEEL +4 -0
- accordsync_core-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Accord's merge core: hybrid logical clocks, operations and the four merge strategies."""
|
|
2
|
+
|
|
3
|
+
from .canonical import canonical_json
|
|
4
|
+
from .errors import AccordError, ClockSkewError
|
|
5
|
+
from .hlc import MAX_COUNTER, MAX_SAFE_INTEGER, Hlc, assert_node, compare_hlc
|
|
6
|
+
from .op import (
|
|
7
|
+
AddOp,
|
|
8
|
+
AssignOp,
|
|
9
|
+
IncOp,
|
|
10
|
+
JsonValue,
|
|
11
|
+
Op,
|
|
12
|
+
OpId,
|
|
13
|
+
OpKind,
|
|
14
|
+
ParsedOpId,
|
|
15
|
+
RemoveOp,
|
|
16
|
+
SetElement,
|
|
17
|
+
compare_op_ids,
|
|
18
|
+
parse_op_id,
|
|
19
|
+
record_type,
|
|
20
|
+
)
|
|
21
|
+
from .replica import ApplyResult, FieldRef, RecordSnapshot, Replica
|
|
22
|
+
from .schema import (
|
|
23
|
+
KINDS,
|
|
24
|
+
RecordFields,
|
|
25
|
+
Schema,
|
|
26
|
+
Strategy,
|
|
27
|
+
StrategyName,
|
|
28
|
+
conflict,
|
|
29
|
+
counter,
|
|
30
|
+
define_schema,
|
|
31
|
+
fields_of,
|
|
32
|
+
lww,
|
|
33
|
+
set_,
|
|
34
|
+
strategy_for,
|
|
35
|
+
)
|
|
36
|
+
from .strategies import ABSENT, FieldSnapshot
|
|
37
|
+
from .wire import WireOp, decode_op, encode_op
|
|
38
|
+
from .writer import DEFAULT_MAX_SKEW_MS, LocalWriter
|
|
39
|
+
|
|
40
|
+
PROTOCOL_VERSION = 1
|
|
41
|
+
"""The Accord sync protocol this package speaks (docs/protocol.md in the Accord repository).
|
|
42
|
+
A breaking change to the wire format bumps it."""
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ABSENT",
|
|
46
|
+
"DEFAULT_MAX_SKEW_MS",
|
|
47
|
+
"KINDS",
|
|
48
|
+
"MAX_COUNTER",
|
|
49
|
+
"MAX_SAFE_INTEGER",
|
|
50
|
+
"PROTOCOL_VERSION",
|
|
51
|
+
"AccordError",
|
|
52
|
+
"AddOp",
|
|
53
|
+
"ApplyResult",
|
|
54
|
+
"AssignOp",
|
|
55
|
+
"ClockSkewError",
|
|
56
|
+
"FieldRef",
|
|
57
|
+
"FieldSnapshot",
|
|
58
|
+
"Hlc",
|
|
59
|
+
"IncOp",
|
|
60
|
+
"JsonValue",
|
|
61
|
+
"LocalWriter",
|
|
62
|
+
"Op",
|
|
63
|
+
"OpId",
|
|
64
|
+
"OpKind",
|
|
65
|
+
"ParsedOpId",
|
|
66
|
+
"RecordFields",
|
|
67
|
+
"RecordSnapshot",
|
|
68
|
+
"RemoveOp",
|
|
69
|
+
"Replica",
|
|
70
|
+
"Schema",
|
|
71
|
+
"SetElement",
|
|
72
|
+
"Strategy",
|
|
73
|
+
"StrategyName",
|
|
74
|
+
"WireOp",
|
|
75
|
+
"assert_node",
|
|
76
|
+
"canonical_json",
|
|
77
|
+
"compare_hlc",
|
|
78
|
+
"compare_op_ids",
|
|
79
|
+
"conflict",
|
|
80
|
+
"counter",
|
|
81
|
+
"decode_op",
|
|
82
|
+
"define_schema",
|
|
83
|
+
"encode_op",
|
|
84
|
+
"fields_of",
|
|
85
|
+
"lww",
|
|
86
|
+
"parse_op_id",
|
|
87
|
+
"record_type",
|
|
88
|
+
"set_",
|
|
89
|
+
"strategy_for",
|
|
90
|
+
]
|
accordsync_core/_text.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""String helpers that make Python strings behave like JavaScript's UTF-16 strings."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def utf16_key(s: str) -> bytes:
|
|
5
|
+
"""Sort key giving JavaScript's `a < b` order: by UTF-16 code unit, not by code point.
|
|
6
|
+
|
|
7
|
+
The two differ for characters above U+FFFF (surrogate pairs, 0xD800...) against U+E000-U+FFFF.
|
|
8
|
+
`surrogatepass` keeps lone surrogates, which JSON may carry and JavaScript strings may hold.
|
|
9
|
+
"""
|
|
10
|
+
return s.encode("utf-16-be", "surrogatepass")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def utf16_len(s: str) -> int:
|
|
14
|
+
"""`s.length` in JavaScript: the number of UTF-16 code units."""
|
|
15
|
+
return len(utf16_key(s)) // 2
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Canonical JSON, byte for byte what the TypeScript core's `canonicalJson` prints.
|
|
2
|
+
|
|
3
|
+
That is `JSON.stringify` of objects built from code-unit-sorted keys, so this reproduces
|
|
4
|
+
JavaScript rather than Python's `json` module:
|
|
5
|
+
- keys that are array indices ("0".."4294967294", no leading zeros) come first in numeric order,
|
|
6
|
+
because JavaScript objects always order them that way; the others follow by UTF-16 code unit;
|
|
7
|
+
- numbers print as JavaScript prints them: `1` not `1.0`, `0` for `-0.0`, `1e-7`, `1e+21`;
|
|
8
|
+
- strings escape exactly like `JSON.stringify` (lone surrogates as `\\udXXX`, the rest raw).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
|
|
16
|
+
from ._text import utf16_key
|
|
17
|
+
|
|
18
|
+
MAX_ARRAY_INDEX = 4294967294
|
|
19
|
+
_ARRAY_INDEX = re.compile(r"0|[1-9][0-9]*")
|
|
20
|
+
_SHORT_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r"}
|
|
21
|
+
_SHORT_ESCAPES["\t"] = "\\t"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_json(value: object) -> str:
|
|
25
|
+
out: list[str] = []
|
|
26
|
+
_write(out, value)
|
|
27
|
+
return "".join(out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write(out: list[str], value: object) -> None:
|
|
31
|
+
# `bool` first: it is a subclass of `int`.
|
|
32
|
+
if value is None:
|
|
33
|
+
out.append("null")
|
|
34
|
+
elif type(value) is bool:
|
|
35
|
+
out.append("true" if value else "false")
|
|
36
|
+
elif isinstance(value, int | float):
|
|
37
|
+
out.append(js_number(value))
|
|
38
|
+
elif isinstance(value, str):
|
|
39
|
+
out.append(js_string(value))
|
|
40
|
+
elif isinstance(value, list | tuple):
|
|
41
|
+
out.append("[")
|
|
42
|
+
for i, v in enumerate(value):
|
|
43
|
+
if i:
|
|
44
|
+
out.append(",")
|
|
45
|
+
_write(out, v)
|
|
46
|
+
out.append("]")
|
|
47
|
+
elif isinstance(value, Mapping):
|
|
48
|
+
out.append("{")
|
|
49
|
+
for i, k in enumerate(js_key_order(value.keys())):
|
|
50
|
+
if i:
|
|
51
|
+
out.append(",")
|
|
52
|
+
out.append(js_string(k))
|
|
53
|
+
out.append(":")
|
|
54
|
+
_write(out, value[k])
|
|
55
|
+
out.append("}")
|
|
56
|
+
else:
|
|
57
|
+
raise TypeError(f"canonical_json: not a JSON value: {type(value).__name__}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def js_key_order(keys: Iterable[object]) -> list[str]:
|
|
61
|
+
"""The order JavaScript gives the keys of an object built from code-unit-sorted entries."""
|
|
62
|
+
indices: list[str] = []
|
|
63
|
+
others: list[str] = []
|
|
64
|
+
for k in keys:
|
|
65
|
+
if not isinstance(k, str):
|
|
66
|
+
raise TypeError(f"canonical_json: object keys must be strings, got {k!r}")
|
|
67
|
+
if _ARRAY_INDEX.fullmatch(k) and int(k) <= MAX_ARRAY_INDEX:
|
|
68
|
+
indices.append(k)
|
|
69
|
+
else:
|
|
70
|
+
others.append(k)
|
|
71
|
+
indices.sort(key=int)
|
|
72
|
+
others.sort(key=utf16_key)
|
|
73
|
+
return indices + others
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def js_number(n: int | float) -> str:
|
|
77
|
+
"""A number as JavaScript's `String(n)` / `JSON.stringify` prints it."""
|
|
78
|
+
if isinstance(n, int):
|
|
79
|
+
# JavaScript numbers are doubles: an integer past 2^53 prints as the double it rounds to.
|
|
80
|
+
if abs(n) <= 2**53:
|
|
81
|
+
return str(n)
|
|
82
|
+
n = float(n)
|
|
83
|
+
if not math.isfinite(n):
|
|
84
|
+
return "null"
|
|
85
|
+
if n == 0:
|
|
86
|
+
return "0" # also -0.0
|
|
87
|
+
sign = "-" if n < 0 else ""
|
|
88
|
+
# repr gives the shortest digits that round-trip, as JavaScript does; only the layout differs.
|
|
89
|
+
# The value is 0.DIGITS x 10^point (the k digits and n of ECMAScript's Number::toString).
|
|
90
|
+
t = Decimal(repr(abs(n))).normalize().as_tuple()
|
|
91
|
+
digits = "".join(map(str, t.digits))
|
|
92
|
+
point = len(digits) + int(t.exponent)
|
|
93
|
+
k = len(digits)
|
|
94
|
+
if k <= point <= 21:
|
|
95
|
+
return sign + digits + "0" * (point - k)
|
|
96
|
+
if 0 < point <= 21:
|
|
97
|
+
return sign + digits[:point] + "." + digits[point:]
|
|
98
|
+
if -6 < point <= 0:
|
|
99
|
+
return sign + "0." + "0" * -point + digits
|
|
100
|
+
e = point - 1
|
|
101
|
+
e_str = f"e+{e}" if e >= 0 else f"e-{-e}"
|
|
102
|
+
if k == 1:
|
|
103
|
+
return sign + digits + e_str
|
|
104
|
+
return sign + digits[0] + "." + digits[1:] + e_str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def js_string(s: str) -> str:
|
|
108
|
+
"""A string as `JSON.stringify` prints it."""
|
|
109
|
+
# Re-pair surrogates first: a high and a low surrogate side by side are one character to
|
|
110
|
+
# JavaScript, printed raw; only lone surrogates are escaped.
|
|
111
|
+
s = s.encode("utf-16-be", "surrogatepass").decode("utf-16-be", "surrogatepass")
|
|
112
|
+
out = ['"']
|
|
113
|
+
for ch in s:
|
|
114
|
+
c = ord(ch)
|
|
115
|
+
if ch in _SHORT_ESCAPES:
|
|
116
|
+
out.append(_SHORT_ESCAPES[ch])
|
|
117
|
+
elif c < 0x20 or 0xD800 <= c <= 0xDFFF:
|
|
118
|
+
out.append(f"\\u{c:04x}")
|
|
119
|
+
else:
|
|
120
|
+
out.append(ch)
|
|
121
|
+
out.append('"')
|
|
122
|
+
return "".join(out)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Errors raised by the core."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AccordError(ValueError):
|
|
5
|
+
"""Invalid input: a malformed op, a write that does not fit the schema, a bad snapshot."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClockSkewError(AccordError):
|
|
9
|
+
"""A remote clock too far ahead of this device's physical time."""
|
accordsync_core/hlc.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Hybrid logical clock: physical time + logical counter + node id.
|
|
2
|
+
|
|
3
|
+
Orders events consistently even when device clocks are wrong, and `compare_hlc` is a total order:
|
|
4
|
+
two distinct clocks never compare equal, because the node id breaks ties.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ._text import utf16_key
|
|
13
|
+
from .errors import AccordError, ClockSkewError
|
|
14
|
+
|
|
15
|
+
MAX_COUNTER = 99_999
|
|
16
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
17
|
+
"""JavaScript's `Number.MAX_SAFE_INTEGER`: integers on the wire stay within +/- this."""
|
|
18
|
+
|
|
19
|
+
# fullmatch and explicit [0-9]: Python's `$` accepts a trailing newline and `\d` any Unicode digit.
|
|
20
|
+
_NODE = re.compile(r"[A-Za-z0-9_-]{1,64}")
|
|
21
|
+
_ENCODED = re.compile(r"([0-9]{1,16}):([0-9]{5}):([A-Za-z0-9_-]{1,64})")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Hlc:
|
|
26
|
+
wall: int
|
|
27
|
+
"""Milliseconds since the Unix epoch, as seen by the node (maybe pushed forward by others)."""
|
|
28
|
+
counter: int
|
|
29
|
+
"""Disambiguates events within the same `wall` millisecond."""
|
|
30
|
+
node: str
|
|
31
|
+
"""The device or server that produced the clock."""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def initial(node: str) -> Hlc:
|
|
35
|
+
assert_node(node)
|
|
36
|
+
return Hlc(0, 0, node)
|
|
37
|
+
|
|
38
|
+
def encode(self) -> str:
|
|
39
|
+
"""`wall:counter:node`, with the counter zero-padded to 5 digits."""
|
|
40
|
+
return f"{self.wall}:{self.counter:05d}:{self.node}"
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def decode(s: str) -> Hlc:
|
|
44
|
+
m = _ENCODED.fullmatch(s)
|
|
45
|
+
if not m:
|
|
46
|
+
raise AccordError(f'malformed hlc "{s}"')
|
|
47
|
+
wall = int(m[1])
|
|
48
|
+
if wall > MAX_SAFE_INTEGER:
|
|
49
|
+
raise AccordError(f'hlc wall out of range in "{s}"')
|
|
50
|
+
return Hlc(wall, int(m[2]), m[3])
|
|
51
|
+
|
|
52
|
+
def compare(self, other: Hlc) -> int:
|
|
53
|
+
return compare_hlc(self, other)
|
|
54
|
+
|
|
55
|
+
def tick(self, now: int) -> Hlc:
|
|
56
|
+
"""The clock for a new local event at physical time `now`."""
|
|
57
|
+
if now > self.wall:
|
|
58
|
+
return Hlc(now, 0, self.node)
|
|
59
|
+
return _after(self.wall, self.counter, self.node)
|
|
60
|
+
|
|
61
|
+
def receive(self, remote: Hlc, now: int, max_skew_ms: int) -> Hlc:
|
|
62
|
+
"""The clock after observing `remote` at physical time `now`.
|
|
63
|
+
|
|
64
|
+
Refuses a remote clock more than `max_skew_ms` ahead of `now`, so one phone with a wrong
|
|
65
|
+
date cannot win every merge forever.
|
|
66
|
+
"""
|
|
67
|
+
if remote.wall - now > max_skew_ms:
|
|
68
|
+
raise ClockSkewError(
|
|
69
|
+
f"clock of {remote.node} is {remote.wall - now} ms ahead (limit {max_skew_ms} ms)"
|
|
70
|
+
)
|
|
71
|
+
wall = max(self.wall, remote.wall, now)
|
|
72
|
+
if wall == self.wall and wall == remote.wall:
|
|
73
|
+
return _after(wall, max(self.counter, remote.counter), self.node)
|
|
74
|
+
if wall == self.wall:
|
|
75
|
+
return _after(wall, self.counter, self.node)
|
|
76
|
+
if wall == remote.wall:
|
|
77
|
+
return _after(wall, remote.counter, self.node)
|
|
78
|
+
return Hlc(wall, 0, self.node)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compare_hlc(a: Hlc, b: Hlc) -> int:
|
|
82
|
+
if a.wall != b.wall:
|
|
83
|
+
return -1 if a.wall < b.wall else 1
|
|
84
|
+
if a.counter != b.counter:
|
|
85
|
+
return -1 if a.counter < b.counter else 1
|
|
86
|
+
if a.node != b.node:
|
|
87
|
+
return -1 if utf16_key(a.node) < utf16_key(b.node) else 1
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def assert_node(node: str) -> None:
|
|
92
|
+
if not isinstance(node, str) or not _NODE.fullmatch(node):
|
|
93
|
+
raise AccordError(f'node id must match [A-Za-z0-9_-]{{1,64}}, got "{node}"')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _after(wall: int, counter: int, node: str) -> Hlc:
|
|
97
|
+
"""The smallest clock after (wall, counter): a full counter rolls into the next millisecond."""
|
|
98
|
+
if counter >= MAX_COUNTER:
|
|
99
|
+
return Hlc(wall + 1, 0, node)
|
|
100
|
+
return Hlc(wall, counter + 1, node)
|
accordsync_core/op.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Operations: one change to one field of one record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import ClassVar, Literal, TypeAlias
|
|
9
|
+
|
|
10
|
+
from ._text import utf16_key, utf16_len
|
|
11
|
+
from .errors import AccordError
|
|
12
|
+
from .hlc import MAX_SAFE_INTEGER, Hlc
|
|
13
|
+
|
|
14
|
+
JsonValue: TypeAlias = "bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None"
|
|
15
|
+
"""Any JSON value. Field values must survive a JSON round trip."""
|
|
16
|
+
|
|
17
|
+
SetElement: TypeAlias = str | int | float
|
|
18
|
+
"""Set elements are compared by value, so they are limited to strings and finite numbers."""
|
|
19
|
+
|
|
20
|
+
OpId: TypeAlias = str
|
|
21
|
+
"""`deviceId:sequence`. Unique per op, so applying an op twice is a no-op."""
|
|
22
|
+
|
|
23
|
+
OpKind: TypeAlias = Literal["assign", "inc", "add", "remove"]
|
|
24
|
+
|
|
25
|
+
_OP_ID = re.compile(r"([A-Za-z0-9_-]{1,64}):([1-9][0-9]{0,15})")
|
|
26
|
+
_RECORD_ID = re.compile(r"([A-Za-z][A-Za-z0-9_]{0,63}):(.+)", re.DOTALL)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class AssignOp:
|
|
31
|
+
"""Writes a value. For `lww` the highest clock wins. For `conflict`, `deps` lists the values the
|
|
32
|
+
writer could see; the assign supersedes exactly those, so resolving a conflict is an assign
|
|
33
|
+
whose deps name every conflicting value (ADR-0003)."""
|
|
34
|
+
|
|
35
|
+
kind: ClassVar[Literal["assign"]] = "assign"
|
|
36
|
+
op_id: OpId
|
|
37
|
+
record: str
|
|
38
|
+
field: str
|
|
39
|
+
hlc: Hlc
|
|
40
|
+
value: JsonValue
|
|
41
|
+
deps: tuple[OpId, ...] = ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class IncOp:
|
|
46
|
+
"""Adds `by` (a positive or negative integer) to a counter."""
|
|
47
|
+
|
|
48
|
+
kind: ClassVar[Literal["inc"]] = "inc"
|
|
49
|
+
op_id: OpId
|
|
50
|
+
record: str
|
|
51
|
+
field: str
|
|
52
|
+
hlc: Hlc
|
|
53
|
+
by: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class AddOp:
|
|
58
|
+
"""Adds an element to a set. The op id is the element's unique tag. `deps` lists the element's
|
|
59
|
+
tags the writer could see: the add replaces them, so re-adding keeps the set's state small,
|
|
60
|
+
while a concurrent remove (which only cites the tags it saw) still loses."""
|
|
61
|
+
|
|
62
|
+
kind: ClassVar[Literal["add"]] = "add"
|
|
63
|
+
op_id: OpId
|
|
64
|
+
record: str
|
|
65
|
+
field: str
|
|
66
|
+
hlc: Hlc
|
|
67
|
+
element: SetElement
|
|
68
|
+
deps: tuple[OpId, ...] = ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class RemoveOp:
|
|
73
|
+
"""Removes the add-tags in `deps` (the ones the writer had seen); concurrent adds survive."""
|
|
74
|
+
|
|
75
|
+
kind: ClassVar[Literal["remove"]] = "remove"
|
|
76
|
+
op_id: OpId
|
|
77
|
+
record: str
|
|
78
|
+
field: str
|
|
79
|
+
hlc: Hlc
|
|
80
|
+
element: SetElement
|
|
81
|
+
deps: tuple[OpId, ...] = ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Op: TypeAlias = AssignOp | IncOp | AddOp | RemoveOp
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class ParsedOpId:
|
|
89
|
+
device: str
|
|
90
|
+
seq: int
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_op_id(op_id: str) -> ParsedOpId:
|
|
94
|
+
m = _OP_ID.fullmatch(op_id) if isinstance(op_id, str) else None
|
|
95
|
+
if not m:
|
|
96
|
+
raise AccordError(f'malformed op id "{op_id}" (expected device:sequence)')
|
|
97
|
+
return ParsedOpId(m[1], int(m[2]))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_type(record: str) -> str:
|
|
101
|
+
"""The type of a `type:id` record id (the id: 1 to 256 UTF-16 code units, as in JavaScript)."""
|
|
102
|
+
m = _RECORD_ID.fullmatch(record) if isinstance(record, str) else None
|
|
103
|
+
if not m or utf16_len(m[2]) > 256:
|
|
104
|
+
raise AccordError(f'malformed record id "{record}" (expected type:id)')
|
|
105
|
+
return m[1]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def op_id_key(op_id: OpId) -> bytes:
|
|
109
|
+
"""Sort key for op ids, used wherever output order must be deterministic (code-unit order)."""
|
|
110
|
+
return utf16_key(op_id)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compare_op_ids(a: OpId, b: OpId) -> int:
|
|
114
|
+
ka, kb = op_id_key(a), op_id_key(b)
|
|
115
|
+
return -1 if ka < kb else 1 if ka > kb else 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_number(x: object) -> bool:
|
|
119
|
+
"""A JSON number: `int` or `float`, never `bool` (which is an `int` subclass in Python)."""
|
|
120
|
+
return type(x) is not bool and isinstance(x, int | float)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def as_set_element(e: object) -> SetElement:
|
|
124
|
+
"""Checks a set element: a string or a finite number. Booleans are refused, so `True` can never
|
|
125
|
+
be confused with `1`. An integer past 2^53 becomes the double JavaScript would read."""
|
|
126
|
+
if isinstance(e, str):
|
|
127
|
+
return e
|
|
128
|
+
if type(e) is int:
|
|
129
|
+
return e if abs(e) <= MAX_SAFE_INTEGER else float(e)
|
|
130
|
+
if type(e) is float and math.isfinite(e):
|
|
131
|
+
return e
|
|
132
|
+
raise AccordError("set elements must be a string or finite number")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def as_increment(by: object) -> int:
|
|
136
|
+
"""Checks a counter increment: a safe integer. A whole float (`3.0`, as some JSON decoders give)
|
|
137
|
+
is the same number to JavaScript and is accepted; `True` is not a number."""
|
|
138
|
+
if type(by) is float and math.isfinite(by) and by == int(by):
|
|
139
|
+
by = int(by)
|
|
140
|
+
if type(by) is not int or abs(by) > MAX_SAFE_INTEGER:
|
|
141
|
+
raise AccordError(f"counter increment must be a safe integer, got {by!r}")
|
|
142
|
+
return by
|
accordsync_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""An op log and the state projected from it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
from ._text import utf16_key
|
|
10
|
+
from .canonical import canonical_json
|
|
11
|
+
from .errors import AccordError
|
|
12
|
+
from .op import Op, OpId, SetElement, op_id_key, parse_op_id
|
|
13
|
+
from .schema import KINDS, Schema, fields_of, strategy_for
|
|
14
|
+
from .strategies import (
|
|
15
|
+
ABSENT,
|
|
16
|
+
ConflictState,
|
|
17
|
+
FieldSnapshot,
|
|
18
|
+
FieldState,
|
|
19
|
+
apply_op,
|
|
20
|
+
empty_state,
|
|
21
|
+
observed_deps,
|
|
22
|
+
read_state,
|
|
23
|
+
snapshot_state,
|
|
24
|
+
state_from_snapshot,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ApplyResult: TypeAlias = Literal["applied", "duplicate"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class RecordSnapshot:
|
|
32
|
+
"""A record's state with its history folded away (log compaction, ADR-0008)."""
|
|
33
|
+
|
|
34
|
+
record: str
|
|
35
|
+
fields: Mapping[str, FieldSnapshot]
|
|
36
|
+
|
|
37
|
+
def to_json(self) -> dict[str, Any]:
|
|
38
|
+
return {"record": self.record, "fields": dict(self.fields)}
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def from_json(data: Mapping[str, Any]) -> RecordSnapshot:
|
|
42
|
+
record, fields = data.get("record"), data.get("fields")
|
|
43
|
+
if not isinstance(record, str) or not isinstance(fields, Mapping):
|
|
44
|
+
raise AccordError("record snapshot needs a record and fields")
|
|
45
|
+
return RecordSnapshot(record, dict(fields))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class FieldRef:
|
|
50
|
+
record: str
|
|
51
|
+
field: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Replica:
|
|
55
|
+
"""An op log and the state projected from it. Pure: no I/O, no clock.
|
|
56
|
+
|
|
57
|
+
Two replicas holding the same set of ops always read the same state, whatever order the ops
|
|
58
|
+
arrived in.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, schema: Schema) -> None:
|
|
62
|
+
self._schema = schema
|
|
63
|
+
self._ops: dict[OpId, Op] = {}
|
|
64
|
+
self._records: dict[str, dict[str, FieldState]] = {}
|
|
65
|
+
# Snapshots this replica's state was started from, by record.
|
|
66
|
+
self._bases: dict[str, RecordSnapshot] = {}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def schema(self) -> Schema:
|
|
70
|
+
return self._schema
|
|
71
|
+
|
|
72
|
+
def has(self, op_id: OpId) -> bool:
|
|
73
|
+
return op_id in self._ops
|
|
74
|
+
|
|
75
|
+
def __len__(self) -> int:
|
|
76
|
+
return len(self._ops)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def size(self) -> int:
|
|
80
|
+
return len(self._ops)
|
|
81
|
+
|
|
82
|
+
def ops(self) -> list[Op]:
|
|
83
|
+
"""All ops, in a deterministic order."""
|
|
84
|
+
return sorted(self._ops.values(), key=lambda o: op_id_key(o.op_id))
|
|
85
|
+
|
|
86
|
+
def validate(self, op: Op) -> None:
|
|
87
|
+
"""Raises (without changing anything) if the op does not fit the schema."""
|
|
88
|
+
strategy = strategy_for(self._schema, op.record, op.field)
|
|
89
|
+
if op.kind not in KINDS[strategy]:
|
|
90
|
+
raise AccordError(
|
|
91
|
+
f'op kind "{op.kind}" does not apply to {op.field}, a {strategy} field'
|
|
92
|
+
)
|
|
93
|
+
if parse_op_id(op.op_id).device != op.hlc.node:
|
|
94
|
+
raise AccordError(f'op {op.op_id} carries a clock from "{op.hlc.node}"')
|
|
95
|
+
|
|
96
|
+
def apply(self, op: Op) -> ApplyResult:
|
|
97
|
+
if op.op_id in self._ops:
|
|
98
|
+
return "duplicate"
|
|
99
|
+
self.validate(op)
|
|
100
|
+
apply_op(self._state(op.record, op.field), op)
|
|
101
|
+
self._ops[op.op_id] = op
|
|
102
|
+
return "applied"
|
|
103
|
+
|
|
104
|
+
def read(self, record: str) -> dict[str, object] | None:
|
|
105
|
+
"""The record's fields, or None if no op touched it. Fields never written are left out."""
|
|
106
|
+
states = self._records.get(record)
|
|
107
|
+
if states is None:
|
|
108
|
+
return None
|
|
109
|
+
out: dict[str, object] = {}
|
|
110
|
+
for name, s in fields_of(self._schema, record).items():
|
|
111
|
+
state = states.get(name)
|
|
112
|
+
value = read_state(state if state is not None else empty_state(s.strategy))
|
|
113
|
+
if value is not ABSENT:
|
|
114
|
+
out[name] = value
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def records(self) -> list[str]:
|
|
118
|
+
return sorted(self._records, key=utf16_key)
|
|
119
|
+
|
|
120
|
+
def conflicts(self) -> list[FieldRef]:
|
|
121
|
+
"""Every `conflict()` field currently holding more than one value."""
|
|
122
|
+
out: list[FieldRef] = []
|
|
123
|
+
for record in self.records():
|
|
124
|
+
states = self._records[record]
|
|
125
|
+
for name in sorted(states, key=utf16_key):
|
|
126
|
+
state = states[name]
|
|
127
|
+
if isinstance(state, ConflictState) and len(state.live) > 1:
|
|
128
|
+
out.append(FieldRef(record, name))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def observed_deps(
|
|
132
|
+
self, record: str, field: str, element: SetElement | None = None
|
|
133
|
+
) -> list[OpId]:
|
|
134
|
+
"""Op ids a new write to this field must cite (see `AssignOp` and `RemoveOp`)."""
|
|
135
|
+
strategy_for(self._schema, record, field)
|
|
136
|
+
state = self._records.get(record, {}).get(field)
|
|
137
|
+
return observed_deps(state, element) if state is not None else []
|
|
138
|
+
|
|
139
|
+
def snapshot(self) -> str:
|
|
140
|
+
"""The whole state as canonical JSON: equal strings mean converged replicas."""
|
|
141
|
+
return canonical_json({r: self.read(r) for r in self.records()})
|
|
142
|
+
|
|
143
|
+
def snapshot_record(self, record: str) -> RecordSnapshot:
|
|
144
|
+
"""The record's current state, with its history folded away."""
|
|
145
|
+
states = self._records.get(record, {})
|
|
146
|
+
return RecordSnapshot(record, {f: snapshot_state(s) for f, s in states.items()})
|
|
147
|
+
|
|
148
|
+
def load_snapshot(self, snap: RecordSnapshot, keep: Iterable[OpId] = ()) -> None:
|
|
149
|
+
"""Replaces a record's state with a snapshot and forgets that record's ops, except `keep`
|
|
150
|
+
(local ops not yet on the server), which are applied again on top."""
|
|
151
|
+
keep = set(keep)
|
|
152
|
+
fields: dict[str, FieldState] = {}
|
|
153
|
+
for name, fs in snap.fields.items():
|
|
154
|
+
strategy_for(self._schema, snap.record, name)
|
|
155
|
+
fields[name] = state_from_snapshot(fs)
|
|
156
|
+
# Everything checked: now change state.
|
|
157
|
+
reapply = [o for o in self._ops.values() if o.record == snap.record and o.op_id in keep]
|
|
158
|
+
self._ops = {k: o for k, o in self._ops.items() if o.record != snap.record}
|
|
159
|
+
self._records[snap.record] = fields
|
|
160
|
+
self._bases[snap.record] = snap
|
|
161
|
+
for op in reapply:
|
|
162
|
+
self.apply(op)
|
|
163
|
+
|
|
164
|
+
def without(self, drop: Iterable[OpId]) -> Replica:
|
|
165
|
+
"""A copy without the given ops (same snapshots, every other op): used to roll back."""
|
|
166
|
+
drop = set(drop)
|
|
167
|
+
nxt = Replica(self._schema)
|
|
168
|
+
for snap in self._bases.values():
|
|
169
|
+
nxt.load_snapshot(snap)
|
|
170
|
+
for op in self.ops():
|
|
171
|
+
if op.op_id not in drop:
|
|
172
|
+
nxt.apply(op)
|
|
173
|
+
return nxt
|
|
174
|
+
|
|
175
|
+
def forget(self, record: str, keep: Iterable[OpId] = ()) -> Replica:
|
|
176
|
+
"""Forgets a record entirely (it left this device's scope), except ops in `keep`."""
|
|
177
|
+
keep = set(keep)
|
|
178
|
+
nxt = Replica(self._schema)
|
|
179
|
+
for r, snap in self._bases.items():
|
|
180
|
+
if r != record:
|
|
181
|
+
nxt.load_snapshot(snap)
|
|
182
|
+
for op in self.ops():
|
|
183
|
+
if op.record != record or op.op_id in keep:
|
|
184
|
+
nxt.apply(op)
|
|
185
|
+
return nxt
|
|
186
|
+
|
|
187
|
+
def bases(self) -> list[RecordSnapshot]:
|
|
188
|
+
"""Snapshots this replica was started from (to persist them alongside the ops)."""
|
|
189
|
+
return list(self._bases.values())
|
|
190
|
+
|
|
191
|
+
def _state(self, record: str, field: str) -> FieldState:
|
|
192
|
+
fields = self._records.setdefault(record, {})
|
|
193
|
+
state = fields.get(field)
|
|
194
|
+
if state is None:
|
|
195
|
+
state = fields[field] = empty_state(strategy_for(self._schema, record, field))
|
|
196
|
+
return state
|