kedis-python 0.1.4__tar.gz → 0.2.0__tar.gz

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 (75) hide show
  1. {kedis_python-0.1.4 → kedis_python-0.2.0}/PKG-INFO +20 -13
  2. {kedis_python-0.1.4 → kedis_python-0.2.0}/README.md +13 -12
  3. kedis_python-0.2.0/kedis_python/aof.py +138 -0
  4. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/commands.py +67 -19
  5. kedis_python-0.2.0/kedis_python/help_data.py +711 -0
  6. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/main.py +154 -88
  7. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/network.py +15 -6
  8. kedis_python-0.2.0/kedis_python/parser.py +491 -0
  9. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/server.py +93 -22
  10. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/store.py +118 -69
  11. kedis_python-0.2.0/kedis_python/textcodec.py +32 -0
  12. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/ui.py +71 -1
  13. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/PKG-INFO +20 -13
  14. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/SOURCES.txt +26 -1
  15. {kedis_python-0.1.4 → kedis_python-0.2.0}/pyproject.toml +11 -6
  16. kedis_python-0.2.0/tests/test_aof_persistence.py +280 -0
  17. kedis_python-0.2.0/tests/test_aof_records.py +140 -0
  18. kedis_python-0.2.0/tests/test_binary_safety.py +340 -0
  19. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bitmap_persistence.py +20 -8
  20. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bitmaps.py +17 -11
  21. kedis_python-0.2.0/tests/test_cli_housekeep.py +121 -0
  22. kedis_python-0.2.0/tests/test_cli_stream_view.py +118 -0
  23. kedis_python-0.2.0/tests/test_data_replies.py +123 -0
  24. kedis_python-0.2.0/tests/test_del_exists.py +107 -0
  25. kedis_python-0.2.0/tests/test_docs_data_types.py +152 -0
  26. kedis_python-0.2.0/tests/test_help_data.py +211 -0
  27. kedis_python-0.2.0/tests/test_history_setup.py +74 -0
  28. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_hyperloglog.py +9 -2
  29. kedis_python-0.2.0/tests/test_kesp_decoder.py +254 -0
  30. kedis_python-0.2.0/tests/test_kesp_encoder.py +208 -0
  31. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_lists.py +1 -1
  32. kedis_python-0.2.0/tests/test_lrange.py +88 -0
  33. kedis_python-0.2.0/tests/test_parser_framing.py +175 -0
  34. kedis_python-0.2.0/tests/test_parser_limits.py +138 -0
  35. kedis_python-0.2.0/tests/test_pubsub.py +220 -0
  36. kedis_python-0.2.0/tests/test_reply_limits.py +123 -0
  37. kedis_python-0.2.0/tests/test_server_limits.py +345 -0
  38. kedis_python-0.2.0/tests/test_server_protocol_errors.py +112 -0
  39. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_store.py +13 -5
  40. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_store.py +9 -1
  41. kedis_python-0.2.0/tests/test_type.py +77 -0
  42. kedis_python-0.2.0/tests/test_version.py +34 -0
  43. kedis_python-0.2.0/tests/test_zadd.py +98 -0
  44. kedis_python-0.1.4/kedis_python/parser.py +0 -307
  45. {kedis_python-0.1.4 → kedis_python-0.2.0}/LICENSE +0 -0
  46. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/__init__.py +0 -0
  47. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/benchmark.py +0 -0
  48. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/benchmark_asyncio.py +0 -0
  49. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/hyperloglog.py +0 -0
  50. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/kesp_cli.py +0 -0
  51. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/replica.py +0 -0
  52. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/simulator.py +0 -0
  53. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/skiplist.py +0 -0
  54. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/stream.py +0 -0
  55. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/dependency_links.txt +0 -0
  56. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/entry_points.txt +0 -0
  57. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/requires.txt +0 -0
  58. {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/top_level.txt +0 -0
  59. {kedis_python-0.1.4 → kedis_python-0.2.0}/setup.cfg +0 -0
  60. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bench.py +0 -0
  61. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_docs_streams.py +0 -0
  62. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_housekeeping.py +0 -0
  63. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_inline_block.py +0 -0
  64. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_kesp.py +0 -0
  65. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_kesp2.py +0 -0
  66. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_lru.py +0 -0
  67. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_parser_casing.py +0 -0
  68. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_parser_harden.py +0 -0
  69. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_replica.py +0 -0
  70. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_commands.py +0 -0
  71. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_core.py +0 -0
  72. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_commands.py +0 -0
  73. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_core.py +0 -0
  74. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_server.py +0 -0
  75. {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_server.py +0 -0
@@ -1,10 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kedis-python
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: A Redis-inspired in-memory store in Python.
5
5
  Author: V SS Karthik
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Hogwarts-coder10/kedis-python
8
+ Keywords: redis,in-memory,key-value,database,streams,pub-sub
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Database
8
14
  Requires-Python: >=3.9
9
15
  Description-Content-Type: text/markdown
10
16
  License-File: LICENSE
@@ -43,16 +49,17 @@ Rather than focusing on Redis compatibility, Kedis focuses on understanding the
43
49
  ## 📚 Engine Documentation
44
50
 
45
51
  Dive into the internal grimoire to understand how Kedis achieves microsecond latency:
46
- * [Architecture & Pipeline](docs/architecture.md)
47
- * [KESP Wire Protocol](docs/protocol.md)
48
- * [Networking & Surge Tank](docs/networking.md)
49
- * [Persistence & I/O Drivetrain](docs/persistence.md)
50
- * [Command Spellbook](docs/commands.md)
51
- * [TTL & Lifespan Mechanics](docs/ttl.md)
52
- * [Streams & Consumer Groups](docs/streams.md)
53
- * [Pub/Sub Broadcasts](docs/pubsub.md)
54
- * [Benchmarks & Telemetry](docs/benchmarks.md)
55
- * [Future Roadmap](docs/roadmap.md)
52
+ * [Architecture & Pipeline](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/architecture.md)
53
+ * [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
54
+ * [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
55
+ * [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
56
+ * [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
57
+ * [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
58
+ * [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
59
+ * [Streams & Consumer Groups](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md)
60
+ * [Pub/Sub Broadcasts](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/pubsub.md)
61
+ * [Benchmarks & Telemetry](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/benchmarks.md)
62
+ * [Future Roadmap](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/roadmap.md)
56
63
 
57
64
  ---
58
65
 
@@ -120,7 +127,7 @@ XPENDING orders shipping
120
127
  XACK orders shipping 1718000000000-0
121
128
  ```
122
129
 
123
- Append-only event logs with blocking reads, and consumer groups that share a stream between workers with a pending list and acknowledgements. Streams persist through the AOF, `COMPACT` and snapshots, and replicate with the exact IDs and group state the master chose. See [Streams & Consumer Groups](docs/streams.md).
130
+ Append-only event logs with blocking reads, and consumer groups that share a stream between workers with a pending list and acknowledgements. Streams persist through the AOF, `COMPACT` and snapshots, and replicate with the exact IDs and group state the master chose. See [Streams & Consumer Groups](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md).
124
131
 
125
132
  ---
126
133
 
@@ -377,7 +384,7 @@ Current limitations include:
377
384
  * limited fault tolerance compared to production databases
378
385
  * no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
379
386
  * AOF persistence is not buffered — writes can trigger a sync depending on config
380
- * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](docs/streams.md#-known-limits)
387
+ * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
381
388
 
382
389
  These limitations are intentional learning opportunities and areas of active development.
383
390
 
@@ -29,16 +29,17 @@ Rather than focusing on Redis compatibility, Kedis focuses on understanding the
29
29
  ## 📚 Engine Documentation
30
30
 
31
31
  Dive into the internal grimoire to understand how Kedis achieves microsecond latency:
32
- * [Architecture & Pipeline](docs/architecture.md)
33
- * [KESP Wire Protocol](docs/protocol.md)
34
- * [Networking & Surge Tank](docs/networking.md)
35
- * [Persistence & I/O Drivetrain](docs/persistence.md)
36
- * [Command Spellbook](docs/commands.md)
37
- * [TTL & Lifespan Mechanics](docs/ttl.md)
38
- * [Streams & Consumer Groups](docs/streams.md)
39
- * [Pub/Sub Broadcasts](docs/pubsub.md)
40
- * [Benchmarks & Telemetry](docs/benchmarks.md)
41
- * [Future Roadmap](docs/roadmap.md)
32
+ * [Architecture & Pipeline](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/architecture.md)
33
+ * [KESP Wire Protocol](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/protocol.md)
34
+ * [Networking & Surge Tank](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/networking.md)
35
+ * [Limits & Budgets](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/limits.md)
36
+ * [Persistence & I/O Drivetrain](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/persistence.md)
37
+ * [Command Spellbook](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/commands.md)
38
+ * [TTL & Lifespan Mechanics](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/ttl.md)
39
+ * [Streams & Consumer Groups](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md)
40
+ * [Pub/Sub Broadcasts](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/pubsub.md)
41
+ * [Benchmarks & Telemetry](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/benchmarks.md)
42
+ * [Future Roadmap](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/roadmap.md)
42
43
 
43
44
  ---
44
45
 
@@ -106,7 +107,7 @@ XPENDING orders shipping
106
107
  XACK orders shipping 1718000000000-0
107
108
  ```
108
109
 
109
- Append-only event logs with blocking reads, and consumer groups that share a stream between workers with a pending list and acknowledgements. Streams persist through the AOF, `COMPACT` and snapshots, and replicate with the exact IDs and group state the master chose. See [Streams & Consumer Groups](docs/streams.md).
110
+ Append-only event logs with blocking reads, and consumer groups that share a stream between workers with a pending list and acknowledgements. Streams persist through the AOF, `COMPACT` and snapshots, and replicate with the exact IDs and group state the master chose. See [Streams & Consumer Groups](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md).
110
111
 
111
112
  ---
112
113
 
@@ -363,7 +364,7 @@ Current limitations include:
363
364
  * limited fault tolerance compared to production databases
364
365
  * no memory-based eviction ceiling (LRU tracks usage, but isn't yet tied to a hard memory limit)
365
366
  * AOF persistence is not buffered — writes can trigger a sync depending on config
366
- * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](docs/streams.md#-known-limits)
367
+ * consumer groups have no `XCLAIM`/`XAUTOCLAIM` yet; the full list of stream limits is in [docs/streams.md](https://github.com/Hogwarts-coder10/kedis-python/blob/main/docs/streams.md#-known-limits)
367
368
 
368
369
  These limitations are intentional learning opportunities and areas of active development.
369
370
 
@@ -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
@@ -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 = {
@@ -215,23 +222,30 @@ class CommandHandler:
215
222
  self.store.set(tokens[1], tokens[2])
216
223
  return "OK"
217
224
 
225
+ @staticmethod
226
+ def _data(value):
227
+ """A stored value as a reply: always data, never read as a status or
228
+ an error, whatever it looks like ("OK", "ERROR x", "-ERR y")."""
229
+ return BulkString(value) if isinstance(value, str) else value
230
+
218
231
  def _handle_get(self, tokens: list[str]):
219
232
  if len(tokens) != 2:
220
233
  return "-ERR wrong number of arguments for 'GET' command"
221
234
  try:
222
- return self.store.get(tokens[1])
235
+ return self._data(self.store.get(tokens[1]))
223
236
  except TypeError as e:
224
237
  return f"-ERR {str(e)}"
225
238
 
226
239
  def _handle_del(self, tokens: list[str]):
227
- if len(tokens) != 2:
240
+ if len(tokens) < 2:
228
241
  return "-ERR wrong number of arguments for 'DEL' command"
229
- return self.store.delete(tokens[1])
242
+ return sum(self.store.delete(key) for key in tokens[1:])
230
243
 
231
244
  def _handle_exists(self, tokens: list[str]):
232
- if len(tokens) != 2:
245
+ if len(tokens) < 2:
233
246
  return "-ERR wrong number of arguments for 'EXISTS' command"
234
- return self.store.exists(tokens[1])
247
+ # Like Redis, a key named twice counts twice.
248
+ return sum(self.store.exists(key) for key in tokens[1:])
235
249
 
236
250
  def _handle_expire(self, tokens: list[str]):
237
251
  if len(tokens) != 3:
@@ -310,7 +324,7 @@ class CommandHandler:
310
324
  if len(tokens) != 2:
311
325
  return "-ERR wrong number of arguments for 'lpop' command"
312
326
  try:
313
- return self.store.lpop(tokens[1])
327
+ return self._data(self.store.lpop(tokens[1]))
314
328
  except TypeError as e:
315
329
  return f"-ERR {str(e)}"
316
330
 
@@ -318,7 +332,7 @@ class CommandHandler:
318
332
  if len(tokens) != 2:
319
333
  return "-ERR wrong number of arguments for 'rpop' command"
320
334
  try:
321
- return self.store.rpop(tokens[1])
335
+ return self._data(self.store.rpop(tokens[1]))
322
336
  except TypeError as e:
323
337
  return f"-ERR {str(e)}"
324
338
 
@@ -360,7 +374,7 @@ class CommandHandler:
360
374
  if len(tokens) != 3:
361
375
  return "-ERR wrong number of arguments for 'hget' command"
362
376
  try:
363
- return self.store.hget(tokens[1], tokens[2])
377
+ return self._data(self.store.hget(tokens[1], tokens[2]))
364
378
  except TypeError as e:
365
379
  return f"-ERR {str(e)}"
366
380
 
@@ -386,9 +400,15 @@ class CommandHandler:
386
400
  key = tokens[1]
387
401
  added = 0
388
402
  try:
403
+ # Validate every pair before applying any: a bad score anywhere
404
+ # means nothing is added.
405
+ pairs = []
389
406
  for i in range(2, len(tokens), 2):
390
407
  score = float(tokens[i])
391
- member = tokens[i + 1]
408
+ if math.isnan(score):
409
+ raise ValueError
410
+ pairs.append((score, tokens[i + 1]))
411
+ for score, member in pairs:
392
412
  added += self.store.zadd(key, score, member)
393
413
  return added
394
414
  except ValueError:
@@ -1053,25 +1073,53 @@ class CommandHandler:
1053
1073
  receivers = 0
1054
1074
 
1055
1075
  # 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")
1076
+ kesp_payload = KESPEncoder.encode_command(["message", channel, message])
1062
1077
 
1063
1078
  dead_sockets = []
1064
1079
  for sock in subscribers:
1065
1080
  try:
1066
- sock.sendall(kesp_payload)
1081
+ self._push(sock, kesp_payload)
1067
1082
  receivers += 1
1068
1083
  except Exception:
1069
1084
  dead_sockets.append(sock)
1070
1085
 
1071
1086
  for dead in dead_sockets:
1072
1087
  subscribers.remove(dead)
1073
-
1074
- return
1088
+ if not subscribers:
1089
+ del self._channels[channel]
1090
+
1091
+ return receivers
1092
+
1093
+ def _push(self, sub, payload: bytes) -> None:
1094
+ """Delivers one pub/sub message to a subscriber, raising if it is gone.
1095
+
1096
+ The server hands subscribers over as asyncio StreamWriters, and it runs
1097
+ commands in worker threads, where a StreamWriter must not be touched
1098
+ directly: the write is queued onto the event loop instead. A plain
1099
+ socket (any object with sendall) is written to directly."""
1100
+ if hasattr(sub, "sendall"):
1101
+ sub.sendall(payload)
1102
+ return
1103
+ if sub.is_closing():
1104
+ raise ConnectionError("subscriber is gone")
1105
+ transport = getattr(sub, "transport", None)
1106
+ if (
1107
+ transport is not None
1108
+ and transport.get_write_buffer_size() > self.MAX_SUBSCRIBER_BUFFER
1109
+ ):
1110
+ # A subscriber that isn't reading would otherwise make the server
1111
+ # hold every message for it, without limit. Cut it off instead.
1112
+ loop = self.event_loop
1113
+ if loop is not None and loop.is_running():
1114
+ loop.call_soon_threadsafe(sub.close)
1115
+ else:
1116
+ sub.close()
1117
+ raise ConnectionError("subscriber output buffer limit exceeded")
1118
+ loop = self.event_loop
1119
+ if loop is not None and loop.is_running():
1120
+ loop.call_soon_threadsafe(sub.write, payload)
1121
+ else:
1122
+ sub.write(payload)
1075
1123
 
1076
1124
  def cmd_slowlog(self, tokens: list[str]) -> Any:
1077
1125
  if len(tokens) < 2: