kedis-python 0.1.4__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kedis_python/aof.py ADDED
@@ -0,0 +1,138 @@
1
+ """On-disk record format for the append-only file (AOF).
2
+
3
+ Every logged command is one KESP-framed record, the same shape the wire
4
+ protocol uses for commands:
5
+
6
+ A<count>\\n then, for each token: S<length>\\n<payload>\\n
7
+
8
+ Lengths count bytes of the UTF-8 payload, so a token can hold anything: spaces,
9
+ newlines, tabs, an empty string, text that looks like another command. The old
10
+ format joined tokens with spaces on one line, so a value could not survive a
11
+ restart and a value containing a newline could plant commands in the log.
12
+
13
+ Strings are encoded with "surrogatepass", so every Python str round-trips
14
+ exactly, lone surrogates included.
15
+
16
+ Files written by older versions hold one whitespace-separated command per line.
17
+ scan() still reads those, line by line, and a file may mix both: old lines
18
+ first, framed records after.
19
+ """
20
+
21
+ import re
22
+
23
+ _COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
24
+ MAX_HEADER_LINE = 32 # "A<n>" / "S<n>" lines are never longer than this
25
+
26
+
27
+ def encode_record(tokens) -> bytes:
28
+ """One command as a framed record."""
29
+ parts = [b"A%d\n" % len(tokens)]
30
+ for token in tokens:
31
+ raw = str(token).encode("utf-8", "surrogatepass")
32
+ parts.append(b"S%d\n" % len(raw))
33
+ parts.append(raw)
34
+ parts.append(b"\n")
35
+ return b"".join(parts)
36
+
37
+
38
+ class ScanResult:
39
+ """How a scan ended.
40
+
41
+ status: "ok" every byte was consumed
42
+ "torn" the file ends in the middle of a record (a crash while
43
+ writing); everything before good_end is intact
44
+ "corrupt" a record is malformed; see reason
45
+ good_end: offset just past the last complete record or line
46
+ """
47
+
48
+ __slots__ = ("good_end", "reason", "status")
49
+
50
+ def __init__(self):
51
+ self.status = "ok"
52
+ self.good_end = 0
53
+ self.reason = ""
54
+
55
+
56
+ def _count(buf, start: int, end: int):
57
+ field = bytes(buf[start:end])
58
+ return int(field) if _COUNT.fullmatch(field) else None
59
+
60
+
61
+ def scan(buf, result: ScanResult):
62
+ """Yields (tokens, end_offset) for each command in buf, a bytes-like object
63
+ that supports find() and slicing (bytes or mmap). Stops at the first
64
+ incomplete or malformed record and says why in result."""
65
+ total = len(buf)
66
+ pos = 0
67
+
68
+ def stop(status: str, reason: str):
69
+ result.status = status
70
+ result.reason = reason
71
+
72
+ while pos < total:
73
+ if buf[pos : pos + 1] == b"A":
74
+ following = buf[pos + 1 : pos + 2]
75
+ if following == b"":
76
+ return stop("torn", "file ends inside a record header")
77
+ # A legacy command word starts "A" + a letter (AUTH, ...). Anything
78
+ # else after the "A" is a record header and must validate as one.
79
+ framed = not following.isalpha()
80
+ else:
81
+ framed = False
82
+
83
+ if not framed:
84
+ # Legacy line: whitespace-separated tokens, as older versions wrote.
85
+ nl = buf.find(b"\n", pos)
86
+ line_end = total if nl == -1 else nl
87
+ next_pos = total if nl == -1 else nl + 1
88
+ tokens = bytes(buf[pos:line_end]).decode("utf-8", "replace").split()
89
+ result.good_end = next_pos
90
+ if tokens:
91
+ yield tokens, next_pos
92
+ pos = next_pos
93
+ continue
94
+
95
+ nl = buf.find(b"\n", pos)
96
+ if nl == -1:
97
+ if total - pos > MAX_HEADER_LINE:
98
+ return stop("corrupt", "array header too long")
99
+ return stop("torn", "file ends inside a record header")
100
+ if nl - pos > MAX_HEADER_LINE:
101
+ return stop("corrupt", "array header too long")
102
+ count = _count(buf, pos + 1, nl)
103
+ if count is None:
104
+ return stop("corrupt", "invalid array header")
105
+
106
+ cursor = nl + 1
107
+ tokens = []
108
+ for _ in range(count):
109
+ if cursor >= total:
110
+ return stop("torn", "file ends between tokens")
111
+ if buf[cursor : cursor + 1] != b"S":
112
+ return stop("corrupt", "expected a string header")
113
+ nl = buf.find(b"\n", cursor)
114
+ if nl == -1:
115
+ if total - cursor > MAX_HEADER_LINE:
116
+ return stop("corrupt", "string header too long")
117
+ return stop("torn", "file ends inside a string header")
118
+ if nl - cursor > MAX_HEADER_LINE:
119
+ return stop("corrupt", "string header too long")
120
+ length = _count(buf, cursor + 1, nl)
121
+ if length is None:
122
+ return stop("corrupt", "invalid string length")
123
+ start = nl + 1
124
+ end = start + length
125
+ if end + 1 > total:
126
+ return stop("torn", "file ends inside a string")
127
+ if buf[end : end + 1] != b"\n":
128
+ return stop("corrupt", "string is not newline-terminated")
129
+ try:
130
+ tokens.append(bytes(buf[start:end]).decode("utf-8", "surrogatepass"))
131
+ except UnicodeDecodeError:
132
+ return stop("corrupt", "string is not valid UTF-8")
133
+ cursor = end + 1
134
+
135
+ result.good_end = cursor
136
+ if tokens:
137
+ yield tokens, cursor
138
+ pos = cursor
kedis_python/commands.py CHANGED
@@ -1,13 +1,17 @@
1
+ import math
1
2
  import threading
