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.
- pymkdb/__init__.py +6 -0
- pymkdb/cli.py +57 -0
- pymkdb-0.1.0.dist-info/METADATA +86 -0
- pymkdb-0.1.0.dist-info/RECORD +54 -0
- pymkdb-0.1.0.dist-info/WHEEL +5 -0
- pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
- pymkdb-0.1.0.dist-info/top_level.txt +3 -0
- sdk/__init__.py +1 -0
- sdk/connection.py +225 -0
- sdk/delta.py +19 -0
- sdk/http_connection.py +180 -0
- sdk/mkdb_client.py +226 -0
- sdk/responses.py +154 -0
- src/__init__.py +1 -0
- src/config/db.py +227 -0
- src/config/server.py +52 -0
- src/db/__init__.py +207 -0
- src/db/cache/__init__.py +1 -0
- src/db/cache/ram_cache.py +144 -0
- src/db/cache/write_queue.py +156 -0
- src/db/maintenance/__init__.py +0 -0
- src/db/maintenance/compactor.py +118 -0
- src/db/maintenance/task_scheduler.py +73 -0
- src/db/objects/store.py +283 -0
- src/db/parity/__init__.py +0 -0
- src/db/parity/parity_manager.py +196 -0
- src/db/query/__init__.py +1 -0
- src/db/query/full_text_index.py +168 -0
- src/db/query/numeric_index.py +196 -0
- src/db/query/query_engine.py +308 -0
- src/db/query/tokenizer.py +48 -0
- src/db/query_workers/__init__.py +16 -0
- src/db/query_workers/dispatcher.py +339 -0
- src/db/query_workers/task.py +78 -0
- src/db/query_workers/worker.py +292 -0
- src/db/requesting/main.py +0 -0
- src/db/storage/__init__.py +1 -0
- src/db/storage/blob_store.py +47 -0
- src/db/storage/index_manager.py +92 -0
- src/db/storage/log_manager.py +119 -0
- src/db/storage/serializer.py +38 -0
- src/filing/__init__.py +31 -0
- src/objects/__init__.py +190 -0
- src/runtime/__init__.py +15 -0
- src/server/__init__.py +0 -0
- src/server/coms/actions.py +209 -0
- src/server/coms/http.py +46 -0
- src/server/coms/http_handlers.py +445 -0
- src/server/coms/metrics.py +231 -0
- src/server/coms/socket.py +461 -0
- src/server/coms/socket_protocol.py +54 -0
- src/server/control/api/actions.py +1001 -0
- src/server/control/server.py +404 -0
- 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())
|