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.
- {kedis_python-0.1.4 → kedis_python-0.2.0}/PKG-INFO +20 -13
- {kedis_python-0.1.4 → kedis_python-0.2.0}/README.md +13 -12
- kedis_python-0.2.0/kedis_python/aof.py +138 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/commands.py +67 -19
- kedis_python-0.2.0/kedis_python/help_data.py +711 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/main.py +154 -88
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/network.py +15 -6
- kedis_python-0.2.0/kedis_python/parser.py +491 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/server.py +93 -22
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/store.py +118 -69
- kedis_python-0.2.0/kedis_python/textcodec.py +32 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/ui.py +71 -1
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/PKG-INFO +20 -13
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/SOURCES.txt +26 -1
- {kedis_python-0.1.4 → kedis_python-0.2.0}/pyproject.toml +11 -6
- kedis_python-0.2.0/tests/test_aof_persistence.py +280 -0
- kedis_python-0.2.0/tests/test_aof_records.py +140 -0
- kedis_python-0.2.0/tests/test_binary_safety.py +340 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bitmap_persistence.py +20 -8
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bitmaps.py +17 -11
- kedis_python-0.2.0/tests/test_cli_housekeep.py +121 -0
- kedis_python-0.2.0/tests/test_cli_stream_view.py +118 -0
- kedis_python-0.2.0/tests/test_data_replies.py +123 -0
- kedis_python-0.2.0/tests/test_del_exists.py +107 -0
- kedis_python-0.2.0/tests/test_docs_data_types.py +152 -0
- kedis_python-0.2.0/tests/test_help_data.py +211 -0
- kedis_python-0.2.0/tests/test_history_setup.py +74 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_hyperloglog.py +9 -2
- kedis_python-0.2.0/tests/test_kesp_decoder.py +254 -0
- kedis_python-0.2.0/tests/test_kesp_encoder.py +208 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_lists.py +1 -1
- kedis_python-0.2.0/tests/test_lrange.py +88 -0
- kedis_python-0.2.0/tests/test_parser_framing.py +175 -0
- kedis_python-0.2.0/tests/test_parser_limits.py +138 -0
- kedis_python-0.2.0/tests/test_pubsub.py +220 -0
- kedis_python-0.2.0/tests/test_reply_limits.py +123 -0
- kedis_python-0.2.0/tests/test_server_limits.py +345 -0
- kedis_python-0.2.0/tests/test_server_protocol_errors.py +112 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_store.py +13 -5
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_store.py +9 -1
- kedis_python-0.2.0/tests/test_type.py +77 -0
- kedis_python-0.2.0/tests/test_version.py +34 -0
- kedis_python-0.2.0/tests/test_zadd.py +98 -0
- kedis_python-0.1.4/kedis_python/parser.py +0 -307
- {kedis_python-0.1.4 → kedis_python-0.2.0}/LICENSE +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/__init__.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/benchmark.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/benchmark_asyncio.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/hyperloglog.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/kesp_cli.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/replica.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/simulator.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/skiplist.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python/stream.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/dependency_links.txt +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/entry_points.txt +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/requires.txt +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/kedis_python.egg-info/top_level.txt +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/setup.cfg +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_bench.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_docs_streams.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_housekeeping.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_inline_block.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_kesp.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_kesp2.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_lru.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_parser_casing.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_parser_harden.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_replica.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_commands.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_core.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_commands.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_core.py +0 -0
- {kedis_python-0.1.4 → kedis_python-0.2.0}/tests/test_stream_groups_server.py +0 -0
- {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.
|
|
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
|
-
* [
|
|
50
|
-
* [
|
|
51
|
-
* [
|
|
52
|
-
* [
|
|
53
|
-
* [
|
|
54
|
-
* [
|
|
55
|
-
* [
|
|
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
|
-
* [
|
|
36
|
-
* [
|
|
37
|
-
* [
|
|
38
|
-
* [
|
|
39
|
-
* [
|
|
40
|
-
* [
|
|
41
|
-
* [
|
|
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)
|
|
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)
|
|
245
|
+
if len(tokens) < 2:
|
|
233
246
|
return "-ERR wrong number of arguments for 'EXISTS' command"
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|