2
3
  import time
3
4
  from typing import Any
4
5
 
5
- from .parser import BulkString
6
+ from .parser import BulkString, KESPEncoder
6
7
  from .store import KedisStore
7
8
  from .stream import StreamError
8
9
 
9
10
 
10
11
  class CommandHandler:
12
+ # Bytes a pub/sub subscriber may leave unread before the server cuts it off.
13
+ MAX_SUBSCRIBER_BUFFER = 32 * 1024 * 1024
14
+
11
15
  def __init__(self, store: KedisStore):
12
16
  self.store = store
13
17
 
@@ -16,6 +20,9 @@ class CommandHandler:
16
20
 
17
21
  # Pub / Sub SwitchBoard
18
22
  self._channels = {}
23
+ # The server's event loop, set per connection; pub/sub pushes are queued
24
+ # onto it because commands run in worker threads.
25
+ self.event_loop = None
19
26
 
20
27
  # The O(1) Dispatch Table
21
28
  self._commands = {
@@ -129,57 +136,82 @@ class CommandHandler:
129
136
  if not tokens:
130
137
  return "-ERR empty command or syntax error", []
131
138
 
132
- cmd = tokens[0].upper()
133
- args = tokens[
134
- 1:
135
- ] # (Though your current code passes tokens, let's keep your routing exact)
136
-
137
139
  # 🚀 TRACK A: Start the high-precision telemetry stopwatch (in nanoseconds)
138
140
  start_time = time.perf_counter_ns()
141
+ try:
142
+ with self._engine_lock:
143
+ return self._run_locked(tokens, client_socket)
144
+ finally:
145
+ self._record_duration(tokens, start_time)
146
+
147
+ def execute_batch(self, batch: list, client_socket=None):
148
+ """Runs several commands as one unit: the engine lock is taken once, so
149
+ no other client's command can run between them, and the AOF records
150
+ them back to back. Returns (results, forwards): one result per command,
151
+ and the commands a master must send to replicas, in order (see
152
+ replication_payloads). Commands are not skipped or reordered; one that
153
+ fails gives an error result and the rest still run, like the one-at-a-
154
+ time path."""
155
+ results: list = []
156
+ forwards: list = []
157
+ write_commands = self.WRITE_COMMANDS
158
+ starts: list = []
159
+ with self._engine_lock:
160
+ for tokens in batch:
161
+ if not tokens:
162
+ results.append("-ERR empty command or syntax error")
163
+ starts.append(None)
164
+ continue
165
+ starts.append(time.perf_counter_ns())
166
+ result, records = self._run_locked(tokens, client_socket)
167
+ results.append(result)
168
+ if tokens[0].upper() in write_commands:
169
+ forwards.extend(self.replication_payloads(tokens, result, records))
170
+ for tokens, started in zip(batch, starts):
171
+ if started is not None:
172
+ self._record_duration(tokens, started)
173
+ return results, forwards
174
+
175
+ def _run_locked(self, tokens: list[str], client_socket=None):
176
+ """Runs one command. The caller holds the engine lock."""
177
+ cmd = tokens[0].upper()
139
178
  result = None
140
179
  records = []
141
180
 
142
181
  try:
143
- with self._engine_lock:
144
- try:
145
- if cmd == "XGAPPLY" and client_socket is not None:
146
- # Internal replication command: a network client
147
- # must not be able to write raw group records.
148
- result = f"-ERR unknown command '{cmd}'"
149
- elif cmd in self._commands:
150
- if cmd in ["SUBSCRIBE", "PUBLISH"]:
151
- handler_function = self._commands[cmd]
152
- result = handler_function(tokens, client_socket)
153
- else:
154
- handler_function = self._commands[cmd]
155
- # Note: Depending on how your handlers are registered,
156
- # they might expect 'tokens' or 'args'. Keeping your existing call:
157
- result = handler_function(tokens)
182
+ try:
183
+ if cmd == "XGAPPLY" and client_socket is not None:
184
+ # Internal replication command: a network client
185
+ # must not be able to write raw group records.
186
+ result = f"-ERR unknown command '{cmd}'"
187
+ elif cmd in self._commands:
188
+ handler_function = self._commands[cmd]
189
+ if cmd in ["SUBSCRIBE", "PUBLISH"]:
190
+ result = handler_function(tokens, client_socket)
158
191
  else:
159
- result = f"-ERR unknown command '{cmd}'"
160
- finally:
161
- records = self.store.drain_group_records()
162
- return result, records
163
-
164
- except TypeError as e:
165
- result = f"-ERR wrong number of arguments for '{cmd}' command"
192
+ # Handlers take the whole token list, command name included.
193
+ result = handler_function(tokens)
194
+ else:
195
+ result = f"-ERR unknown command '{cmd}'"
196
+ finally:
197
+ records = self.store.drain_group_records()
166
198
  return result, records
199
+ except TypeError:
200
+ return f"-ERR wrong number of arguments for '{cmd}' command", records
167
201
  except Exception as e:
168
- result = f"-ERR {str(e)}"
169
- return result, records
202
+ return f"-ERR {str(e)}", records
170
203
 
171
- finally:
172
- # 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
173
- end_time = time.perf_counter_ns()
174
- duration_us = (end_time - start_time) // 1000
175
-
176
- # Feed the metrics to the store's slowlog ring buffer if available
177
- if (
178
- hasattr(self, "store")
179
- and self.store
180
- and hasattr(self.store, "_log_slow_command")
181
- ):
182
- self.store._log_slow_command(tokens, duration_us)
204
+ def _record_duration(self, tokens: list[str], start_time: int) -> None:
205
+ # 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
206
+ duration_us = (time.perf_counter_ns() - start_time) // 1000
207
+
208
+ # Feed the metrics to the store's slowlog ring buffer if available
209
+ if (
210
+ hasattr(self, "store")
211
+ and self.store
212
+ and hasattr(self.store, "_log_slow_command")
213
+ ):
214
+ self.store._log_slow_command(tokens, duration_us)
183
215
 
184
216
  # ---------------------------------------------------------
185
217
  # COMMAND HANDLERS (The isolated engine components)
@@ -215,23 +247,30 @@ class CommandHandler:
215
247
  self.store.set(tokens[1], tokens[2])
216
248
  return "OK"
217
249
 
250
+ @staticmethod
251
+ def _data(value):
252
+ """A stored value as a reply: always data, never read as a status or
253
+ an error, whatever it looks like ("OK", "ERROR x", "-ERR y")."""
254
+ return BulkString(value) if isinstance(value, str) else value
255
+
218
256
  def _handle_get(self, tokens: list[str]):
219
257
  if len(tokens) != 2:
220
258
  return "-ERR wrong number of arguments for 'GET' command"
221
259
  try:
222
- return self.store.get(tokens[1])
260
+ return self._data(self.store.get(tokens[1]))
223
261
  except TypeError as e:
224
262
  return f"-ERR {str(e)}"
225
263
 
226
264
  def _handle_del(self, tokens: list[str]):
227
- if len(tokens) != 2:
265
+ if len(tokens) < 2:
228
266
  return "-ERR wrong number of arguments for 'DEL' command"
229
- return self.store.delete(tokens[1])
267
+ return sum(self.store.delete(key) for key in tokens[1:])
230
268
 
231
269
  def _handle_exists(self, tokens: list[str]):
232
- if len(tokens) != 2:
270
+ if len(tokens) < 2:
233
271
  return "-ERR wrong number of arguments for 'EXISTS' command"
234
- return self.store.exists(tokens[1])
272
+ # Like Redis, a key named twice counts twice.
273
+ return sum(self.store.exists(key) for key in tokens[1:])
235
274
 
236
275
  def _handle_expire(self, tokens: list[str]):
237
276
  if len(tokens) != 3:
@@ -310,7 +349,7 @@ class CommandHandler:
310
349
  if len(tokens) != 2:
311
350
  return "-ERR wrong number of arguments for 'lpop' command"
312
351
  try:
313
- return self.store.lpop(tokens[1])
352
+ return self._data(self.store.lpop(tokens[1]))
314
353
  except TypeError as e:
315
354
  return f"-ERR {str(e)}"
316
355
 
@@ -318,7 +357,7 @@ class CommandHandler:
318
357
  if len(tokens) != 2:
319
358
  return "-ERR wrong number of arguments for 'rpop' command"
320
359
  try:
321
- return self.store.rpop(tokens[1])
360
+ return self._data(self.store.rpop(tokens[1]))
322
361
  except TypeError as e:
323
362
  return f"-ERR {str(e)}"
324
363
 
@@ -360,7 +399,7 @@ class CommandHandler:
360
399
  if len(tokens) != 3:
361
400
  return "-ERR wrong number of arguments for 'hget' command"
362
401
  try:
363
- return self.store.hget(tokens[1], tokens[2])
402
+ return self._data(self.store.hget(tokens[1], tokens[2]))
364
403
  except TypeError as e:
365
404
  return f"-ERR {str(e)}"
366
405
 
@@ -386,9 +425,15 @@ class CommandHandler:
386
425
  key = tokens[1]
387
426
  added = 0
388
427
  try:
428
+ # Validate every pair before applying any: a bad score anywhere
429
+ # means nothing is added.
430
+ pairs = []
389
431
  for i in range(2, len(tokens), 2):
390
432
  score = float(tokens[i])
391
- member = tokens[i + 1]
433
+ if math.isnan(score):
434
+ raise ValueError
435
+ pairs.append((score, tokens[i + 1]))
436
+ for score, member in pairs:
392
437
  added += self.store.zadd(key, score, member)
393
438
  return added
394
439
  except ValueError:
@@ -1053,25 +1098,53 @@ class CommandHandler:
1053
1098
  receivers = 0
1054
1099
 
1055
1100
  # Construct raw KESP bytes for the broadcast push
1056
- kesp_payload = (
1057
- f"A3\n"
1058
- f"S7\nmessage\n"
1059
- f"S{len(channel.encode('utf-8'))}\n{channel}\n"
1060
- f"S{len(message.encode('utf-8'))}\n{message}\n"
1061
- ).encode("utf-8")
1101
+ kesp_payload = KESPEncoder.encode_command(["message", channel, message])
1062
1102
 
1063
1103
  dead_sockets = []
1064
1104
  for sock in subscribers:
1065
1105
  try:
1066
- sock.sendall(kesp_payload)
1106
+ self._push(sock, kesp_payload)
1067
1107
  receivers += 1
1068
1108
  except Exception:
1069
1109
  dead_sockets.append(sock)
1070
1110
 
1071
1111
  for dead in dead_sockets:
1072
1112
  subscribers.remove(dead)
1073
-
1074
- return
1113
+ if not subscribers:
1114
+ del self._channels[channel]
1115
+
1116
+ return receivers
1117
+
1118
+ def _push(self, sub, payload: bytes) -> None:
1119
+ """Delivers one pub/sub message to a subscriber, raising if it is gone.
1120
+
1121
+ The server hands subscribers over as asyncio StreamWriters, and it runs
1122
+ commands in worker threads, where a StreamWriter must not be touched
1123
+ directly: the write is queued onto the event loop instead. A plain
1124
+ socket (any object with sendall) is written to directly."""
1125
+ if hasattr(sub, "sendall"):
1126
+ sub.sendall(payload)
1127
+ return
1128
+ if sub.is_closing():
1129
+ raise ConnectionError("subscriber is gone")
1130
+ transport = getattr(sub, "transport", None)
1131
+ if (
1132
+ transport is not None
1133
+ and transport.get_write_buffer_size() > self.MAX_SUBSCRIBER_BUFFER
1134
+ ):
1135
+ # A subscriber that isn't reading would otherwise make the server
1136
+ # hold every message for it, without limit. Cut it off instead.
1137
+ loop = self.event_loop
1138
+ if loop is not None and loop.is_running():
1139
+ loop.call_soon_threadsafe(sub.close)
1140
+ else:
1141
+ sub.close()
1142
+ raise ConnectionError("subscriber output buffer limit exceeded")
1143
+ loop = self.event_loop
1144
+ if loop is not None and loop.is_running():
1145
+ loop.call_soon_threadsafe(sub.write, payload)
1146
+ else:
1147
+ sub.write(payload)
1075
1148
 
1076
1149
  def cmd_slowlog(self, tokens: list[str]) -> Any:
1077
1150
  if len(tokens) < 2: