accordsync-server 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_server/__init__.py +54 -0
- accordsync_server/__main__.py +50 -0
- accordsync_server/app.py +281 -0
- accordsync_server/auth.py +63 -0
- accordsync_server/compact.py +82 -0
- accordsync_server/db.py +25 -0
- accordsync_server/define.py +165 -0
- accordsync_server/jsnum.py +43 -0
- accordsync_server/migrations.py +213 -0
- accordsync_server/py.typed +0 -0
- accordsync_server/ratelimit.py +64 -0
- accordsync_server/sync.py +577 -0
- accordsync_server-0.3.0.dist-info/METADATA +135 -0
- accordsync_server-0.3.0.dist-info/RECORD +16 -0
- accordsync_server-0.3.0.dist-info/WHEEL +4 -0
- accordsync_server-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The Accord sync server on PostgreSQL, framework-agnostic: push, pull, scopes, compaction."""
|
|
2
|
+
|
|
3
|
+
from accordsync_core import PROTOCOL_VERSION
|
|
4
|
+
|
|
5
|
+
from .app import AccordServer, Response
|
|
6
|
+
from .auth import AuthError, create_verifier
|
|
7
|
+
from .compact import CompactionResult, compact
|
|
8
|
+
from .db import create_pool
|
|
9
|
+
from .define import (
|
|
10
|
+
Access,
|
|
11
|
+
Auth,
|
|
12
|
+
Claims,
|
|
13
|
+
Compaction,
|
|
14
|
+
Limits,
|
|
15
|
+
RateLimit,
|
|
16
|
+
RateLimits,
|
|
17
|
+
ScopedRecord,
|
|
18
|
+
ServerDefinition,
|
|
19
|
+
define_server,
|
|
20
|
+
)
|
|
21
|
+
from .migrations import migrate
|
|
22
|
+
from .ratelimit import Limiter, RateLimiter
|
|
23
|
+
from .sync import FEED_LOCK, BadRequestError, Caller, ForbiddenError, op_hash, pull, push
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"FEED_LOCK",
|
|
27
|
+
"PROTOCOL_VERSION",
|
|
28
|
+
"Access",
|
|
29
|
+
"AccordServer",
|
|
30
|
+
"Auth",
|
|
31
|
+
"AuthError",
|
|
32
|
+
"BadRequestError",
|
|
33
|
+
"Caller",
|
|
34
|
+
"Claims",
|
|
35
|
+
"Compaction",
|
|
36
|
+
"CompactionResult",
|
|
37
|
+
"ForbiddenError",
|
|
38
|
+
"Limiter",
|
|
39
|
+
"Limits",
|
|
40
|
+
"RateLimit",
|
|
41
|
+
"RateLimiter",
|
|
42
|
+
"RateLimits",
|
|
43
|
+
"Response",
|
|
44
|
+
"ScopedRecord",
|
|
45
|
+
"ServerDefinition",
|
|
46
|
+
"compact",
|
|
47
|
+
"create_pool",
|
|
48
|
+
"create_verifier",
|
|
49
|
+
"define_server",
|
|
50
|
+
"migrate",
|
|
51
|
+
"op_hash",
|
|
52
|
+
"pull",
|
|
53
|
+
"push",
|
|
54
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""`python -m accordsync_server migrate|compact`.
|
|
2
|
+
|
|
3
|
+
`migrate` needs only ACCORD_DATABASE_URL. `compact` also needs the server definition:
|
|
4
|
+
`--definition module:attribute` (or ACCORD_SERVER) naming a `ServerDefinition`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import importlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .compact import compact
|
|
16
|
+
from .db import create_pool
|
|
17
|
+
from .define import ServerDefinition
|
|
18
|
+
from .migrations import migrate
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_definition(spec: str) -> ServerDefinition:
|
|
22
|
+
module, _, attr = spec.partition(":")
|
|
23
|
+
value = getattr(importlib.import_module(module), attr or "server")
|
|
24
|
+
if not isinstance(value, ServerDefinition):
|
|
25
|
+
raise SystemExit(f"{spec} is not a ServerDefinition (use define_server(...))")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
parser = argparse.ArgumentParser(prog="python -m accordsync_server")
|
|
31
|
+
parser.add_argument("command", choices=["migrate", "compact"])
|
|
32
|
+
parser.add_argument("--database-url", default=os.environ.get("ACCORD_DATABASE_URL"))
|
|
33
|
+
parser.add_argument("--definition", default=os.environ.get("ACCORD_SERVER"))
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
if not args.database_url:
|
|
36
|
+
parser.error("ACCORD_DATABASE_URL (or --database-url) is required")
|
|
37
|
+
if args.command == "migrate":
|
|
38
|
+
ran = migrate(args.database_url)
|
|
39
|
+
print(f"applied {len(ran)} migration(s): {', '.join(ran)}" if ran else "up to date")
|
|
40
|
+
return 0
|
|
41
|
+
if not args.definition:
|
|
42
|
+
parser.error("compact needs --definition module:attribute (or ACCORD_SERVER)")
|
|
43
|
+
definition = load_definition(args.definition)
|
|
44
|
+
with create_pool(args.database_url, size=2) as pool:
|
|
45
|
+
print(json.dumps(compact(pool, definition)))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
sys.exit(main())
|
accordsync_server/app.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The framework-agnostic request layer (`app.ts`): one `handle()` call per HTTP request.
|
|
2
|
+
|
|
3
|
+
Framework adapters (FastAPI, Django, WSGI) only translate their request into
|
|
4
|
+
`handle(method, path, query, headers, body)` and the returned `Response` back.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import math
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs
|
|
17
|
+
|
|
18
|
+
from accordsync_core import PROTOCOL_VERSION, AccordError, assert_node, canonical_json
|
|
19
|
+
|
|
20
|
+
from .auth import AuthError, Verifier, create_verifier
|
|
21
|
+
from .define import ServerDefinition
|
|
22
|
+
from .jsnum import js_safe_integer
|
|
23
|
+
from .ratelimit import Limiter, RateLimiter
|
|
24
|
+
from .sync import (
|
|
25
|
+
BadRequestError,
|
|
26
|
+
Caller,
|
|
27
|
+
ForbiddenError,
|
|
28
|
+
Pool,
|
|
29
|
+
SyncContext,
|
|
30
|
+
device_ttl_ms,
|
|
31
|
+
pull,
|
|
32
|
+
push,
|
|
33
|
+
touch_device,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("accordsync_server")
|
|
37
|
+
|
|
38
|
+
Query = Mapping[str, Sequence[str]] | str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class Response:
|
|
43
|
+
status: int
|
|
44
|
+
headers: list[tuple[str, str]] = field(default_factory=list)
|
|
45
|
+
body: bytes = b""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TooManyRequestsError(Exception):
|
|
49
|
+
def __init__(self, retry_after_ms: int) -> None:
|
|
50
|
+
super().__init__("too many requests")
|
|
51
|
+
self.retry_after_ms = retry_after_ms
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def now_ms() -> int:
|
|
55
|
+
return time.time_ns() // 1_000_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _json(status: int, body: object, headers: Iterable[tuple[str, str]] = ()) -> Response:
|
|
59
|
+
return Response(
|
|
60
|
+
status,
|
|
61
|
+
[("Content-Type", "application/json"), *headers],
|
|
62
|
+
canonical_json(body).encode("utf-8"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _error(status: int, message: str) -> Response:
|
|
67
|
+
return _json(status, {"error": message})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reject_constant(name: str) -> object:
|
|
71
|
+
raise ValueError(f"{name} is not JSON")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AccordServer:
|
|
75
|
+
"""Push, pull and health over one connection pool, with auth, limits, CORS and rate limits."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
definition: ServerDefinition,
|
|
80
|
+
pool: Pool,
|
|
81
|
+
*,
|
|
82
|
+
now: Callable[[], int] = now_ms,
|
|
83
|
+
verifier: Verifier | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.definition = definition
|
|
86
|
+
self.pool = pool
|
|
87
|
+
self.ctx = SyncContext(pool, definition, now)
|
|
88
|
+
self._verify = verifier or create_verifier(definition.auth)
|
|
89
|
+
self._limiters: tuple[Limiter, Limiter] | None = None
|
|
90
|
+
self.reset_rate_limits()
|
|
91
|
+
|
|
92
|
+
def reset_rate_limits(self) -> None:
|
|
93
|
+
"""Starts every rate-limit bucket full again (the conformance control API's reset)."""
|
|
94
|
+
rl = self.definition.rate_limit
|
|
95
|
+
self._limiters = (
|
|
96
|
+
None
|
|
97
|
+
if rl is None
|
|
98
|
+
else (
|
|
99
|
+
RateLimiter(rl.per_device, self.ctx.now),
|
|
100
|
+
RateLimiter(rl.per_user, self.ctx.now),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def max_body_bytes(self) -> int:
|
|
106
|
+
"""Adapters may stop reading a body past this (it gets 413 anyway)."""
|
|
107
|
+
return self.definition.limits.max_body_bytes
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------ dispatch
|
|
110
|
+
|
|
111
|
+
def handle(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
query: Query,
|
|
116
|
+
headers: Mapping[str, str],
|
|
117
|
+
body: bytes = b"",
|
|
118
|
+
) -> Response:
|
|
119
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
120
|
+
q = parse_qs(query, keep_blank_values=True) if isinstance(query, str) else query
|
|
121
|
+
method = method.upper()
|
|
122
|
+
cors = self._cors(method, h)
|
|
123
|
+
if cors is not None and cors[0]:
|
|
124
|
+
res = Response(204, cors[1])
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
res = self._route(method, path, q, h, body)
|
|
128
|
+
except AuthError as e:
|
|
129
|
+
res = _error(401, str(e))
|
|
130
|
+
except ForbiddenError as e:
|
|
131
|
+
res = _error(403, str(e))
|
|
132
|
+
except BadRequestError as e:
|
|
133
|
+
res = _error(400, str(e))
|
|
134
|
+
except TooManyRequestsError as e:
|
|
135
|
+
res = _error(429, "too many requests")
|
|
136
|
+
res.headers.append(("Retry-After", str(math.ceil(e.retry_after_ms / 1000))))
|
|
137
|
+
except Exception:
|
|
138
|
+
log.exception("accord: internal error")
|
|
139
|
+
res = _error(500, "internal error")
|
|
140
|
+
if cors is not None:
|
|
141
|
+
res.headers += cors[1]
|
|
142
|
+
res.headers.append(("Accord-Protocol", str(PROTOCOL_VERSION)))
|
|
143
|
+
return res
|
|
144
|
+
|
|
145
|
+
def _cors(self, method: str, h: Mapping[str, str]) -> tuple[bool, list[tuple[str, str]]] | None:
|
|
146
|
+
"""(is a preflight, headers to add), or None when CORS is off for this request."""
|
|
147
|
+
origins = self.definition.cors
|
|
148
|
+
if not origins:
|
|
149
|
+
return None
|
|
150
|
+
origin = h.get("origin")
|
|
151
|
+
allowed = origin if origin in origins else None
|
|
152
|
+
out: list[tuple[str, str]] = []
|
|
153
|
+
if allowed:
|
|
154
|
+
out.append(("Access-Control-Allow-Origin", allowed))
|
|
155
|
+
out.append(("Vary", "Origin"))
|
|
156
|
+
if method == "OPTIONS":
|
|
157
|
+
out += [
|
|
158
|
+
("Access-Control-Max-Age", "600"),
|
|
159
|
+
("Access-Control-Allow-Methods", "GET,HEAD,PUT,POST,DELETE,PATCH"),
|
|
160
|
+
("Access-Control-Allow-Headers", "Authorization,Accord-Device,Content-Type"),
|
|
161
|
+
]
|
|
162
|
+
return True, out
|
|
163
|
+
out.append(("Access-Control-Expose-Headers", "Accord-Protocol"))
|
|
164
|
+
return False, out
|
|
165
|
+
|
|
166
|
+
def _route(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
path: str,
|
|
170
|
+
q: Mapping[str, Sequence[str]],
|
|
171
|
+
h: Mapping[str, str],
|
|
172
|
+
body: bytes,
|
|
173
|
+
) -> Response:
|
|
174
|
+
if path == "/health" and method in ("GET", "HEAD"):
|
|
175
|
+
return self.health()
|
|
176
|
+
if path.startswith("/v1/"):
|
|
177
|
+
limit = self.definition.limits.max_body_bytes
|
|
178
|
+
length = js_safe_integer(h.get("content-length", "") or "0")
|
|
179
|
+
if len(body) > limit or (length is not None and length > limit):
|
|
180
|
+
return _error(413, "request body too large")
|
|
181
|
+
if path == "/v1/push" and method == "POST":
|
|
182
|
+
return self._push(h, body)
|
|
183
|
+
if path == "/v1/pull" and method in ("GET", "HEAD"):
|
|
184
|
+
return self._pull(h, q)
|
|
185
|
+
return _error(404, "not found")
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------ routes
|
|
188
|
+
|
|
189
|
+
def health(self) -> Response:
|
|
190
|
+
try:
|
|
191
|
+
with self.pool.connection(timeout=5) as conn:
|
|
192
|
+
conn.execute("select 1")
|
|
193
|
+
except Exception:
|
|
194
|
+
return _json(503, {"status": "unavailable", "reason": "database unreachable"})
|
|
195
|
+
return _json(200, {"status": "ok", "protocolVersion": PROTOCOL_VERSION})
|
|
196
|
+
|
|
197
|
+
def _caller(self, h: Mapping[str, str]) -> Caller:
|
|
198
|
+
claims = self._verify(h.get("authorization"))
|
|
199
|
+
device_id = h.get("accord-device", "")
|
|
200
|
+
try:
|
|
201
|
+
assert_node(device_id)
|
|
202
|
+
except AccordError:
|
|
203
|
+
raise BadRequestError(
|
|
204
|
+
"Accord-Device header must be a device id ([A-Za-z0-9_-]{1,64})"
|
|
205
|
+
) from None
|
|
206
|
+
if self._limiters is not None:
|
|
207
|
+
device, user = self._limiters
|
|
208
|
+
wait = max(device.take(device_id), user.take(claims["sub"]))
|
|
209
|
+
if wait > 0:
|
|
210
|
+
raise TooManyRequestsError(wait)
|
|
211
|
+
access = self.definition.access(claims)
|
|
212
|
+
who = Caller(claims["sub"], device_id, tuple(access.read), tuple(access.write))
|
|
213
|
+
touch_device(self.pool, who, device_ttl_ms(self.definition))
|
|
214
|
+
return who
|
|
215
|
+
|
|
216
|
+
def _push(self, h: Mapping[str, str], body: bytes) -> Response:
|
|
217
|
+
who = self._caller(h)
|
|
218
|
+
try:
|
|
219
|
+
data: Any = json.loads(body.decode("utf-8", "replace"), parse_constant=_reject_constant)
|
|
220
|
+
except (ValueError, RecursionError):
|
|
221
|
+
raise BadRequestError("body must be JSON") from None
|
|
222
|
+
if not (isinstance(data, dict) and set(data) == {"ops"} and isinstance(data["ops"], list)):
|
|
223
|
+
raise BadRequestError('body must be { "ops": [...] }')
|
|
224
|
+
return _json(200, push(self.ctx, who, data["ops"]))
|
|
225
|
+
|
|
226
|
+
def _pull(self, h: Mapping[str, str], q: Mapping[str, Sequence[str]]) -> Response:
|
|
227
|
+
who = self._caller(h)
|
|
228
|
+
cursor = js_safe_integer(_first(q, "cursor", "0"))
|
|
229
|
+
limit = js_safe_integer(_first(q, "limit", "500"))
|
|
230
|
+
if cursor is None or cursor < 0:
|
|
231
|
+
raise BadRequestError("cursor must be an integer ≥ 0")
|
|
232
|
+
if limit is None or limit < 1:
|
|
233
|
+
raise BadRequestError("limit must be an integer ≥ 1")
|
|
234
|
+
return _json(200, pull(self.ctx, who, cursor, limit))
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------ WSGI
|
|
237
|
+
|
|
238
|
+
def wsgi(
|
|
239
|
+
self, environ: Mapping[str, Any], start_response: Callable[..., object]
|
|
240
|
+
) -> list[bytes]:
|
|
241
|
+
"""A WSGI application: `AccordServer(...).wsgi` can be served by any WSGI server."""
|
|
242
|
+
headers = {
|
|
243
|
+
k[5:].replace("_", "-"): str(v) for k, v in environ.items() if k.startswith("HTTP_")
|
|
244
|
+
}
|
|
245
|
+
if environ.get("CONTENT_TYPE"):
|
|
246
|
+
headers["content-type"] = str(environ["CONTENT_TYPE"])
|
|
247
|
+
if environ.get("CONTENT_LENGTH"):
|
|
248
|
+
headers["content-length"] = str(environ["CONTENT_LENGTH"])
|
|
249
|
+
length = js_safe_integer(headers.get("content-length", "0")) or 0
|
|
250
|
+
stream = environ["wsgi.input"]
|
|
251
|
+
# Read at most one byte past the limit: a larger body gets 413 without being buffered.
|
|
252
|
+
body = stream.read(min(max(length, 0), self.max_body_bytes + 1)) if length > 0 else b""
|
|
253
|
+
res = self.handle(
|
|
254
|
+
str(environ.get("REQUEST_METHOD", "GET")),
|
|
255
|
+
str(environ.get("PATH_INFO", "/")) or "/",
|
|
256
|
+
str(environ.get("QUERY_STRING", "")),
|
|
257
|
+
headers,
|
|
258
|
+
body,
|
|
259
|
+
)
|
|
260
|
+
reason = _REASONS.get(res.status, "Unknown")
|
|
261
|
+
start_response(f"{res.status} {reason}", res.headers)
|
|
262
|
+
return [res.body]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _first(q: Mapping[str, Sequence[str]], name: str, default: str) -> str:
|
|
266
|
+
v = q.get(name)
|
|
267
|
+
return v[0] if v else default
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_REASONS = {
|
|
271
|
+
200: "OK",
|
|
272
|
+
204: "No Content",
|
|
273
|
+
400: "Bad Request",
|
|
274
|
+
401: "Unauthorized",
|
|
275
|
+
403: "ForbiddenError",
|
|
276
|
+
404: "Not Found",
|
|
277
|
+
413: "Payload Too Large",
|
|
278
|
+
429: "Too Many Requests",
|
|
279
|
+
500: "Internal Server Error",
|
|
280
|
+
503: "Service Unavailable",
|
|
281
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Verifies `Authorization: Bearer <jwt>` against the app's JWKS (or a dev secret)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt.types import Options
|
|
11
|
+
|
|
12
|
+
from .define import Auth, Claims
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthError(Exception):
|
|
16
|
+
"""The request is not authenticated: answered 401."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Verifier = Callable[[str | None], Claims]
|
|
20
|
+
|
|
21
|
+
_BEARER = re.compile(r"Bearer (.+)")
|
|
22
|
+
ASYMMETRIC = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
|
|
23
|
+
ASYMMETRIC.append("EdDSA")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_verifier(auth: Auth) -> Verifier:
|
|
27
|
+
"""Like the TypeScript server (jose): `exp` and `nbf` checked when present, `iss` and `aud`
|
|
28
|
+
only when configured, `sub` required. With a JWKS, keys are fetched once and cached."""
|
|
29
|
+
jwks = (
|
|
30
|
+
jwt.PyJWKClient(auth.jwks_url, cache_keys=True, lifespan=auth.jwks_cache_seconds)
|
|
31
|
+
if auth.jwks_url
|
|
32
|
+
else None
|
|
33
|
+
)
|
|
34
|
+
options: Options = {"verify_aud": auth.audience is not None, "verify_iat": False}
|
|
35
|
+
|
|
36
|
+
def verify(authorization: str | None) -> Claims:
|
|
37
|
+
m = _BEARER.fullmatch(authorization or "")
|
|
38
|
+
if not m:
|
|
39
|
+
raise AuthError("missing bearer token")
|
|
40
|
+
token = m[1]
|
|
41
|
+
try:
|
|
42
|
+
if jwks is not None:
|
|
43
|
+
key: Any = jwks.get_signing_key_from_jwt(token).key
|
|
44
|
+
algorithms = ASYMMETRIC
|
|
45
|
+
else:
|
|
46
|
+
key = auth.hs256_secret
|
|
47
|
+
algorithms = ["HS256"]
|
|
48
|
+
payload: dict[str, Any] = jwt.decode(
|
|
49
|
+
token,
|
|
50
|
+
key,
|
|
51
|
+
algorithms=algorithms,
|
|
52
|
+
issuer=auth.issuer,
|
|
53
|
+
audience=auth.audience,
|
|
54
|
+
options=options,
|
|
55
|
+
)
|
|
56
|
+
except (jwt.PyJWTError, ValueError, TypeError) as e:
|
|
57
|
+
raise AuthError(f"invalid token: {e}") from e
|
|
58
|
+
sub = payload.get("sub")
|
|
59
|
+
if not isinstance(sub, str) or sub == "":
|
|
60
|
+
raise AuthError('token has no "sub" claim')
|
|
61
|
+
return payload
|
|
62
|
+
|
|
63
|
+
return verify
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Log compaction, from `app/packages/server/src/compact.ts` (ADR-0005, ADR-0008, ADR-0010)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
from accordsync_core import canonical_json, encode_op, parse_op_id
|
|
8
|
+
|
|
9
|
+
from .define import ServerDefinition
|
|
10
|
+
from .sync import FEED_LOCK, Pool, device_ttl_ms, load_record, op_hash
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CompactionResult(TypedDict):
|
|
14
|
+
watermark: int
|
|
15
|
+
"""Feed position every live device has applied: only ops at or below it were folded."""
|
|
16
|
+
records: int
|
|
17
|
+
opsFolded: int
|
|
18
|
+
tombstonesPruned: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compact(pool: Pool, definition: ServerDefinition) -> CompactionResult:
|
|
22
|
+
"""Folds the history of records every live device already has into one snapshot per record.
|
|
23
|
+
|
|
24
|
+
The snapshot takes the position of the last op it replaces, so live devices (past it) never
|
|
25
|
+
receive it; devices starting from zero and records entering a scope get it instead.
|
|
26
|
+
"""
|
|
27
|
+
min_ops = definition.compaction.min_ops
|
|
28
|
+
ttl_secs = device_ttl_ms(definition) / 1000
|
|
29
|
+
with pool.connection() as conn, conn.transaction():
|
|
30
|
+
# Exclusive: waits for running pushes (they hold the lock shared) and holds new ones back.
|
|
31
|
+
conn.execute("select pg_advisory_xact_lock(%s)", (FEED_LOCK,))
|
|
32
|
+
conn.execute("select set_config('accord.compaction', 'on', true)")
|
|
33
|
+
w = conn.execute(
|
|
34
|
+
"select coalesce((select min(cursor) from devices"
|
|
35
|
+
" where last_seen > now() - make_interval(secs => %s)), accord_horizon() - 1) as w",
|
|
36
|
+
(ttl_secs,),
|
|
37
|
+
).fetchone()
|
|
38
|
+
assert w is not None
|
|
39
|
+
watermark = int(w[0])
|
|
40
|
+
candidates: list[tuple[Any, ...]] = conn.execute(
|
|
41
|
+
"select record, max(pos) as last from feed where kind in ('op', 'snapshot')"
|
|
42
|
+
" group by record having max(pos) <= %s"
|
|
43
|
+
" and count(*) filter (where kind = 'op') >= %s",
|
|
44
|
+
(watermark, min_ops),
|
|
45
|
+
).fetchall()
|
|
46
|
+
|
|
47
|
+
ops_folded = 0
|
|
48
|
+
for record, last in candidates:
|
|
49
|
+
replica = load_record(conn, definition, record)
|
|
50
|
+
ops = replica.ops()
|
|
51
|
+
row = conn.execute("select scopes from records where record = %s", (record,)).fetchone()
|
|
52
|
+
if row is None:
|
|
53
|
+
raise RuntimeError(f"no records row for {record}")
|
|
54
|
+
if ops:
|
|
55
|
+
values = ", ".join(["(%s, %s, %s, %s)"] * len(ops))
|
|
56
|
+
params: list[object] = []
|
|
57
|
+
for op in ops:
|
|
58
|
+
p = parse_op_id(op.op_id)
|
|
59
|
+
params += [op.op_id, p.device, p.seq, op_hash(encode_op(op))]
|
|
60
|
+
conn.execute(
|
|
61
|
+
"insert into compacted_ops (op_id, device, op_seq, op_hash)" # noqa: S608 (placeholders only)
|
|
62
|
+
f" values {values} on conflict do nothing",
|
|
63
|
+
params,
|
|
64
|
+
)
|
|
65
|
+
conn.execute("delete from feed where record = %s and pos <= %s", (record, last))
|
|
66
|
+
conn.execute(
|
|
67
|
+
"insert into feed (pos, kind, record, op, scopes)"
|
|
68
|
+
" values (%s, 'snapshot', %s, %s::jsonb, %s::text[])",
|
|
69
|
+
(last, record, canonical_json(replica.snapshot_record(record).to_json()), row[0]),
|
|
70
|
+
)
|
|
71
|
+
ops_folded += len(ops)
|
|
72
|
+
# Entries a device can no longer retry (it has pushed past them) are no longer needed.
|
|
73
|
+
pruned = conn.execute(
|
|
74
|
+
"delete from compacted_ops c using devices d"
|
|
75
|
+
" where c.device = d.device_id and c.op_seq < d.push_floor"
|
|
76
|
+
).rowcount
|
|
77
|
+
return {
|
|
78
|
+
"watermark": watermark,
|
|
79
|
+
"records": len(candidates),
|
|
80
|
+
"opsFolded": ops_folded,
|
|
81
|
+
"tombstonesPruned": max(pruned, 0),
|
|
82
|
+
}
|
accordsync_server/db.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The connection pool: psycopg 3, autocommit connections, explicit transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from psycopg_pool import ConnectionPool
|
|
6
|
+
|
|
7
|
+
from .sync import Conn, Pool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _configure(conn: Conn) -> None:
|
|
11
|
+
# Every statement outside `with conn.transaction()` commits on its own; transactions are
|
|
12
|
+
# always explicit (BEGIN ... COMMIT), so nothing is ever left open between requests.
|
|
13
|
+
conn.autocommit = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_pool(database_url: str, *, size: int = 20, open: bool = True) -> Pool:
|
|
17
|
+
"""A pool of up to `size` connections (the TypeScript server's default is 20)."""
|
|
18
|
+
pool: Pool = ConnectionPool(
|
|
19
|
+
database_url,
|
|
20
|
+
min_size=1,
|
|
21
|
+
max_size=size,
|
|
22
|
+
configure=_configure,
|
|
23
|
+
open=open,
|
|
24
|
+
)
|
|
25
|
+
return pool
|