nanocached 0.1.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.
- nanocached-0.1.0/LICENSE +21 -0
- nanocached-0.1.0/PKG-INFO +215 -0
- nanocached-0.1.0/README.md +195 -0
- nanocached-0.1.0/pyproject.toml +28 -0
- nanocached-0.1.0/setup.cfg +4 -0
- nanocached-0.1.0/src/nanocached/__init__.py +28 -0
- nanocached-0.1.0/src/nanocached/_compression.py +92 -0
- nanocached-0.1.0/src/nanocached/_connection.py +229 -0
- nanocached-0.1.0/src/nanocached/_errors.py +35 -0
- nanocached-0.1.0/src/nanocached/_hashring.py +65 -0
- nanocached-0.1.0/src/nanocached/_identify.py +164 -0
- nanocached-0.1.0/src/nanocached/client.py +647 -0
- nanocached-0.1.0/src/nanocached.egg-info/PKG-INFO +215 -0
- nanocached-0.1.0/src/nanocached.egg-info/SOURCES.txt +17 -0
- nanocached-0.1.0/src/nanocached.egg-info/dependency_links.txt +1 -0
- nanocached-0.1.0/src/nanocached.egg-info/top_level.txt +1 -0
- nanocached-0.1.0/tests/test_client.py +987 -0
- nanocached-0.1.0/tests/test_compression.py +69 -0
- nanocached-0.1.0/tests/test_hashring.py +65 -0
nanocached-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takashi Yamashina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nanocached
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: asyncio client SDK for nanocached, a tiny distributed cache with client-side replication
|
|
5
|
+
Author: Takashi Yamashina
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nanocached.org
|
|
8
|
+
Project-URL: Repository, https://github.com/nanocached/nanocached
|
|
9
|
+
Project-URL: Issues, https://github.com/nanocached/nanocached/issues
|
|
10
|
+
Keywords: nanocached,cache,distributed-cache,rendezvous-hashing,client,sdk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# nanocached (Python)
|
|
22
|
+
|
|
23
|
+
asyncio client SDK for [nanocached](https://github.com/nanocached/nanocached),
|
|
24
|
+
a tiny distributed cache. Talks to either a single `nanocached-node` or a
|
|
25
|
+
`nanocached-discovery`-fronted cluster — the SDK figures out which from the
|
|
26
|
+
server's own handshake, so the calling code is identical either way.
|
|
27
|
+
|
|
28
|
+
Requires Python 3.11+. No runtime dependencies.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install nanocached
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from nanocached import NanocachedClient
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
# Point at a single node, or at a discovery server fronting a
|
|
44
|
+
# cluster — same call either way. `addresses` always takes a list;
|
|
45
|
+
# a one-element list is the single-target case.
|
|
46
|
+
client = await NanocachedClient.connect([("127.0.0.1", 8357)])
|
|
47
|
+
|
|
48
|
+
await client.set("greeting", "hello", ttl_seconds=60)
|
|
49
|
+
value = await client.get("greeting") # str | None
|
|
50
|
+
print(value) # hello
|
|
51
|
+
existed = await client.delete("greeting") # bool
|
|
52
|
+
|
|
53
|
+
client.close()
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or use it as an async context manager, which closes the client for you:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
async with await NanocachedClient.connect([("127.0.0.1", 8357)]) as client:
|
|
62
|
+
await client.set("greeting", "hello")
|
|
63
|
+
print(await client.get("greeting"))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Keys may be `str` (encoded as UTF-8) or `bytes`; values may likewise be
|
|
67
|
+
`str` or `bytes` on the way in. `get(key)` strictly decodes the stored
|
|
68
|
+
value as UTF-8 and returns `str | None` — a value that isn't valid UTF-8
|
|
69
|
+
raises `UnicodeDecodeError` rather than silently mangling it. Use
|
|
70
|
+
`get_bytes(key) -> bytes | None` for the raw bytes.
|
|
71
|
+
|
|
72
|
+
## Discovery replicas
|
|
73
|
+
|
|
74
|
+
When the cluster runs more than one discovery server, pass them all in
|
|
75
|
+
`addresses`; both the initial connect and every node-list refresh try them
|
|
76
|
+
in order. An address that is warming up after a restart (answers `B`) is
|
|
77
|
+
skipped like an unreachable one; if every address is warming up, `connect()`
|
|
78
|
+
raises `DiscoveryBusyError` — retry shortly.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
client = await NanocachedClient.connect([("10.0.0.1", 8357), ("10.0.0.2", 8357)])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Replication
|
|
85
|
+
|
|
86
|
+
The cluster's replication factor R rides along with the node list, so the
|
|
87
|
+
SDK needs no configuration: `set`/`delete` fan out to all R owners of a
|
|
88
|
+
key (the primary's result decides; a dead replica never fails a write),
|
|
89
|
+
and `get` asks the primary, falling over to the next owner only when the
|
|
90
|
+
holder is unreachable. `client.replication` exposes the factor in use.
|
|
91
|
+
|
|
92
|
+
## Fire-and-forget replica writes
|
|
93
|
+
|
|
94
|
+
Off by default. `set`/`delete` normally wait for every replica leg to
|
|
95
|
+
finish, same as the primary. Enabling `fire_and_forget_replicas` returns
|
|
96
|
+
as soon as the primary acks, letting replica legs finish in the
|
|
97
|
+
background (doc/adr/0014-*.md):
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
client = await NanocachedClient.connect(
|
|
101
|
+
[("cache.internal", 8357)],
|
|
102
|
+
fire_and_forget_replicas=True,
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Unlike `compress`, this is a pure latency/durability trade for this
|
|
107
|
+
client's own writes — it carries no wire format, and different clients
|
|
108
|
+
may use different settings freely. At most 32 replica writes across the
|
|
109
|
+
whole client run in the background at once; past that cap, further
|
|
110
|
+
replica legs run synchronously exactly as with the option off (a
|
|
111
|
+
graceful degrade, not a queue or a drop). `close()` gives any
|
|
112
|
+
still-in-flight background replica writes a chance to finish before
|
|
113
|
+
tearing down their connections.
|
|
114
|
+
|
|
115
|
+
## Read repair
|
|
116
|
+
|
|
117
|
+
Off by default. A clean miss (the key's first-reached owner reports it
|
|
118
|
+
missing) is normally accepted as-is. Enabling `read_repair` probes the
|
|
119
|
+
remaining owners before accepting that, and repairs the primary in the
|
|
120
|
+
background if one still has the value (doc/adr/0015-*.md):
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
client = await NanocachedClient.connect(
|
|
124
|
+
[("cache.internal", 8357)],
|
|
125
|
+
read_repair=True,
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Closes the narrow window after a primary restart where a replica still
|
|
130
|
+
holds a key its (fresh) primary doesn't, at the cost of extra reads only
|
|
131
|
+
on the misses that hit that window. The repair write carries no TTL —
|
|
132
|
+
the wire protocol's `G` response never returns one to preserve — and,
|
|
133
|
+
unlike fire-and-forget replica writes, is uncapped and not drained on
|
|
134
|
+
`close()`: this only fires on an already-rare clean miss, and losing one
|
|
135
|
+
costs nothing beyond staying in the window for one more read.
|
|
136
|
+
|
|
137
|
+
## Reconnect and keep-alive
|
|
138
|
+
|
|
139
|
+
`nanocached-node` closes connections idle for 60 seconds; the SDK keeps
|
|
140
|
+
its connections warm automatically, pinging any connection that real
|
|
141
|
+
traffic has left idle for 30 seconds — so an idle timeout never severs a
|
|
142
|
+
healthy client, and a request that does find its connection dead (a node
|
|
143
|
+
restart, a network blip) redials and retries once transparently (all
|
|
144
|
+
operations are idempotent). There is nothing to configure.
|
|
145
|
+
|
|
146
|
+
## Authentication and TLS
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
client = await NanocachedClient.connect(
|
|
150
|
+
[("cache.internal", 8357)],
|
|
151
|
+
auth_secret="change-me", # NANOCACHED_AUTH_SECRET on the server
|
|
152
|
+
tls=True, # verifies against the platform trust store
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For a self-signed or private-CA server, pass `ca` — a PEM file of trusted
|
|
157
|
+
root certificate(s), which replaces the default trust store:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
client = await NanocachedClient.connect(
|
|
161
|
+
[("cache.internal", 8357)],
|
|
162
|
+
tls=True,
|
|
163
|
+
ca="cluster-ca.pem",
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`ca` is only meaningful when `tls=True`; if `tls=False` it is silently
|
|
168
|
+
ignored. An unreadable or unparseable CA file is a connect-time error.
|
|
169
|
+
|
|
170
|
+
## Value compression
|
|
171
|
+
|
|
172
|
+
Off by default. When enabled, values at or above `compression_threshold`
|
|
173
|
+
bytes are transparently DEFLATE-compressed on `set` and decompressed on
|
|
174
|
+
`get`/`get_bytes` (doc/adr/0013-\*.md):
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
client = await NanocachedClient.connect(
|
|
178
|
+
[("cache.internal", 8357)],
|
|
179
|
+
compress=True,
|
|
180
|
+
compression_threshold=256, # default; bytes, below which values are stored as-is
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Every client that reads or writes a given set of keys must agree on
|
|
185
|
+
`compress`.** This is a per-keyspace format decision, not a per-client
|
|
186
|
+
preference — enabling it prefixes every value this client writes with a
|
|
187
|
+
one-byte marker, so a client with `compress=False` reading one of those
|
|
188
|
+
values gets the marker byte back as if it were part of the value (wrong,
|
|
189
|
+
silently), and a client with `compress=True` reading a value written
|
|
190
|
+
before compression was enabled anywhere risks misreading that value's
|
|
191
|
+
first byte as the marker (a `DecompressionError`, or — if that byte
|
|
192
|
+
happens to be the "uncompressed" marker by chance — a silently wrong
|
|
193
|
+
read). There is no dual-mode migration path: only turn this on for a
|
|
194
|
+
fresh keyspace, or only after every client touching an existing one has
|
|
195
|
+
upgraded and enabled it together. Incompressible data (already-compressed
|
|
196
|
+
media, random bytes) is passed through unchanged rather than bloated.
|
|
197
|
+
|
|
198
|
+
## Notes
|
|
199
|
+
|
|
200
|
+
- Requests are pipelined per connection (doc/adr/0016-*.md), matching
|
|
201
|
+
the TypeScript SDK: concurrent callers on the same connection each pay
|
|
202
|
+
only their own network latency, not everyone else's ahead of them.
|
|
203
|
+
- This SDK speaks the current wire protocol (rendezvous hashing,
|
|
204
|
+
replication-aware `L`/`W`); it requires an up-to-date server.
|
|
205
|
+
- `close()` is idempotent, but calling it again on an already-closed
|
|
206
|
+
client prints a warning to stderr — usually a sign the client's
|
|
207
|
+
lifecycle was mismanaged. Likewise, calling `connect()` again for the
|
|
208
|
+
same single address while a previous connection to it is still open
|
|
209
|
+
prints a warning ("was close() forgotten?"); this check is skipped for
|
|
210
|
+
multi-address configs, where concurrent clients sharing an address list
|
|
211
|
+
are legitimate.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# nanocached (Python)
|
|
2
|
+
|
|
3
|
+
asyncio client SDK for [nanocached](https://github.com/nanocached/nanocached),
|
|
4
|
+
a tiny distributed cache. Talks to either a single `nanocached-node` or a
|
|
5
|
+
`nanocached-discovery`-fronted cluster — the SDK figures out which from the
|
|
6
|
+
server's own handshake, so the calling code is identical either way.
|
|
7
|
+
|
|
8
|
+
Requires Python 3.11+. No runtime dependencies.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pip install nanocached
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import asyncio
|
|
20
|
+
from nanocached import NanocachedClient
|
|
21
|
+
|
|
22
|
+
async def main():
|
|
23
|
+
# Point at a single node, or at a discovery server fronting a
|
|
24
|
+
# cluster — same call either way. `addresses` always takes a list;
|
|
25
|
+
# a one-element list is the single-target case.
|
|
26
|
+
client = await NanocachedClient.connect([("127.0.0.1", 8357)])
|
|
27
|
+
|
|
28
|
+
await client.set("greeting", "hello", ttl_seconds=60)
|
|
29
|
+
value = await client.get("greeting") # str | None
|
|
30
|
+
print(value) # hello
|
|
31
|
+
existed = await client.delete("greeting") # bool
|
|
32
|
+
|
|
33
|
+
client.close()
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or use it as an async context manager, which closes the client for you:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
async with await NanocachedClient.connect([("127.0.0.1", 8357)]) as client:
|
|
42
|
+
await client.set("greeting", "hello")
|
|
43
|
+
print(await client.get("greeting"))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Keys may be `str` (encoded as UTF-8) or `bytes`; values may likewise be
|
|
47
|
+
`str` or `bytes` on the way in. `get(key)` strictly decodes the stored
|
|
48
|
+
value as UTF-8 and returns `str | None` — a value that isn't valid UTF-8
|
|
49
|
+
raises `UnicodeDecodeError` rather than silently mangling it. Use
|
|
50
|
+
`get_bytes(key) -> bytes | None` for the raw bytes.
|
|
51
|
+
|
|
52
|
+
## Discovery replicas
|
|
53
|
+
|
|
54
|
+
When the cluster runs more than one discovery server, pass them all in
|
|
55
|
+
`addresses`; both the initial connect and every node-list refresh try them
|
|
56
|
+
in order. An address that is warming up after a restart (answers `B`) is
|
|
57
|
+
skipped like an unreachable one; if every address is warming up, `connect()`
|
|
58
|
+
raises `DiscoveryBusyError` — retry shortly.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
client = await NanocachedClient.connect([("10.0.0.1", 8357), ("10.0.0.2", 8357)])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Replication
|
|
65
|
+
|
|
66
|
+
The cluster's replication factor R rides along with the node list, so the
|
|
67
|
+
SDK needs no configuration: `set`/`delete` fan out to all R owners of a
|
|
68
|
+
key (the primary's result decides; a dead replica never fails a write),
|
|
69
|
+
and `get` asks the primary, falling over to the next owner only when the
|
|
70
|
+
holder is unreachable. `client.replication` exposes the factor in use.
|
|
71
|
+
|
|
72
|
+
## Fire-and-forget replica writes
|
|
73
|
+
|
|
74
|
+
Off by default. `set`/`delete` normally wait for every replica leg to
|
|
75
|
+
finish, same as the primary. Enabling `fire_and_forget_replicas` returns
|
|
76
|
+
as soon as the primary acks, letting replica legs finish in the
|
|
77
|
+
background (doc/adr/0014-*.md):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
client = await NanocachedClient.connect(
|
|
81
|
+
[("cache.internal", 8357)],
|
|
82
|
+
fire_and_forget_replicas=True,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Unlike `compress`, this is a pure latency/durability trade for this
|
|
87
|
+
client's own writes — it carries no wire format, and different clients
|
|
88
|
+
may use different settings freely. At most 32 replica writes across the
|
|
89
|
+
whole client run in the background at once; past that cap, further
|
|
90
|
+
replica legs run synchronously exactly as with the option off (a
|
|
91
|
+
graceful degrade, not a queue or a drop). `close()` gives any
|
|
92
|
+
still-in-flight background replica writes a chance to finish before
|
|
93
|
+
tearing down their connections.
|
|
94
|
+
|
|
95
|
+
## Read repair
|
|
96
|
+
|
|
97
|
+
Off by default. A clean miss (the key's first-reached owner reports it
|
|
98
|
+
missing) is normally accepted as-is. Enabling `read_repair` probes the
|
|
99
|
+
remaining owners before accepting that, and repairs the primary in the
|
|
100
|
+
background if one still has the value (doc/adr/0015-*.md):
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
client = await NanocachedClient.connect(
|
|
104
|
+
[("cache.internal", 8357)],
|
|
105
|
+
read_repair=True,
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Closes the narrow window after a primary restart where a replica still
|
|
110
|
+
holds a key its (fresh) primary doesn't, at the cost of extra reads only
|
|
111
|
+
on the misses that hit that window. The repair write carries no TTL —
|
|
112
|
+
the wire protocol's `G` response never returns one to preserve — and,
|
|
113
|
+
unlike fire-and-forget replica writes, is uncapped and not drained on
|
|
114
|
+
`close()`: this only fires on an already-rare clean miss, and losing one
|
|
115
|
+
costs nothing beyond staying in the window for one more read.
|
|
116
|
+
|
|
117
|
+
## Reconnect and keep-alive
|
|
118
|
+
|
|
119
|
+
`nanocached-node` closes connections idle for 60 seconds; the SDK keeps
|
|
120
|
+
its connections warm automatically, pinging any connection that real
|
|
121
|
+
traffic has left idle for 30 seconds — so an idle timeout never severs a
|
|
122
|
+
healthy client, and a request that does find its connection dead (a node
|
|
123
|
+
restart, a network blip) redials and retries once transparently (all
|
|
124
|
+
operations are idempotent). There is nothing to configure.
|
|
125
|
+
|
|
126
|
+
## Authentication and TLS
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
client = await NanocachedClient.connect(
|
|
130
|
+
[("cache.internal", 8357)],
|
|
131
|
+
auth_secret="change-me", # NANOCACHED_AUTH_SECRET on the server
|
|
132
|
+
tls=True, # verifies against the platform trust store
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
For a self-signed or private-CA server, pass `ca` — a PEM file of trusted
|
|
137
|
+
root certificate(s), which replaces the default trust store:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
client = await NanocachedClient.connect(
|
|
141
|
+
[("cache.internal", 8357)],
|
|
142
|
+
tls=True,
|
|
143
|
+
ca="cluster-ca.pem",
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`ca` is only meaningful when `tls=True`; if `tls=False` it is silently
|
|
148
|
+
ignored. An unreadable or unparseable CA file is a connect-time error.
|
|
149
|
+
|
|
150
|
+
## Value compression
|
|
151
|
+
|
|
152
|
+
Off by default. When enabled, values at or above `compression_threshold`
|
|
153
|
+
bytes are transparently DEFLATE-compressed on `set` and decompressed on
|
|
154
|
+
`get`/`get_bytes` (doc/adr/0013-\*.md):
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
client = await NanocachedClient.connect(
|
|
158
|
+
[("cache.internal", 8357)],
|
|
159
|
+
compress=True,
|
|
160
|
+
compression_threshold=256, # default; bytes, below which values are stored as-is
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Every client that reads or writes a given set of keys must agree on
|
|
165
|
+
`compress`.** This is a per-keyspace format decision, not a per-client
|
|
166
|
+
preference — enabling it prefixes every value this client writes with a
|
|
167
|
+
one-byte marker, so a client with `compress=False` reading one of those
|
|
168
|
+
values gets the marker byte back as if it were part of the value (wrong,
|
|
169
|
+
silently), and a client with `compress=True` reading a value written
|
|
170
|
+
before compression was enabled anywhere risks misreading that value's
|
|
171
|
+
first byte as the marker (a `DecompressionError`, or — if that byte
|
|
172
|
+
happens to be the "uncompressed" marker by chance — a silently wrong
|
|
173
|
+
read). There is no dual-mode migration path: only turn this on for a
|
|
174
|
+
fresh keyspace, or only after every client touching an existing one has
|
|
175
|
+
upgraded and enabled it together. Incompressible data (already-compressed
|
|
176
|
+
media, random bytes) is passed through unchanged rather than bloated.
|
|
177
|
+
|
|
178
|
+
## Notes
|
|
179
|
+
|
|
180
|
+
- Requests are pipelined per connection (doc/adr/0016-*.md), matching
|
|
181
|
+
the TypeScript SDK: concurrent callers on the same connection each pay
|
|
182
|
+
only their own network latency, not everyone else's ahead of them.
|
|
183
|
+
- This SDK speaks the current wire protocol (rendezvous hashing,
|
|
184
|
+
replication-aware `L`/`W`); it requires an up-to-date server.
|
|
185
|
+
- `close()` is idempotent, but calling it again on an already-closed
|
|
186
|
+
client prints a warning to stderr — usually a sign the client's
|
|
187
|
+
lifecycle was mismanaged. Likewise, calling `connect()` again for the
|
|
188
|
+
same single address while a previous connection to it is still open
|
|
189
|
+
prints a warning ("was close() forgotten?"); this check is skipped for
|
|
190
|
+
multi-address configs, where concurrent clients sharing an address list
|
|
191
|
+
are legitimate.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nanocached"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "asyncio client SDK for nanocached, a tiny distributed cache with client-side replication"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Takashi Yamashina" }]
|
|
13
|
+
keywords = ["nanocached", "cache", "distributed-cache", "rendezvous-hashing", "client", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://nanocached.org"
|
|
24
|
+
Repository = "https://github.com/nanocached/nanocached"
|
|
25
|
+
Issues = "https://github.com/nanocached/nanocached/issues"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""nanocached — asyncio client SDK for the nanocached distributed cache.
|
|
2
|
+
|
|
3
|
+
See https://github.com/nanocached/nanocached for the server and protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._compression import DecompressionError
|
|
7
|
+
from ._errors import (
|
|
8
|
+
AlreadyClosedError,
|
|
9
|
+
DiscoveryBusyError,
|
|
10
|
+
NanocachedError,
|
|
11
|
+
WrongNodeError,
|
|
12
|
+
)
|
|
13
|
+
from ._hashring import HashRing
|
|
14
|
+
from ._identify import DiscoveredNode
|
|
15
|
+
from .client import NanocachedClient
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AlreadyClosedError",
|
|
19
|
+
"DecompressionError",
|
|
20
|
+
"DiscoveredNode",
|
|
21
|
+
"DiscoveryBusyError",
|
|
22
|
+
"HashRing",
|
|
23
|
+
"NanocachedClient",
|
|
24
|
+
"NanocachedError",
|
|
25
|
+
"WrongNodeError",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""doc/adr/0013-*.md: transparent, opt-in value compression. Values are
|
|
2
|
+
prefixed with a one-byte marker once ``compress`` is enabled on a client —
|
|
3
|
+
``0x00`` for stored-as-is (below threshold, or compression didn't shrink
|
|
4
|
+
it), ``0x01`` for raw-DEFLATE-compressed (RFC 1951, no zlib/gzip
|
|
5
|
+
wrapper). Never present when ``compress`` is off."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
from ._errors import NanocachedError
|
|
12
|
+
|
|
13
|
+
_MARKER_RAW = 0x00
|
|
14
|
+
_MARKER_DEFLATE = 0x01
|
|
15
|
+
|
|
16
|
+
# Raw DEFLATE (no zlib header/checksum): wbits=-15 selects it on both the
|
|
17
|
+
# compress and decompress side. See doc/adr/0013-*.md for why raw DEFLATE
|
|
18
|
+
# was chosen over a zlib/gzip wrapper.
|
|
19
|
+
_WBITS_RAW_DEFLATE = -15
|
|
20
|
+
|
|
21
|
+
# Bounds a DEFLATE value's expanded output. The wire cap
|
|
22
|
+
# (_MAX_VALUE_LENGTH) bounds only the compressed bytes received; without
|
|
23
|
+
# this, a small, highly-repetitive value written by a compromised node
|
|
24
|
+
# could expand to gigabytes and exhaust client memory on a plain get (a
|
|
25
|
+
# decompression bomb). Far above any realistic cache value.
|
|
26
|
+
_MAX_DECOMPRESSED_LENGTH = 64 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DecompressionError(NanocachedError):
|
|
30
|
+
"""Raised by get/get_bytes when a value with ``compress`` enabled
|
|
31
|
+
can't be interpreted — almost always a ``compress`` mismatch between
|
|
32
|
+
clients sharing this key (doc/adr/0013-*.md's compatibility caveat:
|
|
33
|
+
every client touching a given keyspace must agree on ``compress``),
|
|
34
|
+
not a transient failure."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compress_value(value: bytes, threshold: int) -> bytes:
|
|
38
|
+
"""Below ``threshold``, or when compressing doesn't actually shrink
|
|
39
|
+
the value (incompressible data), the marker byte alone is added and
|
|
40
|
+
the value is stored unchanged. Always returns a value with the marker
|
|
41
|
+
byte prefixed."""
|
|
42
|
+
if len(value) < threshold:
|
|
43
|
+
return bytes([_MARKER_RAW]) + value
|
|
44
|
+
|
|
45
|
+
# zlib.compress() always emits a zlib-wrapped stream; compressobj()
|
|
46
|
+
# with a negative wbits is what produces the header-less raw DEFLATE
|
|
47
|
+
# form this SDK's wire format uses (see the module docstring).
|
|
48
|
+
compressor = zlib.compressobj(6, zlib.DEFLATED, _WBITS_RAW_DEFLATE)
|
|
49
|
+
compressed = compressor.compress(value) + compressor.flush()
|
|
50
|
+
|
|
51
|
+
if len(compressed) < len(value):
|
|
52
|
+
return bytes([_MARKER_DEFLATE]) + compressed
|
|
53
|
+
return bytes([_MARKER_RAW]) + value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decompress_value(value: bytes) -> bytes:
|
|
57
|
+
"""The get/get_bytes counterpart to compress_value(). Only ever called
|
|
58
|
+
when ``compress`` is enabled on this client — see doc/adr/0013-*.md."""
|
|
59
|
+
if len(value) == 0:
|
|
60
|
+
raise DecompressionError(
|
|
61
|
+
"nanocached: compress is enabled but this value has no marker byte "
|
|
62
|
+
"(it's empty) — was it written by a client with compress disabled?"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
marker, body = value[0], value[1:]
|
|
66
|
+
|
|
67
|
+
if marker == _MARKER_RAW:
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
if marker == _MARKER_DEFLATE:
|
|
71
|
+
try:
|
|
72
|
+
decompressor = zlib.decompressobj(_WBITS_RAW_DEFLATE)
|
|
73
|
+
# max_length caps output; a non-empty unconsumed_tail (or an
|
|
74
|
+
# over-cap result) means more would follow — a bomb — so we stop
|
|
75
|
+
# rather than let flush() expand the rest into memory.
|
|
76
|
+
out = decompressor.decompress(body, _MAX_DECOMPRESSED_LENGTH + 1)
|
|
77
|
+
if decompressor.unconsumed_tail or len(out) > _MAX_DECOMPRESSED_LENGTH:
|
|
78
|
+
raise DecompressionError(
|
|
79
|
+
"nanocached: decompressed value exceeds the maximum size — "
|
|
80
|
+
"possible decompression bomb"
|
|
81
|
+
)
|
|
82
|
+
return out + decompressor.flush()
|
|
83
|
+
except zlib.error as error:
|
|
84
|
+
raise DecompressionError(
|
|
85
|
+
"nanocached: failed to decompress a value marked as compressed — "
|
|
86
|
+
f"did every client sharing this key enable compress (doc/adr/0013-*.md)? ({error})"
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
raise DecompressionError(
|
|
90
|
+
f"nanocached: unrecognized compression marker byte {marker} — was this value "
|
|
91
|
+
"written by a client with compress disabled (doc/adr/0013-*.md)?"
|
|
92
|
+
)
|