memd-engine 0.5.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.
- memd/__init__.py +7 -0
- memd/cli.py +699 -0
- memd/core/__init__.py +31 -0
- memd/core/schema.py +343 -0
- memd/engine/__init__.py +0 -0
- memd/engine/forward.py +1175 -0
- memd/engine/memory.py +3005 -0
- memd/harness/core.py +257 -0
- memd/harness/run.py +216 -0
- memd/harness/suites/__init__.py +0 -0
- memd/harness/suites/adversarial.py +113 -0
- memd/harness/suites/halumem_ops.py +54 -0
- memd/harness/suites/longmemeval_synthetic.py +221 -0
- memd/hosted/__init__.py +20 -0
- memd/hosted/app.py +258 -0
- memd/hosted/billing.py +909 -0
- memd/hosted/metering.py +460 -0
- memd/hosted/plans.py +138 -0
- memd/hosted/store.py +830 -0
- memd/index/__init__.py +3 -0
- memd/index/ann_usearch.py +1598 -0
- memd/index/sqlite_index.py +2413 -0
- memd/index/tantivy_lexical.py +886 -0
- memd/metrics.py +591 -0
- memd/pipeline/__init__.py +0 -0
- memd/pipeline/consolidation.py +174 -0
- memd/pipeline/embedder.py +370 -0
- memd/pipeline/extractor.py +567 -0
- memd/query/__init__.py +0 -0
- memd/query/dates.py +212 -0
- memd/query/fusion.py +90 -0
- memd/query/packing.py +671 -0
- memd/query/planner.py +76 -0
- memd/query/rerank.py +532 -0
- memd/sdk/__init__.py +3 -0
- memd/sdk/client.py +292 -0
- memd/server/__init__.py +0 -0
- memd/server/auth.py +423 -0
- memd/server/cluster.py +748 -0
- memd/server/http.py +976 -0
- memd/server/mcp_server.py +147 -0
- memd/storage/__init__.py +3 -0
- memd/storage/audit.py +504 -0
- memd/storage/crypto.py +1313 -0
- memd/storage/engine.py +5331 -0
- memd/storage/objectstore.py +597 -0
- memd/storage/replica.py +1040 -0
- memd/storage/s3store.py +1450 -0
- memd_engine-0.5.0.dist-info/METADATA +1230 -0
- memd_engine-0.5.0.dist-info/RECORD +54 -0
- memd_engine-0.5.0.dist-info/WHEEL +5 -0
- memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
- memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
- memd_engine-0.5.0.dist-info/top_level.txt +1 -0
memd/engine/forward.py
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
"""Write forwarding: several processes on one data root.
|
|
2
|
+
|
|
3
|
+
memd has one writer per namespace: the process holding its lock file (a
|
|
4
|
+
local data root) or its lease (s3://) - see NamespaceStore. A second
|
|
5
|
+
process opening such a namespace used to get NamespaceBusyError. With
|
|
6
|
+
forwarding on (`forwarding="auto"`, the default), its Memory sends that
|
|
7
|
+
namespace's writes - and its strongly consistent reads - to the process that
|
|
8
|
+
holds it instead, and serves eventual reads from its own read replica:
|
|
9
|
+
|
|
10
|
+
- a Memory that may write runs a small endpoint (TCP; 127.0.0.1 and an
|
|
11
|
+
ephemeral port unless `forward_host` / `forward_port` say otherwise) and
|
|
12
|
+
puts its address in what it holds: the lock file's content on a local
|
|
13
|
+
root, the lease holder string on S3
|
|
14
|
+
("host:pid;fwd=127.0.0.1:41234;id=<endpoint id>");
|
|
15
|
+
- a process that finds a namespace busy reads the holder from there,
|
|
16
|
+
connects and runs the call ON THE HOLDER - the same code a local call
|
|
17
|
+
runs, so quarantine, consolidation, session taint and the audit ledger
|
|
18
|
+
stay the writer's;
|
|
19
|
+
- a connection is authenticated both ways with a secret every process on
|
|
20
|
+
the root can read (`<data dir>/forward.secret`, mode 0600, created on
|
|
21
|
+
first use - or `forward_secret` / MEMD_FORWARD_SECRET), and every frame
|
|
22
|
+
after the handshake carries an HMAC-SHA256 under a key derived for that
|
|
23
|
+
connection (integrity and replay protection; NOT confidentiality);
|
|
24
|
+
- every forwarded call carries a request id, and a call that creates
|
|
25
|
+
records carries their ids, generated by the caller. A retry - after a
|
|
26
|
+
lost reply, a timeout, or a holder that died - is applied at most once:
|
|
27
|
+
a live holder answers a repeated request id from memory (or waits for
|
|
28
|
+
the first attempt still running), and a new holder finds the records in
|
|
29
|
+
the log it replayed when it took the namespace;
|
|
30
|
+
- when the holder goes away its lock frees at once (a lease after its
|
|
31
|
+
TTL), and the next forwarded call takes the namespace: the caller
|
|
32
|
+
becomes its writer and runs the call itself.
|
|
33
|
+
|
|
34
|
+
A process never applies a write to a namespace it does not hold: the local
|
|
35
|
+
path always goes through the namespace's lock or lease, and a holder that
|
|
36
|
+
no longer holds it answers "not owner" - nothing applied - to a forwarded
|
|
37
|
+
call.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import hashlib
|
|
42
|
+
import hmac
|
|
43
|
+
import ipaddress
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
import os
|
|
47
|
+
import random
|
|
48
|
+
import secrets
|
|
49
|
+
import select
|
|
50
|
+
import socket
|
|
51
|
+
import stat
|
|
52
|
+
import struct
|
|
53
|
+
import threading
|
|
54
|
+
import time
|
|
55
|
+
import uuid
|
|
56
|
+
from collections import OrderedDict
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
|
|
59
|
+
from memd.metrics import METRICS
|
|
60
|
+
from memd.storage.engine import NamespaceBusyError, NamespaceClosedError, owner_lock_path, read_owner_lock
|
|
61
|
+
|
|
62
|
+
_log = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
PROTOCOL = 1
|
|
65
|
+
FORWARDING_MODES = ("auto", "off")
|
|
66
|
+
SECRET_FILE = "forward.secret"
|
|
67
|
+
# a frame: 4-byte length, a kind byte, the body, and - once the handshake
|
|
68
|
+
# keyed the connection - an HMAC-SHA256 of (direction, sequence, kind, body)
|
|
69
|
+
_LEN = struct.Struct(">I")
|
|
70
|
+
_SEQ = struct.Struct(">Q")
|
|
71
|
+
_MAC_LEN = 32
|
|
72
|
+
MAX_FRAME = 256 << 20
|
|
73
|
+
_MAX_HELLO = 4096 # handshake frames: before authentication, keep them small
|
|
74
|
+
_IDLE_S = 600.0 # an idle authenticated connection is closed after this
|
|
75
|
+
_DONE_MAX = 8192 # write results remembered for a repeated request id...
|
|
76
|
+
_DONE_TTL_S = 900.0 # ...for this long
|
|
77
|
+
_STREAM_CHUNK = 256 << 10 # an export streams in chunks of whole lines about this big
|
|
78
|
+
|
|
79
|
+
# kinds of frame
|
|
80
|
+
_HELLO, _REQUEST, _REPLY, _STREAM, _DATA, _END = b"H", b"Q", b"R", b"S", b"D", b"E"
|
|
81
|
+
|
|
82
|
+
# calls that change the namespace: a repeated request id gets the first
|
|
83
|
+
# answer back instead of running again
|
|
84
|
+
WRITE_OPS = frozenset({"add", "add_events", "remember", "close_session", "delete", "delete_many",
|
|
85
|
+
"forget_ids", "destroy_namespace", "compact", "reembed", "audit"})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ForwardingError(NamespaceBusyError):
|
|
89
|
+
"""Another process holds the namespace and the call could not be
|
|
90
|
+
forwarded to it (no writer answered in time, or it does not accept
|
|
91
|
+
forwarded calls). Nothing was applied."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ForwardAuthError(ForwardingError):
|
|
95
|
+
"""The namespace's holder refused this process's credentials (or could
|
|
96
|
+
not prove its own): the processes do not share the forwarding secret."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ForwardTimeoutError(TimeoutError):
|
|
100
|
+
"""A forwarded write got no answer and no writer answered its retries in
|
|
101
|
+
time: it may or may not have been applied."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ForwardedCallError(RuntimeError):
|
|
105
|
+
"""The holder raised an error that has no counterpart here."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ForwardNotReady(RuntimeError):
|
|
109
|
+
"""(holder side) the Memory is still opening: the caller retries."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Unreachable(Exception):
|
|
113
|
+
"""Nothing was sent: the holder cannot be reached (or another process
|
|
114
|
+
answers at its address)."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Uncertain(Exception):
|
|
118
|
+
"""The request was sent and no answer came back."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _Wait(Exception):
|
|
122
|
+
"""Nobody can be reached right now (a lock or lease changing hands)."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _AuthFailed(Exception):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
LOCAL = object() # Forwarder.call: run the call here - this process took the namespace
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- config
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class ForwardConfig:
|
|
137
|
+
mode: str = "auto"
|
|
138
|
+
secret: str | None = None # forward_secret / MEMD_FORWARD_SECRET
|
|
139
|
+
host: str = "127.0.0.1" # the endpoint's bind address
|
|
140
|
+
port: int = 0 # 0: an ephemeral port
|
|
141
|
+
advertise: str | None = None # host:port other processes connect to (default: bind address)
|
|
142
|
+
wait_s: float = 30.0 # how long a call keeps looking for a writer that answers
|
|
143
|
+
timeout_s: float = 300.0 # how long one attempt waits for the holder's answer
|
|
144
|
+
connect_timeout_s: float = 2.0
|
|
145
|
+
max_inflight: int = 64 # holder: forwarded calls executing at once
|
|
146
|
+
max_conns: int = 256 # holder: connections at once
|
|
147
|
+
queue_wait_s: float = 10.0 # holder: a call waits this long for a slot, then "overloaded"
|
|
148
|
+
check_s: float = 0.0 # S3: how often a call waiting for its answer re-checks the lease
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def enabled(self) -> bool:
|
|
152
|
+
return self.mode == "auto"
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_config(cls, cfg: dict, forwarding: str | None = None, *,
|
|
156
|
+
lease_ttl_s: float | None = None) -> "ForwardConfig":
|
|
157
|
+
"""From Memory(config=...) keys, else the MEMD_FORWARD* environment.
|
|
158
|
+
`lease_ttl_s` (an s3:// root): a crashed holder's lease frees only
|
|
159
|
+
after it, so a call waits that long plus a margin by default."""
|
|
160
|
+
def pick(key: str, env: str, default):
|
|
161
|
+
v = cfg.get(key)
|
|
162
|
+
if v is None or v == "":
|
|
163
|
+
v = os.environ.get(env)
|
|
164
|
+
return default if v is None or v == "" else v
|
|
165
|
+
|
|
166
|
+
mode = str(forwarding or pick("forwarding", "MEMD_FORWARDING", "auto")).strip().lower()
|
|
167
|
+
if mode not in FORWARDING_MODES:
|
|
168
|
+
raise ValueError(f"forwarding must be one of {FORWARDING_MODES}, got {mode!r}")
|
|
169
|
+
wait_default = 30.0 if lease_ttl_s is None else max(30.0, float(lease_ttl_s) + 15.0)
|
|
170
|
+
out = cls(
|
|
171
|
+
mode=mode,
|
|
172
|
+
secret=pick("forward_secret", "MEMD_FORWARD_SECRET", None),
|
|
173
|
+
host=str(pick("forward_host", "MEMD_FORWARD_HOST", "127.0.0.1")),
|
|
174
|
+
port=int(pick("forward_port", "MEMD_FORWARD_PORT", 0)),
|
|
175
|
+
advertise=pick("forward_advertise", "MEMD_FORWARD_ADVERTISE", None),
|
|
176
|
+
wait_s=float(pick("forward_wait_s", "MEMD_FORWARD_WAIT_S", wait_default)),
|
|
177
|
+
timeout_s=float(pick("forward_timeout_s", "MEMD_FORWARD_TIMEOUT_S", 300.0)),
|
|
178
|
+
max_inflight=int(pick("forward_max_inflight", "MEMD_FORWARD_MAX_INFLIGHT", 64)),
|
|
179
|
+
queue_wait_s=float(pick("forward_queue_wait_s", "MEMD_FORWARD_QUEUE_WAIT_S", 10.0)),
|
|
180
|
+
check_s=0.0 if lease_ttl_s is None else max(0.5, float(lease_ttl_s) / 3.0),
|
|
181
|
+
)
|
|
182
|
+
if out.secret is not None and len(out.secret) < 16:
|
|
183
|
+
raise ValueError("forward_secret / MEMD_FORWARD_SECRET must be at least 16 characters")
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ForwardSecretError(PermissionError):
|
|
188
|
+
"""The forwarding secret file is not one this process may trust: a
|
|
189
|
+
symbolic link, not a regular file, another user's, or open to others."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _read_secret(path: str) -> bytes | None:
|
|
193
|
+
"""The secret file's content - None when there is none - read without
|
|
194
|
+
following a symbolic link, and only from a regular file of this
|
|
195
|
+
process's user that nobody else may read or write (0600 or narrower)."""
|
|
196
|
+
try:
|
|
197
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
return None
|
|
200
|
+
except OSError as ex:
|
|
201
|
+
if os.path.islink(path):
|
|
202
|
+
raise ForwardSecretError(
|
|
203
|
+
f"the forwarding secret {path} is a symbolic link: refused") from ex
|
|
204
|
+
raise
|
|
205
|
+
try:
|
|
206
|
+
st = os.fstat(fd)
|
|
207
|
+
if not stat.S_ISREG(st.st_mode):
|
|
208
|
+
raise ForwardSecretError(f"the forwarding secret {path} is not a regular file: refused")
|
|
209
|
+
uid = os.getuid() if hasattr(os, "getuid") else st.st_uid
|
|
210
|
+
if st.st_uid != uid:
|
|
211
|
+
raise ForwardSecretError(
|
|
212
|
+
f"the forwarding secret {path} belongs to uid {st.st_uid}, not to this process's "
|
|
213
|
+
f"user (uid {uid}): refused")
|
|
214
|
+
if st.st_mode & 0o077:
|
|
215
|
+
raise ForwardSecretError(
|
|
216
|
+
f"the forwarding secret {path} has mode {stat.S_IMODE(st.st_mode):04o}: other users "
|
|
217
|
+
"may read or write it - refused (it must be 0600)")
|
|
218
|
+
return os.read(fd, 4096).strip()
|
|
219
|
+
finally:
|
|
220
|
+
os.close(fd)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_secret(cfg: ForwardConfig, data_dir: str) -> str:
|
|
224
|
+
"""The forwarding secret: the configured one, else `<data_dir>/
|
|
225
|
+
forward.secret` - created on first use (mode 0600), so every process of
|
|
226
|
+
the user that can read the data directory shares it. ForwardSecretError
|
|
227
|
+
when the file is not one to trust (see _read_secret)."""
|
|
228
|
+
if cfg.secret:
|
|
229
|
+
return cfg.secret
|
|
230
|
+
path = os.path.join(data_dir, SECRET_FILE)
|
|
231
|
+
for _ in range(100):
|
|
232
|
+
got = _read_secret(path)
|
|
233
|
+
if got is not None:
|
|
234
|
+
if len(got) >= 32:
|
|
235
|
+
return got.decode("ascii", errors="replace")
|
|
236
|
+
time.sleep(0.02) # being written by the process creating it
|
|
237
|
+
continue
|
|
238
|
+
# created whole or not at all: written to a temp file, then linked
|
|
239
|
+
# into place - which fails if another process got there first
|
|
240
|
+
tmp = f"{path}.{os.getpid()}-{secrets.token_hex(4)}.tmp"
|
|
241
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
242
|
+
try:
|
|
243
|
+
os.write(fd, secrets.token_hex(32).encode())
|
|
244
|
+
os.fsync(fd)
|
|
245
|
+
finally:
|
|
246
|
+
os.close(fd)
|
|
247
|
+
try:
|
|
248
|
+
os.link(tmp, path)
|
|
249
|
+
except FileExistsError:
|
|
250
|
+
pass
|
|
251
|
+
except OSError:
|
|
252
|
+
# no hard links here: an exclusive create (a reader may see it
|
|
253
|
+
# empty for a moment - the loop above waits for that)
|
|
254
|
+
try:
|
|
255
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
256
|
+
| getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
257
|
+
try:
|
|
258
|
+
os.write(fd, secrets.token_hex(32).encode())
|
|
259
|
+
os.fsync(fd)
|
|
260
|
+
finally:
|
|
261
|
+
os.close(fd)
|
|
262
|
+
except FileExistsError:
|
|
263
|
+
pass
|
|
264
|
+
finally:
|
|
265
|
+
try:
|
|
266
|
+
os.unlink(tmp)
|
|
267
|
+
except OSError:
|
|
268
|
+
pass
|
|
269
|
+
raise RuntimeError(f"could not read or create the forwarding secret {path}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------- holders
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class Endpoint:
|
|
277
|
+
host: str
|
|
278
|
+
port: int
|
|
279
|
+
endpoint_id: str
|
|
280
|
+
hostname: str # the machine the holder runs on
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def holder_string(address: str, endpoint_id: str) -> str:
|
|
284
|
+
"""The lock-file content / lease holder of a process that accepts
|
|
285
|
+
forwarded calls (no newline: a lease body is "<holder>\\n<stamp>")."""
|
|
286
|
+
return f"{socket.gethostname()}:{os.getpid()};fwd={address};id={endpoint_id}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def parse_holder(holder: str | None) -> Endpoint | None:
|
|
290
|
+
"""The endpoint a holder string advertises; None when it advertises
|
|
291
|
+
none (forwarding off, a cluster node, an older memd)."""
|
|
292
|
+
if not holder or ";fwd=" not in holder:
|
|
293
|
+
return None
|
|
294
|
+
head, *fields = holder.strip().split(";")
|
|
295
|
+
kv = dict(f.split("=", 1) for f in fields if "=" in f)
|
|
296
|
+
addr, eid = kv.get("fwd"), kv.get("id")
|
|
297
|
+
if not addr or not eid:
|
|
298
|
+
return None
|
|
299
|
+
host, _, port = addr.rpartition(":")
|
|
300
|
+
try:
|
|
301
|
+
return Endpoint(host.strip("[]"), int(port), eid, head.rsplit(":", 1)[0])
|
|
302
|
+
except ValueError:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _unspecified(host: str) -> bool:
|
|
307
|
+
"""A bind address meaning every interface: not one others can connect to."""
|
|
308
|
+
if not host:
|
|
309
|
+
return True
|
|
310
|
+
try:
|
|
311
|
+
return ipaddress.ip_address(host).is_unspecified
|
|
312
|
+
except ValueError:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _loopback(host: str) -> bool:
|
|
317
|
+
if host == "localhost":
|
|
318
|
+
return True
|
|
319
|
+
try:
|
|
320
|
+
return ipaddress.ip_address(host).is_loopback
|
|
321
|
+
except ValueError:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------- framing
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _shutdown(sock: socket.socket) -> None:
|
|
329
|
+
try:
|
|
330
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _jdump(obj) -> bytes:
|
|
336
|
+
return json.dumps(obj, separators=(",", ":"), default=_jdefault).encode()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _jdefault(o):
|
|
340
|
+
name = getattr(o, "name", None)
|
|
341
|
+
if isinstance(name, str) and hasattr(type(o), "__members__"):
|
|
342
|
+
return name # an enum (Source): by name
|
|
343
|
+
if isinstance(o, (set, frozenset, tuple)):
|
|
344
|
+
return list(o)
|
|
345
|
+
item = getattr(o, "item", None) # numpy scalars
|
|
346
|
+
if callable(item):
|
|
347
|
+
return item()
|
|
348
|
+
raise TypeError(f"{type(o).__name__} cannot be forwarded")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _json_object(body: bytes) -> dict | None:
|
|
352
|
+
"""A frame's JSON object, or None (not JSON, or not an object)."""
|
|
353
|
+
try:
|
|
354
|
+
got = json.loads(body)
|
|
355
|
+
except ValueError:
|
|
356
|
+
return None
|
|
357
|
+
return got if isinstance(got, dict) else None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _hmac(key: bytes, *parts: bytes) -> bytes:
|
|
361
|
+
return hmac.new(key, b"".join(parts), hashlib.sha256).digest()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class _Channel:
|
|
365
|
+
"""Length-prefixed frames over one socket; once keyed (after the
|
|
366
|
+
handshake), every frame carries an HMAC over its direction, its
|
|
367
|
+
sequence number and its bytes - a frame altered, dropped, replayed or
|
|
368
|
+
reflected fails the check and closes the connection."""
|
|
369
|
+
|
|
370
|
+
def __init__(self, sock: socket.socket, side: bytes):
|
|
371
|
+
self.sock = sock
|
|
372
|
+
self.key: bytes | None = None
|
|
373
|
+
self.out_tag, self.in_tag = (b"c", b"s") if side == b"c" else (b"s", b"c")
|
|
374
|
+
self.seq_out = self.seq_in = 0
|
|
375
|
+
|
|
376
|
+
def send(self, kind: bytes, body: bytes) -> None:
|
|
377
|
+
mac = b""
|
|
378
|
+
if self.key is not None:
|
|
379
|
+
mac = _hmac(self.key, self.out_tag, _SEQ.pack(self.seq_out), kind, body)
|
|
380
|
+
self.seq_out += 1
|
|
381
|
+
head = _LEN.pack(1 + len(body) + len(mac)) + kind
|
|
382
|
+
if len(body) < 65536:
|
|
383
|
+
self.sock.sendall(head + body + mac)
|
|
384
|
+
else:
|
|
385
|
+
self.sock.sendall(head)
|
|
386
|
+
self.sock.sendall(body)
|
|
387
|
+
self.sock.sendall(mac)
|
|
388
|
+
|
|
389
|
+
def recv(self, limit: int = MAX_FRAME) -> tuple[bytes, bytes]:
|
|
390
|
+
n = _LEN.unpack(self._exact(_LEN.size))[0]
|
|
391
|
+
extra = 1 + (_MAC_LEN if self.key is not None else 0)
|
|
392
|
+
if n < extra or n > limit + extra:
|
|
393
|
+
raise _AuthFailed(f"bad frame length {n}")
|
|
394
|
+
frame = self._exact(n)
|
|
395
|
+
kind = frame[:1]
|
|
396
|
+
if self.key is None:
|
|
397
|
+
return kind, frame[1:]
|
|
398
|
+
body, mac = frame[1:-_MAC_LEN], frame[-_MAC_LEN:]
|
|
399
|
+
want = _hmac(self.key, self.in_tag, _SEQ.pack(self.seq_in), kind, body)
|
|
400
|
+
if not hmac.compare_digest(mac, want):
|
|
401
|
+
raise _AuthFailed("frame authentication failed")
|
|
402
|
+
self.seq_in += 1
|
|
403
|
+
return kind, body
|
|
404
|
+
|
|
405
|
+
def _exact(self, n: int) -> bytes:
|
|
406
|
+
buf = bytearray(n)
|
|
407
|
+
view = memoryview(buf)
|
|
408
|
+
got = 0
|
|
409
|
+
while got < n:
|
|
410
|
+
k = self.sock.recv_into(view[got:], min(n - got, 1 << 20))
|
|
411
|
+
if not k:
|
|
412
|
+
raise ConnectionError("connection closed")
|
|
413
|
+
got += k
|
|
414
|
+
return bytes(buf)
|
|
415
|
+
|
|
416
|
+
def close(self) -> None:
|
|
417
|
+
try:
|
|
418
|
+
self.sock.close()
|
|
419
|
+
except OSError:
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _keys(secret: str, endpoint_id: str, nonce: str, client: str, cnonce: str) -> tuple[str, str, bytes]:
|
|
424
|
+
"""(client proof, server proof, connection key) for one handshake - the
|
|
425
|
+
cluster router's request signature (memd.server.cluster._sign) over
|
|
426
|
+
both nonces, so neither side's proof can be replayed to the other.
|
|
427
|
+
(Imported here: a single server never loads the cluster module.)"""
|
|
428
|
+
from memd.server.cluster import _sign
|
|
429
|
+
|
|
430
|
+
cproof = _sign(secret, endpoint_id, nonce, client, "forward-client", cnonce)
|
|
431
|
+
sproof = _sign(secret, endpoint_id, nonce, client, "forward-server", cnonce)
|
|
432
|
+
key = bytes.fromhex(_sign(secret, endpoint_id, nonce, client, "forward-key", cnonce))
|
|
433
|
+
return cproof, sproof, key
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------------- holder side
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _Stream:
|
|
440
|
+
"""A handler result sent as a stream (an export): `meta` first, then
|
|
441
|
+
the chunks of `chunks`."""
|
|
442
|
+
|
|
443
|
+
def __init__(self, meta: dict, chunks):
|
|
444
|
+
self.meta = meta
|
|
445
|
+
self.chunks = chunks
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _error_payload(ex: BaseException) -> dict:
|
|
449
|
+
from memd.storage.objectstore import LeaseLostError
|
|
450
|
+
|
|
451
|
+
if isinstance(ex, (NamespaceBusyError, LeaseLostError, NamespaceClosedError)):
|
|
452
|
+
# this process does not hold the namespace (any more): nothing was
|
|
453
|
+
# applied, the caller finds the writer again
|
|
454
|
+
return {"code": "not_owner", "retry": True, "message": str(ex)[:500]}
|
|
455
|
+
if isinstance(ex, ForwardNotReady):
|
|
456
|
+
return {"code": "not_ready", "retry": True, "message": str(ex)[:500]}
|
|
457
|
+
return {"code": "error", "retry": False, "type": type(ex).__name__, "message": str(ex)[:4000]}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class ForwardServer:
|
|
461
|
+
"""The endpoint of a process that may hold namespaces. `handler(op, ns,
|
|
462
|
+
args, ids)` runs one forwarded call (see Memory._serve_forwarded) and
|
|
463
|
+
returns a JSON-able result or a _Stream; NamespaceBusyError /
|
|
464
|
+
LeaseLostError from it mean "not the writer, nothing applied"."""
|
|
465
|
+
|
|
466
|
+
def __init__(self, cfg: ForwardConfig, secret: str, handler):
|
|
467
|
+
self.cfg = cfg
|
|
468
|
+
self.secret = secret
|
|
469
|
+
self.handler = handler
|
|
470
|
+
self.endpoint_id = secrets.token_hex(8)
|
|
471
|
+
self._sock = socket.create_server((cfg.host, int(cfg.port)), backlog=128)
|
|
472
|
+
port = self._sock.getsockname()[1]
|
|
473
|
+
if cfg.advertise:
|
|
474
|
+
address = str(cfg.advertise)
|
|
475
|
+
elif _unspecified(cfg.host):
|
|
476
|
+
self._sock.close()
|
|
477
|
+
raise ValueError("forward_host binds every interface: set forward_advertise / "
|
|
478
|
+
"MEMD_FORWARD_ADVERTISE to the host:port other processes reach this one at")
|
|
479
|
+
else:
|
|
480
|
+
address = f"[{cfg.host}]:{port}" if ":" in cfg.host else f"{cfg.host}:{port}"
|
|
481
|
+
self.address = address
|
|
482
|
+
self.holder = holder_string(address, self.endpoint_id)
|
|
483
|
+
self._lock = threading.Lock()
|
|
484
|
+
self._idle = threading.Condition(self._lock)
|
|
485
|
+
self._conns: set[socket.socket] = set()
|
|
486
|
+
self._inflight = 0
|
|
487
|
+
self._slots = threading.BoundedSemaphore(max(1, int(cfg.max_inflight)))
|
|
488
|
+
# Calls run on these threads, never on a connection's own: a thread
|
|
489
|
+
# that reads an index keeps a SQLite connection of its own until the
|
|
490
|
+
# index closes, so a thread per connection kept two descriptors per
|
|
491
|
+
# connection for good. At most max_inflight, created as needed, reused.
|
|
492
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
493
|
+
|
|
494
|
+
self._pool = ThreadPoolExecutor(max_workers=max(1, int(cfg.max_inflight)),
|
|
495
|
+
thread_name_prefix="memd-forward-call")
|
|
496
|
+
self._done: "OrderedDict[str, tuple[float, dict]]" = OrderedDict()
|
|
497
|
+
self._running: dict[str, threading.Event] = {}
|
|
498
|
+
self._closing = False
|
|
499
|
+
self._thread: threading.Thread | None = None
|
|
500
|
+
|
|
501
|
+
def start(self) -> None:
|
|
502
|
+
self._thread = threading.Thread(target=self._accept_loop, daemon=True, name="memd-forward")
|
|
503
|
+
self._thread.start()
|
|
504
|
+
|
|
505
|
+
def stop(self, drain_s: float = 30.0) -> None:
|
|
506
|
+
"""Stop serving: new calls are answered "not owner" (the callers
|
|
507
|
+
find the next writer), calls already running finish - within
|
|
508
|
+
`drain_s` - and then every connection is closed."""
|
|
509
|
+
with self._lock:
|
|
510
|
+
if self._closing:
|
|
511
|
+
return
|
|
512
|
+
self._closing = True
|
|
513
|
+
try:
|
|
514
|
+
self._sock.shutdown(socket.SHUT_RDWR)
|
|
515
|
+
except OSError:
|
|
516
|
+
pass
|
|
517
|
+
self._sock.close()
|
|
518
|
+
deadline = time.monotonic() + drain_s
|
|
519
|
+
with self._lock:
|
|
520
|
+
while self._inflight and time.monotonic() < deadline:
|
|
521
|
+
self._idle.wait(max(0.01, deadline - time.monotonic()))
|
|
522
|
+
conns = list(self._conns)
|
|
523
|
+
for c in conns:
|
|
524
|
+
try:
|
|
525
|
+
c.shutdown(socket.SHUT_RDWR)
|
|
526
|
+
except OSError:
|
|
527
|
+
pass
|
|
528
|
+
if self._thread is not None and self._thread is not threading.current_thread():
|
|
529
|
+
self._thread.join(timeout=5)
|
|
530
|
+
self._pool.shutdown(wait=False)
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------ connections
|
|
533
|
+
|
|
534
|
+
def _accept_loop(self) -> None:
|
|
535
|
+
# (a timeout, so the loop also ends where shutting the listening
|
|
536
|
+
# socket down does not wake an accept() blocked on it)
|
|
537
|
+
self._sock.settimeout(1.0)
|
|
538
|
+
while True:
|
|
539
|
+
try:
|
|
540
|
+
conn, _addr = self._sock.accept()
|
|
541
|
+
except socket.timeout:
|
|
542
|
+
if self._closing:
|
|
543
|
+
return
|
|
544
|
+
continue
|
|
545
|
+
except OSError:
|
|
546
|
+
return # closed (stop)
|
|
547
|
+
with self._lock:
|
|
548
|
+
refuse = self._closing or len(self._conns) >= self.cfg.max_conns
|
|
549
|
+
if not refuse:
|
|
550
|
+
self._conns.add(conn)
|
|
551
|
+
if refuse:
|
|
552
|
+
METRICS.inc("memd_forward_refused_total", reason="connections",
|
|
553
|
+
help="forwarded connections refused (too many at once, or closing)")
|
|
554
|
+
conn.close()
|
|
555
|
+
continue
|
|
556
|
+
threading.Thread(target=self._serve_conn, args=(conn,), daemon=True,
|
|
557
|
+
name="memd-forward-conn").start()
|
|
558
|
+
|
|
559
|
+
def _serve_conn(self, conn: socket.socket) -> None:
|
|
560
|
+
ch = _Channel(conn, b"s")
|
|
561
|
+
# the whole handshake, however slowly its bytes come, within 5 s
|
|
562
|
+
cut = threading.Timer(max(5.0, self.cfg.connect_timeout_s * 2), _shutdown, args=(conn,))
|
|
563
|
+
cut.daemon = True
|
|
564
|
+
try:
|
|
565
|
+
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
566
|
+
conn.settimeout(max(5.0, self.cfg.connect_timeout_s * 2))
|
|
567
|
+
cut.start()
|
|
568
|
+
ok = self._handshake(ch)
|
|
569
|
+
cut.cancel()
|
|
570
|
+
if not ok:
|
|
571
|
+
return
|
|
572
|
+
conn.settimeout(_IDLE_S)
|
|
573
|
+
while True:
|
|
574
|
+
kind, body = ch.recv()
|
|
575
|
+
req = _json_object(body) if kind == _REQUEST else None
|
|
576
|
+
if req is None:
|
|
577
|
+
return
|
|
578
|
+
self._handle(ch, req)
|
|
579
|
+
except _AuthFailed as ex:
|
|
580
|
+
self._refused(ch, str(ex))
|
|
581
|
+
except (OSError, ConnectionError, ValueError):
|
|
582
|
+
pass # the caller went away (or sent garbage)
|
|
583
|
+
except Exception: # noqa: BLE001 - one connection never takes the endpoint down
|
|
584
|
+
_log.exception("memd forward: connection failed")
|
|
585
|
+
finally:
|
|
586
|
+
cut.cancel()
|
|
587
|
+
with self._lock:
|
|
588
|
+
self._conns.discard(conn)
|
|
589
|
+
ch.close()
|
|
590
|
+
|
|
591
|
+
def _handshake(self, ch: _Channel) -> bool:
|
|
592
|
+
nonce = secrets.token_hex(16)
|
|
593
|
+
ch.send(_HELLO, _jdump({"v": PROTOCOL, "id": self.endpoint_id, "nonce": nonce}))
|
|
594
|
+
kind, body = ch.recv(_MAX_HELLO)
|
|
595
|
+
msg = _json_object(body) if kind == _HELLO else None
|
|
596
|
+
if msg is None:
|
|
597
|
+
self._refused(ch, "a malformed handshake")
|
|
598
|
+
return False
|
|
599
|
+
client, cnonce, proof = str(msg.get("client", ""))[:128], str(msg.get("cnonce", ""))[:64], \
|
|
600
|
+
str(msg.get("proof", ""))
|
|
601
|
+
cproof, sproof, key = _keys(self.secret, self.endpoint_id, nonce, client, cnonce)
|
|
602
|
+
if len(cnonce) < 16 or not hmac.compare_digest(proof, cproof):
|
|
603
|
+
self._refused(ch, "credentials that do not prove the secret")
|
|
604
|
+
ch.send(_HELLO, _jdump({"ok": False, "error": "auth"}))
|
|
605
|
+
return False
|
|
606
|
+
ch.send(_HELLO, _jdump({"ok": True, "proof": sproof}))
|
|
607
|
+
ch.key = key
|
|
608
|
+
return True
|
|
609
|
+
|
|
610
|
+
@staticmethod
|
|
611
|
+
def _refused(ch: _Channel, why: str) -> None:
|
|
612
|
+
"""One line and one count per refused connection - never a traceback."""
|
|
613
|
+
METRICS.inc("memd_forward_auth_failures_total",
|
|
614
|
+
help="forwarded connections refused: bad credentials or a tampered frame")
|
|
615
|
+
try:
|
|
616
|
+
peer = "%s:%s" % ch.sock.getpeername()[:2]
|
|
617
|
+
except OSError:
|
|
618
|
+
peer = "?"
|
|
619
|
+
_log.warning("memd forward: refused a connection from %s: %s", peer, why)
|
|
620
|
+
|
|
621
|
+
# ------------------------------------------------------------ calls
|
|
622
|
+
|
|
623
|
+
def _handle(self, ch: _Channel, req: dict) -> None:
|
|
624
|
+
op = str(req.get("op") or "")
|
|
625
|
+
rid = str(req.get("rid") or "")
|
|
626
|
+
if op in WRITE_OPS and rid:
|
|
627
|
+
resp = self._write(req, rid)
|
|
628
|
+
else:
|
|
629
|
+
resp = self._execute(req)
|
|
630
|
+
if isinstance(resp, _Stream):
|
|
631
|
+
self._send_stream(ch, resp)
|
|
632
|
+
return
|
|
633
|
+
try:
|
|
634
|
+
body = _jdump(resp)
|
|
635
|
+
except (TypeError, ValueError) as ex: # a result that cannot travel
|
|
636
|
+
body = _jdump({"ok": False, "error": _error_payload(ex)})
|
|
637
|
+
ch.send(_REPLY, body)
|
|
638
|
+
|
|
639
|
+
def _write(self, req: dict, rid: str) -> dict:
|
|
640
|
+
"""A write runs once per request id: a repeat gets the first
|
|
641
|
+
answer, or waits for the first attempt still running."""
|
|
642
|
+
while True:
|
|
643
|
+
with self._lock:
|
|
644
|
+
hit = self._done.get(rid)
|
|
645
|
+
if hit is not None:
|
|
646
|
+
METRICS.inc("memd_forward_repeats_total",
|
|
647
|
+
help="forwarded writes answered from an earlier attempt (not run again)")
|
|
648
|
+
return hit[1]
|
|
649
|
+
ev = self._running.get(rid)
|
|
650
|
+
mine = ev is None
|
|
651
|
+
if mine:
|
|
652
|
+
ev = self._running[rid] = threading.Event()
|
|
653
|
+
if not mine:
|
|
654
|
+
if not ev.wait(self.cfg.timeout_s):
|
|
655
|
+
return {"ok": False, "error": {"code": "overloaded", "retry": True,
|
|
656
|
+
"message": "the first attempt is still running"}}
|
|
657
|
+
continue
|
|
658
|
+
resp: dict = {"ok": False, "error": {"code": "error", "retry": False, "type": "RuntimeError",
|
|
659
|
+
"message": "not run"}}
|
|
660
|
+
try:
|
|
661
|
+
resp = self._execute(req)
|
|
662
|
+
finally:
|
|
663
|
+
with self._lock:
|
|
664
|
+
if resp.get("ok") or not (resp.get("error") or {}).get("retry"):
|
|
665
|
+
now = time.monotonic()
|
|
666
|
+
self._done[rid] = (now, resp)
|
|
667
|
+
while self._done and (len(self._done) > _DONE_MAX
|
|
668
|
+
or next(iter(self._done.values()))[0] < now - _DONE_TTL_S):
|
|
669
|
+
self._done.popitem(last=False)
|
|
670
|
+
self._running.pop(rid, None)
|
|
671
|
+
ev.set()
|
|
672
|
+
return resp
|
|
673
|
+
|
|
674
|
+
def _begin(self) -> dict | None:
|
|
675
|
+
if not self._slots.acquire(timeout=self.cfg.queue_wait_s):
|
|
676
|
+
METRICS.inc("memd_forward_refused_total", reason="overloaded")
|
|
677
|
+
return {"ok": False, "error": {"code": "overloaded", "retry": True,
|
|
678
|
+
"message": "too many forwarded calls running"}}
|
|
679
|
+
with self._lock:
|
|
680
|
+
if self._closing:
|
|
681
|
+
self._slots.release()
|
|
682
|
+
return {"ok": False, "error": {"code": "not_owner", "retry": True,
|
|
683
|
+
"message": "the holder is closing"}}
|
|
684
|
+
self._inflight += 1
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
def _end(self) -> None:
|
|
688
|
+
with self._lock:
|
|
689
|
+
self._inflight -= 1
|
|
690
|
+
if not self._inflight:
|
|
691
|
+
self._idle.notify_all()
|
|
692
|
+
self._slots.release()
|
|
693
|
+
|
|
694
|
+
def _execute(self, req: dict):
|
|
695
|
+
refused = self._begin()
|
|
696
|
+
if refused is not None:
|
|
697
|
+
return refused
|
|
698
|
+
op = str(req.get("op") or "")
|
|
699
|
+
t0 = time.monotonic()
|
|
700
|
+
streaming = False
|
|
701
|
+
try:
|
|
702
|
+
try:
|
|
703
|
+
fut = self._pool.submit(self.handler, op, str(req.get("ns") or ""),
|
|
704
|
+
req.get("args") or {}, req.get("ids"))
|
|
705
|
+
except RuntimeError: # shut down meanwhile: closing
|
|
706
|
+
return {"ok": False, "error": {"code": "not_owner", "retry": True,
|
|
707
|
+
"message": "the holder is closing"}}
|
|
708
|
+
out = fut.result()
|
|
709
|
+
if isinstance(out, _Stream):
|
|
710
|
+
streaming = True # _send_stream ends it
|
|
711
|
+
return out
|
|
712
|
+
METRICS.inc("memd_forward_served_total", op=op, outcome="ok",
|
|
713
|
+
help="forwarded calls this process ran for another")
|
|
714
|
+
return {"ok": True, "result": out}
|
|
715
|
+
except BaseException as ex: # noqa: BLE001 - every failure is the caller's answer
|
|
716
|
+
err = _error_payload(ex)
|
|
717
|
+
METRICS.inc("memd_forward_served_total", op=op, outcome=err["code"])
|
|
718
|
+
return {"ok": False, "error": err}
|
|
719
|
+
finally:
|
|
720
|
+
if not streaming:
|
|
721
|
+
self._end()
|
|
722
|
+
METRICS.observe("memd_forward_serve_ms", (time.monotonic() - t0) * 1000,
|
|
723
|
+
help="forwarded calls run for another process (ms)", op=op)
|
|
724
|
+
|
|
725
|
+
def _send_stream(self, ch: _Channel, st: _Stream) -> None:
|
|
726
|
+
try:
|
|
727
|
+
ch.send(_STREAM, _jdump(st.meta))
|
|
728
|
+
buf: list[bytes] = []
|
|
729
|
+
size = 0
|
|
730
|
+
trailer: dict = {"ok": True}
|
|
731
|
+
try:
|
|
732
|
+
for piece in st.chunks:
|
|
733
|
+
buf.append(piece)
|
|
734
|
+
size += len(piece)
|
|
735
|
+
if size >= _STREAM_CHUNK:
|
|
736
|
+
ch.send(_DATA, b"".join(buf))
|
|
737
|
+
buf, size = [], 0
|
|
738
|
+
if buf:
|
|
739
|
+
ch.send(_DATA, b"".join(buf))
|
|
740
|
+
except (OSError, ConnectionError):
|
|
741
|
+
raise
|
|
742
|
+
except BaseException as ex: # noqa: BLE001 - reported to the caller
|
|
743
|
+
trailer = {"ok": False, "error": _error_payload(ex)}
|
|
744
|
+
ch.send(_END, _jdump(trailer))
|
|
745
|
+
finally:
|
|
746
|
+
close = getattr(st.chunks, "close", None)
|
|
747
|
+
if callable(close):
|
|
748
|
+
close()
|
|
749
|
+
self._end()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# ---------------------------------------------------------------- caller side
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
class _StreamReader:
|
|
756
|
+
"""The chunks of a streamed answer (an export), as whole lines."""
|
|
757
|
+
|
|
758
|
+
def __init__(self, client: "ForwardClient", key, ch: _Channel, meta: dict, timeout_s: float):
|
|
759
|
+
self._client, self._key, self._ch = client, key, ch
|
|
760
|
+
self.meta = meta
|
|
761
|
+
self._timeout_s = timeout_s
|
|
762
|
+
|
|
763
|
+
def __iter__(self):
|
|
764
|
+
ch = self._ch
|
|
765
|
+
ok = False
|
|
766
|
+
try:
|
|
767
|
+
ch.sock.settimeout(self._timeout_s)
|
|
768
|
+
while True:
|
|
769
|
+
try:
|
|
770
|
+
kind, body = ch.recv()
|
|
771
|
+
except (OSError, ConnectionError, _AuthFailed) as ex:
|
|
772
|
+
raise ForwardingError(f"the holder's stream broke off ({ex})") from ex
|
|
773
|
+
if kind == _DATA:
|
|
774
|
+
yield from body.splitlines(keepends=True)
|
|
775
|
+
continue
|
|
776
|
+
trailer = (_json_object(body) if kind == _END else None) or {}
|
|
777
|
+
if not trailer.get("ok"):
|
|
778
|
+
_raise_remote(trailer.get("error") or {"message": "stream ended abnormally"})
|
|
779
|
+
ok = True
|
|
780
|
+
return
|
|
781
|
+
finally:
|
|
782
|
+
if ok:
|
|
783
|
+
self._client._give(self._key, ch)
|
|
784
|
+
else:
|
|
785
|
+
ch.close()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _raise_remote(err: dict):
|
|
789
|
+
"""Raise the holder's error as the same type here, when there is one."""
|
|
790
|
+
from memd.engine import memory as _m
|
|
791
|
+
from memd.storage import crypto, objectstore, replica
|
|
792
|
+
|
|
793
|
+
t, msg = str(err.get("type") or ""), str(err.get("message") or "")
|
|
794
|
+
known = {
|
|
795
|
+
"ValueError": ValueError, "TypeError": TypeError, "KeyError": KeyError,
|
|
796
|
+
"PermissionError": PermissionError,
|
|
797
|
+
"ForgetPreviewMismatch": _m.ForgetPreviewMismatch,
|
|
798
|
+
"KeyCustodyError": crypto.KeyCustodyError,
|
|
799
|
+
"KeyUnavailableError": crypto.KeyUnavailableError,
|
|
800
|
+
"ReadOnlyError": objectstore.ReadOnlyError,
|
|
801
|
+
"ReplicaUnavailableError": replica.ReplicaUnavailableError,
|
|
802
|
+
"ForwardTimeoutError": ForwardTimeoutError,
|
|
803
|
+
}
|
|
804
|
+
cls = known.get(t)
|
|
805
|
+
if cls is None:
|
|
806
|
+
raise ForwardedCallError(f"{t or 'error'} in the namespace's writer: {msg}")
|
|
807
|
+
raise cls(msg)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class ForwardClient:
|
|
811
|
+
"""Authenticated connections to holders, pooled per endpoint."""
|
|
812
|
+
|
|
813
|
+
POOL_IDLE = 16 # idle connections kept per endpoint...
|
|
814
|
+
POOL_IDLE_S = 60.0 # ...for this long (the holder closes them after _IDLE_S)
|
|
815
|
+
|
|
816
|
+
def __init__(self, cfg: ForwardConfig, secret: str, client_id: str):
|
|
817
|
+
self.cfg = cfg
|
|
818
|
+
self.secret = secret
|
|
819
|
+
self.client_id = client_id[:128]
|
|
820
|
+
self._lock = threading.Lock()
|
|
821
|
+
self._pool: dict[tuple, list[_Channel]] = {}
|
|
822
|
+
self._closed = False
|
|
823
|
+
|
|
824
|
+
def close(self) -> None:
|
|
825
|
+
with self._lock:
|
|
826
|
+
self._closed = True
|
|
827
|
+
pool, self._pool = self._pool, {}
|
|
828
|
+
for chans in pool.values():
|
|
829
|
+
for _t, ch in chans:
|
|
830
|
+
ch.close()
|
|
831
|
+
|
|
832
|
+
def drop(self, ep: Endpoint) -> None:
|
|
833
|
+
with self._lock:
|
|
834
|
+
chans = self._pool.pop((ep.host, ep.port, ep.endpoint_id), [])
|
|
835
|
+
for _t, ch in chans:
|
|
836
|
+
ch.close()
|
|
837
|
+
|
|
838
|
+
def _give(self, key, ch: _Channel) -> None:
|
|
839
|
+
with self._lock:
|
|
840
|
+
if not self._closed:
|
|
841
|
+
chans = self._pool.setdefault(key, [])
|
|
842
|
+
if len(chans) < self.POOL_IDLE:
|
|
843
|
+
chans.append((time.monotonic(), ch))
|
|
844
|
+
return
|
|
845
|
+
ch.close()
|
|
846
|
+
|
|
847
|
+
def _take(self, key) -> _Channel | None:
|
|
848
|
+
stale: list[_Channel] = []
|
|
849
|
+
got = None
|
|
850
|
+
with self._lock:
|
|
851
|
+
chans = self._pool.get(key) or []
|
|
852
|
+
while chans:
|
|
853
|
+
t, ch = chans.pop()
|
|
854
|
+
if time.monotonic() - t <= self.POOL_IDLE_S:
|
|
855
|
+
got = ch
|
|
856
|
+
break
|
|
857
|
+
stale.append(ch)
|
|
858
|
+
for ch in stale:
|
|
859
|
+
ch.close()
|
|
860
|
+
return got
|
|
861
|
+
|
|
862
|
+
def _connect(self, ep: Endpoint) -> _Channel:
|
|
863
|
+
try:
|
|
864
|
+
sock = socket.create_connection((ep.host, ep.port), timeout=self.cfg.connect_timeout_s)
|
|
865
|
+
except OSError as ex:
|
|
866
|
+
raise _Unreachable(f"cannot connect to {ep.host}:{ep.port} ({ex})") from None
|
|
867
|
+
ch = _Channel(sock, b"c")
|
|
868
|
+
try:
|
|
869
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
870
|
+
sock.settimeout(max(5.0, self.cfg.connect_timeout_s * 2))
|
|
871
|
+
kind, body = ch.recv(_MAX_HELLO)
|
|
872
|
+
hello = (_json_object(body) if kind == _HELLO else None) or {}
|
|
873
|
+
if hello.get("id") != ep.endpoint_id:
|
|
874
|
+
raise _Unreachable(f"another process answers at {ep.host}:{ep.port} "
|
|
875
|
+
"(the namespace's holder is gone)")
|
|
876
|
+
nonce, cnonce = str(hello.get("nonce", "")), secrets.token_hex(16)
|
|
877
|
+
cproof, sproof, key = _keys(self.secret, ep.endpoint_id, nonce, self.client_id, cnonce)
|
|
878
|
+
ch.send(_HELLO, _jdump({"v": PROTOCOL, "client": self.client_id, "cnonce": cnonce,
|
|
879
|
+
"proof": cproof}))
|
|
880
|
+
kind, body = ch.recv(_MAX_HELLO)
|
|
881
|
+
ack = (_json_object(body) if kind == _HELLO else None) or {}
|
|
882
|
+
except _Unreachable:
|
|
883
|
+
ch.close()
|
|
884
|
+
raise
|
|
885
|
+
except (OSError, ConnectionError, ValueError, _AuthFailed) as ex:
|
|
886
|
+
ch.close()
|
|
887
|
+
raise _Unreachable(f"handshake with {ep.host}:{ep.port} failed ({ex})") from None
|
|
888
|
+
if not ack.get("ok"):
|
|
889
|
+
ch.close()
|
|
890
|
+
raise ForwardAuthError(
|
|
891
|
+
f"the namespace's writer at {ep.host}:{ep.port} refused this process's forwarding "
|
|
892
|
+
"credentials: processes on one data root must share the forwarding secret "
|
|
893
|
+
"(<data dir>/forward.secret, or forward_secret / MEMD_FORWARD_SECRET)")
|
|
894
|
+
if not hmac.compare_digest(str(ack.get("proof", "")), sproof):
|
|
895
|
+
ch.close()
|
|
896
|
+
raise ForwardAuthError(f"the process at {ep.host}:{ep.port} could not prove it holds the "
|
|
897
|
+
"forwarding secret; nothing was sent to it")
|
|
898
|
+
ch.key = key
|
|
899
|
+
return ch
|
|
900
|
+
|
|
901
|
+
def call(self, ep: Endpoint, req: dict, *, timeout_s: float, alive=None, check_s: float = 0.0):
|
|
902
|
+
"""Send one request and wait for its answer: the reply dict, or a
|
|
903
|
+
_StreamReader. _Unreachable: nothing was sent; _Uncertain: sent, no
|
|
904
|
+
answer (within `timeout_s`, or `alive()` said the holder lost the
|
|
905
|
+
namespace meanwhile)."""
|
|
906
|
+
key = (ep.host, ep.port, ep.endpoint_id)
|
|
907
|
+
body = _jdump(req)
|
|
908
|
+
if len(body) > MAX_FRAME:
|
|
909
|
+
raise ValueError(f"a forwarded call is limited to {MAX_FRAME >> 20} MiB")
|
|
910
|
+
ch = self._take(key)
|
|
911
|
+
pooled = ch is not None
|
|
912
|
+
while True:
|
|
913
|
+
if ch is None:
|
|
914
|
+
ch = self._connect(ep)
|
|
915
|
+
try:
|
|
916
|
+
ch.sock.settimeout(max(5.0, timeout_s))
|
|
917
|
+
ch.send(_REQUEST, body)
|
|
918
|
+
break
|
|
919
|
+
except OSError:
|
|
920
|
+
ch.close()
|
|
921
|
+
if not pooled:
|
|
922
|
+
raise _Uncertain(f"sending to {ep.host}:{ep.port} failed") from None
|
|
923
|
+
ch, pooled = None, False # a pooled connection the holder closed: once more
|
|
924
|
+
try:
|
|
925
|
+
self._await(ch, timeout_s, alive, check_s)
|
|
926
|
+
ch.sock.settimeout(max(5.0, timeout_s))
|
|
927
|
+
kind, data = ch.recv()
|
|
928
|
+
except _Uncertain:
|
|
929
|
+
ch.close()
|
|
930
|
+
raise
|
|
931
|
+
except (OSError, ConnectionError, ValueError, _AuthFailed) as ex:
|
|
932
|
+
ch.close()
|
|
933
|
+
raise _Uncertain(f"no answer from {ep.host}:{ep.port} ({ex})") from None
|
|
934
|
+
if kind == _STREAM:
|
|
935
|
+
return _StreamReader(self, key, ch, _json_object(data) or {}, max(5.0, timeout_s))
|
|
936
|
+
reply = _json_object(data)
|
|
937
|
+
if reply is None:
|
|
938
|
+
ch.close()
|
|
939
|
+
raise _Uncertain("unreadable answer") from None
|
|
940
|
+
self._give(key, ch)
|
|
941
|
+
return reply
|
|
942
|
+
|
|
943
|
+
@staticmethod
|
|
944
|
+
def _await(ch: _Channel, timeout_s: float, alive, check_s: float) -> None:
|
|
945
|
+
deadline = time.monotonic() + timeout_s
|
|
946
|
+
next_check = time.monotonic() + check_s if alive is not None and check_s > 0 else None
|
|
947
|
+
poller = select.poll()
|
|
948
|
+
poller.register(ch.sock, select.POLLIN | select.POLLERR | select.POLLHUP)
|
|
949
|
+
while True:
|
|
950
|
+
left = deadline - time.monotonic()
|
|
951
|
+
if left <= 0:
|
|
952
|
+
raise _Uncertain(f"no answer within {timeout_s:.0f} s")
|
|
953
|
+
if poller.poll(int(min(left, 1.0) * 1000)):
|
|
954
|
+
return
|
|
955
|
+
if next_check is not None and time.monotonic() >= next_check:
|
|
956
|
+
next_check = time.monotonic() + check_s
|
|
957
|
+
try:
|
|
958
|
+
still = alive()
|
|
959
|
+
except Exception: # noqa: BLE001 - cannot tell: keep waiting
|
|
960
|
+
still = True
|
|
961
|
+
if not still:
|
|
962
|
+
raise _Uncertain("the holder lost the namespace while the call ran")
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
# ---------------------------------------------------------------- routing
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
class Forwarder:
|
|
969
|
+
"""Where this process's calls for namespaces it does not hold go: the
|
|
970
|
+
holder's endpoint (cached per namespace), found again whenever a call
|
|
971
|
+
fails - and taken over, when its lock or lease is free."""
|
|
972
|
+
|
|
973
|
+
def __init__(self, engine, cfg: ForwardConfig, secret: str, own_holder: str):
|
|
974
|
+
self.engine = engine
|
|
975
|
+
self.cfg = cfg
|
|
976
|
+
self.own_holder = own_holder
|
|
977
|
+
self.hostname = socket.gethostname()
|
|
978
|
+
self.client = ForwardClient(cfg, secret, own_holder)
|
|
979
|
+
self._lock = threading.Lock()
|
|
980
|
+
self._routes: dict[str, Endpoint] = {}
|
|
981
|
+
self._written: set[str] = set() # namespaces written through forwarding since the last flush
|
|
982
|
+
|
|
983
|
+
def close(self) -> None:
|
|
984
|
+
self.client.close()
|
|
985
|
+
|
|
986
|
+
def routes(self) -> dict[str, str]:
|
|
987
|
+
with self._lock:
|
|
988
|
+
return {ns: f"{ep.host}:{ep.port}" for ns, ep in self._routes.items()}
|
|
989
|
+
|
|
990
|
+
def _take(self, ns: str) -> bool:
|
|
991
|
+
"""Become `ns`'s writer if its lock or lease is free."""
|
|
992
|
+
try:
|
|
993
|
+
self.engine.namespace(ns)
|
|
994
|
+
except NamespaceBusyError:
|
|
995
|
+
return False
|
|
996
|
+
if self.engine.holds(ns):
|
|
997
|
+
return True
|
|
998
|
+
# (a pinned store that lost its lease is still in the table: drop it
|
|
999
|
+
# and open the namespace again - or find it held)
|
|
1000
|
+
self.engine.drop_lost(ns)
|
|
1001
|
+
try:
|
|
1002
|
+
self.engine.namespace(ns)
|
|
1003
|
+
except NamespaceBusyError:
|
|
1004
|
+
return False
|
|
1005
|
+
return self.engine.holds(ns)
|
|
1006
|
+
|
|
1007
|
+
def _resolve(self, ns: str) -> Endpoint | None:
|
|
1008
|
+
if self._take(ns):
|
|
1009
|
+
return None
|
|
1010
|
+
store = self.engine.store
|
|
1011
|
+
reader = getattr(store, "read_owner", None)
|
|
1012
|
+
if callable(reader):
|
|
1013
|
+
info = reader(ns)
|
|
1014
|
+
if info is None or not info.get("fresh"):
|
|
1015
|
+
raise _Wait("its lease is changing hands")
|
|
1016
|
+
holder = info.get("holder")
|
|
1017
|
+
else:
|
|
1018
|
+
holder = read_owner_lock(owner_lock_path(store.root, ns))
|
|
1019
|
+
if not holder:
|
|
1020
|
+
raise _Wait("its new writer is starting")
|
|
1021
|
+
ep = parse_holder(holder)
|
|
1022
|
+
if ep is None:
|
|
1023
|
+
raise ForwardingError(
|
|
1024
|
+
f"namespace {ns!r} is held by another process ({holder!r}) that does not accept "
|
|
1025
|
+
"forwarded calls (forwarding off, a cluster node, or an older memd). "
|
|
1026
|
+
"memd is single-writer per namespace: run one process per root, or turn forwarding "
|
|
1027
|
+
"on there")
|
|
1028
|
+
if _loopback(ep.host) and ep.hostname != self.hostname:
|
|
1029
|
+
raise ForwardingError(
|
|
1030
|
+
f"namespace {ns!r} is held by a process on {ep.hostname!r}, whose forwarding "
|
|
1031
|
+
"endpoint listens on loopback only: set forward_host / forward_advertise there")
|
|
1032
|
+
return ep
|
|
1033
|
+
|
|
1034
|
+
def _route(self, ns: str) -> Endpoint | None:
|
|
1035
|
+
with self._lock:
|
|
1036
|
+
ep = self._routes.get(ns)
|
|
1037
|
+
if ep is not None:
|
|
1038
|
+
return ep
|
|
1039
|
+
ep = self._resolve(ns)
|
|
1040
|
+
if ep is not None:
|
|
1041
|
+
with self._lock:
|
|
1042
|
+
self._routes[ns] = ep
|
|
1043
|
+
return ep
|
|
1044
|
+
|
|
1045
|
+
def _forget(self, ns: str, ep: Endpoint | None) -> None:
|
|
1046
|
+
with self._lock:
|
|
1047
|
+
if ep is None or self._routes.get(ns) == ep:
|
|
1048
|
+
self._routes.pop(ns, None)
|
|
1049
|
+
if ep is not None:
|
|
1050
|
+
self.client.drop(ep)
|
|
1051
|
+
|
|
1052
|
+
def _alive(self, ns: str, ep: Endpoint):
|
|
1053
|
+
"""S3: is `ep` still the namespace's (fresh) leaseholder? - checked
|
|
1054
|
+
while a call waits for its answer, so a holder frozen past its TTL
|
|
1055
|
+
is given up on (it is fenced when it resumes)."""
|
|
1056
|
+
reader = getattr(self.engine.store, "read_owner", None)
|
|
1057
|
+
if not callable(reader):
|
|
1058
|
+
return None
|
|
1059
|
+
|
|
1060
|
+
def alive() -> bool:
|
|
1061
|
+
info = reader(ns)
|
|
1062
|
+
if info is None or not info.get("fresh"):
|
|
1063
|
+
return False
|
|
1064
|
+
got = parse_holder(info.get("holder"))
|
|
1065
|
+
return got is not None and got.endpoint_id == ep.endpoint_id
|
|
1066
|
+
return alive
|
|
1067
|
+
|
|
1068
|
+
def call(self, ns: str, op: str, args: dict, ids: list[str] | None, *, write: bool,
|
|
1069
|
+
timeout_s: float | None = None):
|
|
1070
|
+
"""Run `op` on `ns`'s writer: its result, or LOCAL when this process
|
|
1071
|
+
took the namespace (the caller runs it here, with the same `ids`).
|
|
1072
|
+
Retries - same request id - until `wait_s` passes without a writer
|
|
1073
|
+
answering; ForwardingError then (nothing applied), or
|
|
1074
|
+
ForwardTimeoutError if a write's attempt may have been applied."""
|
|
1075
|
+
uncertain: list[str] = [] # (a write's) attempts sent that got no answer
|
|
1076
|
+
try:
|
|
1077
|
+
return self._call(ns, op, args, ids, write=write, timeout_s=timeout_s, uncertain=uncertain)
|
|
1078
|
+
except ForwardingError as ex:
|
|
1079
|
+
if not uncertain:
|
|
1080
|
+
raise
|
|
1081
|
+
# "nothing was applied" would be false: an attempt may have been
|
|
1082
|
+
METRICS.inc("memd_forward_calls_total", op=op, outcome="unknown", ns=ns)
|
|
1083
|
+
raise ForwardTimeoutError(
|
|
1084
|
+
f"namespace {ns!r}: an attempt of this write got no answer ({uncertain[-1]}) and "
|
|
1085
|
+
f"no writer can run it again ({ex}); the earlier attempt may have been applied") from ex
|
|
1086
|
+
|
|
1087
|
+
def _call(self, ns: str, op: str, args: dict, ids: list[str] | None, *, write: bool,
|
|
1088
|
+
timeout_s: float | None, uncertain: list[str]):
|
|
1089
|
+
rid = uuid.uuid4().hex
|
|
1090
|
+
t0 = time.monotonic()
|
|
1091
|
+
deadline = t0 + self.cfg.wait_s
|
|
1092
|
+
why = ""
|
|
1093
|
+
attempt = 0
|
|
1094
|
+
while True:
|
|
1095
|
+
try:
|
|
1096
|
+
ep = self._route(ns)
|
|
1097
|
+
except _Wait as w:
|
|
1098
|
+
ep, why = False, str(w)
|
|
1099
|
+
if ep is None:
|
|
1100
|
+
if attempt:
|
|
1101
|
+
METRICS.inc("memd_forward_takeovers_total", ns=ns,
|
|
1102
|
+
help="namespaces this process took over while forwarding to them")
|
|
1103
|
+
return LOCAL
|
|
1104
|
+
if ep is not False:
|
|
1105
|
+
req = {"rid": rid, "op": op, "ns": ns, "args": args, "ids": ids}
|
|
1106
|
+
try:
|
|
1107
|
+
reply = self.client.call(ep, req, timeout_s=timeout_s or self.cfg.timeout_s,
|
|
1108
|
+
alive=self._alive(ns, ep), check_s=self.cfg.check_s)
|
|
1109
|
+
except _Unreachable as ex:
|
|
1110
|
+
self._forget(ns, ep)
|
|
1111
|
+
why = str(ex)
|
|
1112
|
+
except _Uncertain as ex:
|
|
1113
|
+
self._forget(ns, ep)
|
|
1114
|
+
why = str(ex)
|
|
1115
|
+
if write:
|
|
1116
|
+
uncertain.append(why)
|
|
1117
|
+
else:
|
|
1118
|
+
if isinstance(reply, _StreamReader):
|
|
1119
|
+
return reply
|
|
1120
|
+
if reply.get("ok"):
|
|
1121
|
+
METRICS.inc("memd_forward_calls_total", op=op, outcome="ok", ns=ns,
|
|
1122
|
+
help="calls forwarded to the namespace's writer in another process")
|
|
1123
|
+
METRICS.observe("memd_forward_call_ms", (time.monotonic() - t0) * 1000,
|
|
1124
|
+
help="forwarded call latency, retries included (ms)", op=op)
|
|
1125
|
+
if write:
|
|
1126
|
+
with self._lock:
|
|
1127
|
+
self._written.add(ns)
|
|
1128
|
+
return reply.get("result")
|
|
1129
|
+
err = reply.get("error") or {}
|
|
1130
|
+
if not err.get("retry"):
|
|
1131
|
+
METRICS.inc("memd_forward_calls_total", op=op, outcome="error", ns=ns)
|
|
1132
|
+
_raise_remote(err)
|
|
1133
|
+
why = f"{err.get('code')}: {err.get('message', '')}"
|
|
1134
|
+
if err.get("code") != "overloaded":
|
|
1135
|
+
self._forget(ns, ep)
|
|
1136
|
+
METRICS.inc("memd_forward_retries_total", op=op,
|
|
1137
|
+
help="forwarded call attempts that found no writer answering")
|
|
1138
|
+
if time.monotonic() >= deadline:
|
|
1139
|
+
METRICS.inc("memd_forward_calls_total", op=op, outcome="unavailable", ns=ns)
|
|
1140
|
+
if uncertain:
|
|
1141
|
+
raise ForwardTimeoutError(
|
|
1142
|
+
f"namespace {ns!r}: no writer answered within {self.cfg.wait_s:.0f} s ({why}); "
|
|
1143
|
+
"an earlier attempt of this write may have been applied")
|
|
1144
|
+
raise ForwardingError(
|
|
1145
|
+
f"namespace {ns!r} is held by another process and no writer answered within "
|
|
1146
|
+
f"{self.cfg.wait_s:.0f} s ({why}); nothing was applied")
|
|
1147
|
+
time.sleep(min(0.5, 0.01 * (2 ** min(attempt, 6))) * (0.5 + random.random()))
|
|
1148
|
+
attempt += 1
|
|
1149
|
+
|
|
1150
|
+
def probe(self, ns: str) -> bool:
|
|
1151
|
+
"""At open, for the facade's own namespace held elsewhere: True when
|
|
1152
|
+
this process took it after all. Raises when forwarding to its holder
|
|
1153
|
+
cannot work (refused credentials, a holder that does not forward);
|
|
1154
|
+
one that cannot be reached right now is waited for by the calls."""
|
|
1155
|
+
try:
|
|
1156
|
+
ep = self._route(ns)
|
|
1157
|
+
except _Wait:
|
|
1158
|
+
return False
|
|
1159
|
+
if ep is None:
|
|
1160
|
+
return True
|
|
1161
|
+
try:
|
|
1162
|
+
reply = self.client.call(ep, {"rid": uuid.uuid4().hex, "op": "ping", "ns": ns,
|
|
1163
|
+
"args": {}, "ids": None},
|
|
1164
|
+
timeout_s=min(self.cfg.timeout_s, 10.0))
|
|
1165
|
+
except (_Unreachable, _Uncertain):
|
|
1166
|
+
self._forget(ns, ep)
|
|
1167
|
+
return False
|
|
1168
|
+
if isinstance(reply, dict) and not reply.get("ok"):
|
|
1169
|
+
self._forget(ns, ep)
|
|
1170
|
+
return False
|
|
1171
|
+
|
|
1172
|
+
def take_written(self) -> list[str]:
|
|
1173
|
+
with self._lock:
|
|
1174
|
+
out, self._written = sorted(self._written), set()
|
|
1175
|
+
return out
|