PyMkDB 0.1.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. pymkdb/__init__.py +6 -0
  2. pymkdb/cli.py +57 -0
  3. pymkdb-0.1.0.dist-info/METADATA +86 -0
  4. pymkdb-0.1.0.dist-info/RECORD +54 -0
  5. pymkdb-0.1.0.dist-info/WHEEL +5 -0
  6. pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
  7. pymkdb-0.1.0.dist-info/top_level.txt +3 -0
  8. sdk/__init__.py +1 -0
  9. sdk/connection.py +225 -0
  10. sdk/delta.py +19 -0
  11. sdk/http_connection.py +180 -0
  12. sdk/mkdb_client.py +226 -0
  13. sdk/responses.py +154 -0
  14. src/__init__.py +1 -0
  15. src/config/db.py +227 -0
  16. src/config/server.py +52 -0
  17. src/db/__init__.py +207 -0
  18. src/db/cache/__init__.py +1 -0
  19. src/db/cache/ram_cache.py +144 -0
  20. src/db/cache/write_queue.py +156 -0
  21. src/db/maintenance/__init__.py +0 -0
  22. src/db/maintenance/compactor.py +118 -0
  23. src/db/maintenance/task_scheduler.py +73 -0
  24. src/db/objects/store.py +283 -0
  25. src/db/parity/__init__.py +0 -0
  26. src/db/parity/parity_manager.py +196 -0
  27. src/db/query/__init__.py +1 -0
  28. src/db/query/full_text_index.py +168 -0
  29. src/db/query/numeric_index.py +196 -0
  30. src/db/query/query_engine.py +308 -0
  31. src/db/query/tokenizer.py +48 -0
  32. src/db/query_workers/__init__.py +16 -0
  33. src/db/query_workers/dispatcher.py +339 -0
  34. src/db/query_workers/task.py +78 -0
  35. src/db/query_workers/worker.py +292 -0
  36. src/db/requesting/main.py +0 -0
  37. src/db/storage/__init__.py +1 -0
  38. src/db/storage/blob_store.py +47 -0
  39. src/db/storage/index_manager.py +92 -0
  40. src/db/storage/log_manager.py +119 -0
  41. src/db/storage/serializer.py +38 -0
  42. src/filing/__init__.py +31 -0
  43. src/objects/__init__.py +190 -0
  44. src/runtime/__init__.py +15 -0
  45. src/server/__init__.py +0 -0
  46. src/server/coms/actions.py +209 -0
  47. src/server/coms/http.py +46 -0
  48. src/server/coms/http_handlers.py +445 -0
  49. src/server/coms/metrics.py +231 -0
  50. src/server/coms/socket.py +461 -0
  51. src/server/coms/socket_protocol.py +54 -0
  52. src/server/control/api/actions.py +1001 -0
  53. src/server/control/server.py +404 -0
  54. src/server/event_log.py +58 -0
