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.
Files changed (54) hide show
  1. memd/__init__.py +7 -0
  2. memd/cli.py +699 -0
  3. memd/core/__init__.py +31 -0
  4. memd/core/schema.py +343 -0
  5. memd/engine/__init__.py +0 -0
  6. memd/engine/forward.py +1175 -0
  7. memd/engine/memory.py +3005 -0
  8. memd/harness/core.py +257 -0
  9. memd/harness/run.py +216 -0
  10. memd/harness/suites/__init__.py +0 -0
  11. memd/harness/suites/adversarial.py +113 -0
  12. memd/harness/suites/halumem_ops.py +54 -0
  13. memd/harness/suites/longmemeval_synthetic.py +221 -0
  14. memd/hosted/__init__.py +20 -0
  15. memd/hosted/app.py +258 -0
  16. memd/hosted/billing.py +909 -0
  17. memd/hosted/metering.py +460 -0
  18. memd/hosted/plans.py +138 -0
  19. memd/hosted/store.py +830 -0
  20. memd/index/__init__.py +3 -0
  21. memd/index/ann_usearch.py +1598 -0
  22. memd/index/sqlite_index.py +2413 -0
  23. memd/index/tantivy_lexical.py +886 -0
  24. memd/metrics.py +591 -0
  25. memd/pipeline/__init__.py +0 -0
  26. memd/pipeline/consolidation.py +174 -0
  27. memd/pipeline/embedder.py +370 -0
  28. memd/pipeline/extractor.py +567 -0
  29. memd/query/__init__.py +0 -0
  30. memd/query/dates.py +212 -0
  31. memd/query/fusion.py +90 -0
  32. memd/query/packing.py +671 -0
  33. memd/query/planner.py +76 -0
  34. memd/query/rerank.py +532 -0
  35. memd/sdk/__init__.py +3 -0
  36. memd/sdk/client.py +292 -0
  37. memd/server/__init__.py +0 -0
  38. memd/server/auth.py +423 -0
  39. memd/server/cluster.py +748 -0
  40. memd/server/http.py +976 -0
  41. memd/server/mcp_server.py +147 -0
  42. memd/storage/__init__.py +3 -0
  43. memd/storage/audit.py +504 -0
  44. memd/storage/crypto.py +1313 -0
  45. memd/storage/engine.py +5331 -0
  46. memd/storage/objectstore.py +597 -0
  47. memd/storage/replica.py +1040 -0
  48. memd/storage/s3store.py +1450 -0
  49. memd_engine-0.5.0.dist-info/METADATA +1230 -0
  50. memd_engine-0.5.0.dist-info/RECORD +54 -0
  51. memd_engine-0.5.0.dist-info/WHEEL +5 -0
  52. memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
  53. memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
  54. 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