@@ -0,0 +1,445 @@
1
+ """
2
+ HTTP data-plane request handler for MkDB.
3
+
4
+ Endpoints:
5
+ POST /data — write / delta update a record
6
+ GET /data/{store}/{id} — read a record
7
+ DELETE /data/{store}/{id} — delete a record
8
+ POST /query — query by filter dict
9
+ GET /health — server health check
10
+
11
+ All responses are JSON:
12
+ success: {"status": "ok", "data": <result>}
13
+ error: {"status": "error", "message": "<text>", "code": <int>}
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import threading
19
+ import time
20
+ from http.server import BaseHTTPRequestHandler
21
+ from urllib.parse import urlparse
22
+
23
+ from src.server.coms import metrics as _metrics
24
+ from src.server.coms.actions import execute as _execute
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB default; overridden by config
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # IP rate limiter state
32
+ # ---------------------------------------------------------------------------
33
+ _rate_tracker: dict = {} # ip -> list[float] of recent request timestamps
34
+ _store_rate_tracker: dict = {} # (ip, store_name) -> list[float]
35
+ _rate_lock = threading.Lock()
36
+ _rate_log_lock = threading.Lock()
37
+
38
+
39
+ def _log_rate_limit_event(base_path, ip: str, path: str, scope: str) -> None:
40
+ """Append one JSON-lines record to logs/rate_limit.jsonl."""
41
+ import os
42
+ if not base_path:
43
+ return
44
+ try:
45
+ log_dir = os.path.join(base_path, "logs")
46
+ os.makedirs(log_dir, exist_ok=True)
47
+ entry = json.dumps({
48
+ "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
49
+ "ip": ip,
50
+ "path": path,
51
+ "scope": scope,
52
+ }) + "\n"
53
+ with _rate_log_lock:
54
+ with open(os.path.join(log_dir, "rate_limit.jsonl"), "a", encoding="utf-8") as f:
55
+ f.write(entry)
56
+ except Exception:
57
+ pass
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Token permission map
61
+ # format: token -> {store_name -> set of ops}
62
+ # e.g. {"secret123": {"products_1v": {"read", "write", "delete", "query"}}}
63
+ # When empty, all requests pass through (backwards-compatible).
64
+ # ---------------------------------------------------------------------------
65
+ _token_permissions: dict = {} # token -> {store -> set[str]}
66
+
67
+
68
+ def register_token(token: str, store: str, ops: set) -> None:
69
+ """
70
+ Register an API token granting the given operations for a store.
71
+
72
+ ops examples: {"read"}, {"write", "delete", "query"}, {"read", "write", "delete", "query"}
73
+ """
74
+ if not isinstance(ops, set):
75
+ ops = set(ops)
76
+ _token_permissions.setdefault(token, {})[store] = ops
77
+
78
+
79
+ # Operation required for each endpoint
80
+ _ENDPOINT_OP = {
81
+ ("GET", "data"): "read",
82
+ ("POST", "data"): "write",
83
+ ("DELETE", "data"): "delete",
84
+ ("POST", "query"): "query",
85
+ }
86
+
87
+
88
+ class HTTPDataHandler(BaseHTTPRequestHandler):
89
+ """
90
+ Data-plane HTTP handler. Attach the database reference via:
91
+ HTTPDataHandler.database = my_mkdb_instance
92
+ before passing to HTTPServer.
93
+ """
94
+ database = None
95
+
96
+ # ------------------------------------------------------------------
97
+ # Response helpers
98
+ # ------------------------------------------------------------------
99
+
100
+ # ------------------------------------------------------------------
101
+ # Security guards (rate limit + token auth)
102
+ # ------------------------------------------------------------------
103
+
104
+ def _check_rate_limit(self, store_name: str = None) -> bool:
105
+ """
106
+ Returns True (and sends 429) if any rate limit is exceeded.
107
+ Checks global IP limit, then per-store limit when applicable.
108
+ """
109
+ db = self.database
110
+ ip = self.client_address[0]
111
+ now = time.time()
112
+ cutoff = now - 1.0
113
+ path = urlparse(self.path).path
114
+ base_path = getattr(db.config, "base_path", None) if db else None
115
+
116
+ # --- 1. Global limit (per IP across all endpoints) ---
117
+ global_limit = 100
118
+ if db is not None:
119
+ global_limit = getattr(db.config.servers.http_server, "max_requests_per_second", 100)
120
+
121
+ with _rate_lock:
122
+ ts = _rate_tracker.get(ip, [])
123
+ ts = [t for t in ts if t > cutoff]
124
+ ts.append(now)
125
+ _rate_tracker[ip] = ts
126
+ global_count = len(ts)
127
+
128
+ if global_count > global_limit:
129
+ logger.warning("Rate limit (global) exceeded by %s on %s", ip, path)
130
+ _log_rate_limit_event(base_path, ip, path, "global")
131
+ if store_name:
132
+ _metrics.record_rate_limited(store_name, ip, "http")
133
+ self._json(429, {"status": "error", "message": "Rate limit exceeded", "code": 429})
134
+ return True
135
+
136
+ # --- 2. Per-store limit ---
137
+ if store_name and db is not None:
138
+ store_obj = db.stores.get(store_name)
139
+ if store_obj is not None:
140
+ rl = getattr(store_obj.config, "rate_limit", None)
141
+ if rl is not None and getattr(rl, "enabled", False):
142
+ store_limit = getattr(rl, "max_requests_per_second", 100)
143
+ key = (ip, store_name)
144
+ with _rate_lock:
145
+ st = _store_rate_tracker.get(key, [])
146
+ st = [t for t in st if t > cutoff]
147
+ st.append(now)
148
+ _store_rate_tracker[key] = st
149
+ store_count = len(st)
150
+
151
+ if store_count > store_limit:
152
+ logger.warning(
153
+ "Rate limit (store: %s) exceeded by %s on %s",
154
+ store_name, ip, path
155
+ )
156
+ _log_rate_limit_event(base_path, ip, path, f"store:{store_name}")
157
+ _metrics.record_rate_limited(store_name, ip, "http")
158
+ self._json(429, {
159
+ "status": "error",
160
+ "message": f"Rate limit exceeded for store '{store_name}'",
161
+ "code": 429,
162
+ })
163
+ return True
164
+
165
+ return False
166
+
167
+ def _check_token(self, path: str) -> bool:
168
+ """
169
+ Returns True if the request should be blocked (token missing or forbidden).
170
+ Sends a 403 response and returns True; caller must return immediately.
171
+ """
172
+ # Skip when no tokens are registered (open mode)
173
+ if not _token_permissions:
174
+ return False
175
+
176
+ raw_token = self.headers.get("Authorization", "")
177
+ if raw_token.startswith("Bearer "):
178
+ raw_token = raw_token[7:]
179
+ raw_token = raw_token.strip()
180
+
181
+ if not raw_token or raw_token not in _token_permissions:
182
+ self._json(403, {"status": "error", "message": "Forbidden", "code": 403})
183
+ return True
184
+
185
+ # Determine required operation from method + first path segment
186
+ parts = [p for p in path.split("/") if p]
187
+ first_seg = parts[0] if parts else ""
188
+ required_op = _ENDPOINT_OP.get((self.command, first_seg))
189
+ if required_op is None:
190
+ # Unknown endpoint — let routing handle the 404
191
+ return False
192
+
193
+ # Determine store name for path-based endpoints
194
+ if first_seg == "data" and len(parts) >= 2:
195
+ store_name = parts[1]
196
+ else:
197
+ # For POST /data and POST /query we can't read the body here;
198
+ # use wildcard "*" to check if the token has any access at all
199
+ store_name = "*"
200
+
201
+ store_perms = _token_permissions[raw_token]
202
+ # Accept if the token has a wildcard grant or a store-specific grant
203
+ allowed = store_perms.get(store_name, set()) | store_perms.get("*", set())
204
+ if required_op not in allowed:
205
+ self._json(403, {"status": "error", "message": "Forbidden", "code": 403})
206
+ return True
207
+ return False
208
+
209
+ def _check_user_auth(self, store_name: str = None, require_write: bool = False) -> bool:
210
+ """
211
+ Returns True (and sends 401/403) when user authentication fails.
212
+
213
+ Skipped entirely when no users are configured (open/backwards-compat mode).
214
+ For read requests: only checked when data_security.protect_reads is True.
215
+ For write/delete requests: always checked when users are configured.
216
+ """
217
+ import base64
218
+ db = self.database
219
+ if db is None or not getattr(db.config, "users", {}):
220
+ return False
221
+
222
+ # Determine whether this request needs auth
223
+ store_cfg = db.config.stores.get(store_name) if store_name else None
224
+ protect_reads = getattr(store_cfg, "protect_reads", False) if store_cfg else False
225
+ if not require_write and not protect_reads:
226
+ # Read-only request and reads are not protected → allow
227
+ return False
228
+
229
+ auth_header = self.headers.get("Authorization", "")
230
+ if not auth_header.startswith("Basic "):
231
+ self._json(401, {"status": "error", "message": "Authentication required", "code": 401})
232
+ return True
233
+
234
+ try:
235
+ raw = base64.b64decode(auth_header[6:]).decode("utf-8", errors="replace")
236
+ colon = raw.index(":")
237
+ username = raw[:colon]
238
+ password = raw[colon + 1:]
239
+ except Exception:
240
+ self._json(401, {"status": "error", "message": "Invalid credentials format", "code": 401})
241
+ return True
242
+
243
+ from src.server.control.server import verify_password
244
+ user = db.config.users.get(username)
245
+ if user is None or not verify_password(password, user.password_hash):
246
+ self._json(403, {"status": "error", "message": "Invalid username or password", "code": 403})
247
+ return True
248
+
249
+ if store_name and store_name != "*":
250
+ perm = user.stores.get(store_name)
251
+ if perm is None:
252
+ self._json(403, {"status": "error", "message": f"No access to store '{store_name}'", "code": 403})
253
+ return True
254
+ if require_write and not perm.write:
255
+ self._json(403, {"status": "error", "message": "Write access denied", "code": 403})
256
+ return True
257
+
258
+ # Auth succeeded — track by username instead of IP
259
+ self._client_key = username
260
+ return False
261
+
262
+ def _security_check(self) -> bool:
263
+ """Run rate limit then token and user auth checks. Returns True if blocked."""
264
+ # Default client identifier is the IP; overridden to username after auth
265
+ self._client_key: str = self.client_address[0]
266
+ path = urlparse(self.path).path.rstrip("/")
267
+ parts = [p for p in path.split("/") if p]
268
+
269
+ # Extract store name and write requirement from URL (best-effort)
270
+ store_name = None
271
+ require_write = False
272
+ if parts and parts[0] == "data" and len(parts) >= 2:
273
+ store_name = parts[1]
274
+ require_write = self.command in ("POST", "DELETE")
275
+
276
+ if self._check_rate_limit(store_name):
277
+ return True
278
+ if path != "/health":
279
+ if self._check_token(path):
280
+ return True
281
+ if self._check_user_auth(store_name, require_write):
282
+ return True
283
+ return False
284
+
285
+ # ------------------------------------------------------------------
286
+ # Response helpers
287
+ # ------------------------------------------------------------------
288
+
289
+ def _json(self, code: int, payload: dict) -> None:
290
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
291
+ self.send_response(code)
292
+ self.send_header("Content-Type", "application/json; charset=utf-8")
293
+ self.send_header("Content-Length", str(len(body)))
294
+ self.end_headers()
295
+ self.wfile.write(body)
296
+
297
+ def _ok(self, data=None) -> None:
298
+ self._json(200, {"status": "ok", "data": data})
299
+
300
+ def _err(self, message: str, code: int = 400) -> None:
301
+ self._json(code, {"status": "error", "message": message, "code": code})
302
+
303
+ def _read_body(self) -> dict:
304
+ db = self.database
305
+ max_bytes = MAX_BODY_SIZE
306
+ if db is not None:
307
+ max_bytes = getattr(db.config.servers.http_server, "max_body_size", MAX_BODY_SIZE)
308
+ length = int(self.headers.get("Content-Length", 0))
309
+ if length > max_bytes:
310
+ raise ValueError(f"Payload too large ({length} bytes, max {max_bytes})")
311
+ raw = self.rfile.read(length)
312
+ return json.loads(raw.decode("utf-8"))
313
+
314
+ # ------------------------------------------------------------------
315
+ # Routing
316
+ # ------------------------------------------------------------------
317
+
318
+ def do_GET(self) -> None:
319
+ if self._security_check():
320
+ return
321
+ path = urlparse(self.path).path.rstrip("/")
322
+
323
+ if path == "/health":
324
+ self._handle_health()
325
+ return
326
+
327
+ # /data/{store}/{id}
328
+ parts = [p for p in path.split("/") if p]
329
+ if len(parts) == 3 and parts[0] == "data":
330
+ self._handle_read(parts[1], parts[2])
331
+ return
332
+
333
+ self._err("Not found", 404)
334
+
335
+ def do_POST(self) -> None:
336
+ if self._security_check():
337
+ return
338
+ path = urlparse(self.path).path.rstrip("/")
339
+
340
+ if path == "/data":
341
+ self._handle_write()
342
+ return
343
+
344
+ if path == "/query":
345
+ self._handle_query()
346
+ return
347
+
348
+ self._err("Not found", 404)
349
+
350
+ def do_DELETE(self) -> None:
351
+ if self._security_check():
352
+ return
353
+ path = urlparse(self.path).path.rstrip("/")
354
+
355
+ parts = [p for p in path.split("/") if p]
356
+ if len(parts) == 3 and parts[0] == "data":
357
+ self._handle_delete(parts[1], parts[2])
358
+ return
359
+
360
+ self._err("Not found", 404)
361
+
362
+ # ------------------------------------------------------------------
363
+ # Handlers
364
+ # ------------------------------------------------------------------
365
+
366
+ def _handle_health(self) -> None:
367
+ db = self.database
368
+ if db is None:
369
+ self._ok({"status": "no database"})
370
+ return
371
+ stores = list(db.stores.keys())
372
+ self._ok({"status": "ok", "stores": stores, "store_count": len(stores)})
373
+
374
+ def _resolve_client_key(self, store_name: str) -> str:
375
+ """Return the client identifier for metrics.
376
+
377
+ Priority:
378
+ 1. Value of the store-configured header (if set and present in request)
379
+ 2. self._client_key (username after auth, or remote_addr)
380
+ """
381
+ db = self.database
382
+ if db is not None and store_name and store_name in db.stores:
383
+ header_name: str = getattr(db.stores[store_name].config, "client_id_header", "") or ""
384
+ if header_name:
385
+ val = (self.headers.get(header_name) or "").strip()
386
+ if val:
387
+ return val
388
+ return getattr(self, "_client_key", self.client_address[0])
389
+
390
+ def _handle_read(self, store_name: str, record_id: str) -> None:
391
+ r = _execute(self.database, "read", store_name, {"record_id": record_id},
392
+ self._resolve_client_key(store_name), "http")
393
+ self._ok(r.data) if r.ok else self._err(r.error, r.http_code)
394
+
395
+ def _handle_write(self) -> None:
396
+ try:
397
+ data = self._read_body()
398
+ except ValueError as exc:
399
+ self._err(str(exc), 413 if "too large" in str(exc).lower() else 400)
400
+ return
401
+ except Exception as exc:
402
+ self._err(f"Bad request: {exc}", 400)
403
+ return
404
+
405
+ r = _execute(
406
+ self.database, "write",
407
+ str(data.get("store", "")).strip(),
408
+ {
409
+ "record_id": str(data.get("record_id", "")).strip(),
410
+ "delta": data.get("delta", {}),
411
+ "bytes_in": int(self.headers.get("Content-Length", 0)),
412
+ },
413
+ self._resolve_client_key(str(data.get("store", "")).strip()), "http",
414
+ )
415
+ self._ok(r.data) if r.ok else self._err(r.error, r.http_code)
416
+
417
+ def _handle_delete(self, store_name: str, record_id: str) -> None:
418
+ r = _execute(self.database, "delete", store_name, {"record_id": record_id},
419
+ self._resolve_client_key(store_name), "http")
420
+ self._ok(r.data) if r.ok else self._err(r.error, r.http_code)
421
+
422
+ def _handle_query(self) -> None:
423
+ try:
424
+ data = self._read_body()
425
+ except ValueError as exc:
426
+ self._err(str(exc), 413 if "too large" in str(exc).lower() else 400)
427
+ return
428
+ except Exception as exc:
429
+ self._err(f"Bad request: {exc}", 400)
430
+ return
431
+
432
+ r = _execute(
433
+ self.database, "query",
434
+ str(data.get("store", "")).strip(),
435
+ {
436
+ "filter": data.get("filter", {}),
437
+ "hydrate": bool(data.get("hydrate", False)),
438
+ },
439
+ self._resolve_client_key(str(data.get("store", "")).strip()), "http",
440
+ )
441
+ self._ok(r.data) if r.ok else self._err(r.error, r.http_code)
442
+
443
+ def log_message(self, format: str, *args) -> None:
444
+ """Suppress default stdout access log."""
445
+ pass
@@ -0,0 +1,231 @@
1
+ """
2
+ metrics.py — In-memory per-store, per-client request and bandwidth tracking.
3
+
4
+ All operations are thread-safe via a single RLock.
5
+
6
+ Structure
7
+ ---------
8
+ _store_metrics[store_name] = {
9
+ "reads": int, # total read requests
10
+ "writes": int, # total write requests
11
+ "deletes": int, # total delete requests
12
+ "queries": int, # total query requests
13
+ "errors": int, # total errored requests
14
+ "rate_limited": int, # times this store was rate-limited
15
+ "bytes_in": int, # bytes received in request bodies (writes)
16
+ "bytes_out": int, # bytes sent in response bodies (reads/queries)
17
+ "started_at": float, # unix timestamp of first metric recorded
18
+ "transport": { # breakdown by transport ("http" / "socket")
19
+ "http": {"reads": 0, "writes": 0, ...},
20
+ "socket": {"reads": 0, "writes": 0, ...},
21
+ },
22
+ "clients": {
23
+ "<username or ip>": {
24
+ "reads": int, "writes": int, "deletes": int, "queries": int,
25
+ "errors": int, "rate_limited": int, "bytes_in": int, "bytes_out": int,
26
+ },
27
+ ...
28
+ },
29
+ "rate_limited_ips": { # ip -> count of times blocked for this store
30
+ "<ip>": int,
31
+ },
32
+ }
33
+ """
34
+
35
+ import threading
36
+ import time
37
+ from collections import deque
38
+
39
+ _lock = threading.RLock()
40
+
41
+ # store_name -> metric dict
42
+ _store_metrics: dict = {}
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Internal helpers
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _client_default() -> dict:
50
+ return {
51
+ "reads": 0, "writes": 0, "deletes": 0, "queries": 0,
52
+ "errors": 0, "rate_limited": 0, "bytes_in": 0, "bytes_out": 0,
53
+ }
54
+
55
+
56
+ def _transport_default() -> dict:
57
+ return {
58
+ "http": _client_default(),
59
+ "socket": _client_default(),
60
+ }
61
+
62
+
63
+ def _store_default() -> dict:
64
+ return {
65
+ "reads": 0, "writes": 0, "deletes": 0, "queries": 0,
66
+ "errors": 0, "rate_limited": 0,
67
+ "bytes_in": 0, "bytes_out": 0,
68
+ "slow_queries": 0,
69
+ "started_at": time.time(),
70
+ "transport": _transport_default(),
71
+ "clients": {},
72
+ "rate_limited_ips": {},
73
+ "slow_query_log": deque(maxlen=50),
74
+ "error_log": deque(maxlen=50),
75
+ }
76
+
77
+
78
+ def _get_store(store_name: str) -> dict:
79
+ """Return (and lazily create) the metric dict for a store."""
80
+ if store_name not in _store_metrics:
81
+ _store_metrics[store_name] = _store_default()
82
+ return _store_metrics[store_name]
83
+
84
+
85
+ def _get_client(store_metrics: dict, client_key: str) -> dict:
86
+ """Return (and lazily create) the client sub-dict."""
87
+ clients = store_metrics["clients"]
88
+ if client_key not in clients:
89
+ clients[client_key] = _client_default()
90
+ return clients[client_key]
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Public recording API
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def record(
98
+ store_name: str,
99
+ op: str, # "read" | "write" | "delete" | "query" | "error"
100
+ client_key: str = "", # username or IP
101
+ bytes_in: int = 0,
102
+ bytes_out: int = 0,
103
+ transport: str = "http", # "http" | "socket"
104
+ error_msg: str = "", # human-readable error message (only used when op="error")
105
+ ) -> None:
106
+ """
107
+ Record a single operation against a store.
108
+
109
+ Parameters
110
+ ----------
111
+ store_name : str — name of the store
112
+ op : str — one of "read", "write", "delete", "query", "error"
113
+ client_key : str — username (if authenticated) or IP address
114
+ bytes_in : int — bytes received in the request body
115
+ bytes_out : int — bytes sent in the response body
116
+ transport : str — "http" or "socket"
117
+ """
118
+ if not store_name:
119
+ return
120
+ op = op.lower()
121
+ _op_to_field = {"read": "reads", "write": "writes", "delete": "deletes",
122
+ "query": "queries", "error": "errors"}
123
+ field = _op_to_field.get(op, op)
124
+
125
+ with _lock:
126
+ sm = _get_store(store_name)
127
+ sm[field] = sm.get(field, 0) + 1
128
+ sm["bytes_in"] += bytes_in
129
+ sm["bytes_out"] += bytes_out
130
+
131
+ # Transport breakdown
132
+ t = transport if transport in ("http", "socket") else "http"
133
+ td = sm["transport"].setdefault(t, _client_default())
134
+ td[field] = td.get(field, 0) + 1
135
+ td["bytes_in"] += bytes_in
136
+ td["bytes_out"] += bytes_out
137
+
138
+ # Per-client breakdown
139
+ if client_key:
140
+ cd = _get_client(sm, client_key)
141
+ cd[field] = cd.get(field, 0) + 1
142
+ cd["bytes_in"] += bytes_in
143
+ cd["bytes_out"] += bytes_out
144
+
145
+ # Error log ring buffer
146
+ if op == "error" and error_msg:
147
+ sm["error_log"].append({
148
+ "ts": time.time(),
149
+ "message": error_msg,
150
+ "client": client_key,
151
+ "transport": transport,
152
+ })
153
+
154
+
155
+ def record_slow_query(
156
+ store_name: str,
157
+ duration_ms: float,
158
+ filter_dict: dict,
159
+ result_count: int,
160
+ client_key: str = "",
161
+ transport: str = "http",
162
+ ) -> None:
163
+ """Append a slow-query entry to the store's ring buffer and increment counter."""
164
+ if not store_name:
165
+ return
166
+ entry = {
167
+ "ts": time.time(),
168
+ "duration_ms": round(duration_ms, 2),
169
+ "filter": filter_dict,
170
+ "result_count": result_count,
171
+ "client": client_key,
172
+ "transport": transport,
173
+ }
174
+ with _lock:
175
+ sm = _get_store(store_name)
176
+ sm["slow_queries"] = sm.get("slow_queries", 0) + 1
177
+ sm["slow_query_log"].append(entry)
178
+
179
+
180
+ def record_rate_limited( store_name: str,
181
+ ip: str,
182
+ transport: str = "http",
183
+ ) -> None:
184
+ """Record a rate-limit block event for a store and IP."""
185
+ if not store_name:
186
+ return
187
+ with _lock:
188
+ sm = _get_store(store_name)
189
+ sm["rate_limited"] = sm.get("rate_limited", 0) + 1
190
+ rl_ips = sm.setdefault("rate_limited_ips", {})
191
+ rl_ips[ip] = rl_ips.get(ip, 0) + 1
192
+
193
+ t = transport if transport in ("http", "socket") else "http"
194
+ td = sm["transport"].setdefault(t, _client_default())
195
+ td["rate_limited"] = td.get("rate_limited", 0) + 1
196
+
197
+ # Also record as a client entry so the per-IP table shows it
198
+ cd = _get_client(sm, ip)
199
+ cd["rate_limited"] = cd.get("rate_limited", 0) + 1
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Read API
204
+ # ---------------------------------------------------------------------------
205
+
206
+ def get_store_metrics(store_name: str) -> dict:
207
+ """Return a snapshot of all metrics for a store (deep-copied)."""
208
+ import copy
209
+ with _lock:
210
+ if store_name not in _store_metrics:
211
+ return _store_default()
212
+ return copy.deepcopy(_store_metrics[store_name])
213
+
214
+
215
+ def get_all_metrics() -> dict:
216
+ """Return a snapshot of metrics for every store."""
217
+ import copy
218
+ with _lock:
219
+ return copy.deepcopy(_store_metrics)
220
+
221
+
222
+ def reset_store_metrics(store_name: str) -> None:
223
+ """Clear all metrics for a store."""
224
+ with _lock:
225
+ if store_name in _store_metrics:
226
+ _store_metrics[store_name] = _store_default()
227
+
228
+
229
+ def all_store_names() -> list:
230
+ with _lock:
231
+ return list(_store_metrics.keys())