Skip to content

Commit a76eeac

Browse files
authored
Merge pull request #358 from Krukov/feat-compression-for-serialized-values
Add compression for values
2 parents 9d2c510 + 47b5101 commit a76eeac

8 files changed

Lines changed: 140 additions & 36 deletions

File tree

Readme.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ scalable and reliable applications. This library intends to make it easy to impl
3333
- Bloom filters
3434
- Different cache invalidation techniques (time-based or tags)
3535
- Cache any objects securely with pickle (use [secret](#redis))
36+
- Save memory size with compression
3637
- 2x faster than `aiocache` (with client side caching)
3738

3839
## Usage Example
@@ -135,31 +136,29 @@ _Requires [redis](https://github.com/redis/redis-py) package._\
135136
This will use Redis as a storage.
136137

137138
This backend uses [pickle](https://docs.python.org/3/library/pickle.html) module to serialize
138-
values, but the cashes can store values with sha1-keyed hash.
139+
values, but the cashes can store values with md5-keyed hash.
139140

140141
Use `secret` and `digestmod` parameters to protect your application from security vulnerabilities.
141-
142142
The `digestmod` is a hashing algorithm that can be used: `sum`, `md5` (default), `sha1` and `sha256`
143-
144143
The `secret` is a salt for a hash.
145144

146145
Pickle can't serialize any type of object. In case you need to store more complex types
146+
you can use [dill](https://github.com/uqfoundation/dill) - set `pickle_type="dill"`. Dill is great, but less performance.
147147

148-
you can use [dill](https://github.com/uqfoundation/dill) - set `pickle_type="dill"`.
149-
Dill is great, but less performance.
150148
If you need complex serializer for [sqlalchemy](https://docs.sqlalchemy.org/en/14/core/serializer.html) objects you can set `pickle_type="sqlalchemy"`
151149
Use `json` also an option to serialize/deserialize an object, but it very limited (`pickle_type="json"`)
152150

153151
Any connection errors are suppressed, to disable it use `suppress=False` - a `CacheBackendInteractionError` will be raised
154152

155-
If you would like to use [client-side cache](https://redis.io/topics/client-side-caching) set `client_side=True`
153+
For some data, it may be useful to use compression. Gzip and zlib compression are available;
154+
you can use the `compress_type` parameter to configure it.
156155

157-
Client side cache will add `cashews:` prefix for each key, to customize it use `client_side_prefix` option.
156+
If you would like to use [client-side cache](https://redis.io/topics/client-side-caching) set `client_side=True`. Client side cache will add `cashews:` prefix for each key, to customize it use `client_side_prefix` option.
158157

159158
```python
160159
cache.setup("redis://0.0.0.0/?db=1&minsize=10&suppress=false&secret=my_secret", prefix="func")
161160
cache.setup("redis://0.0.0.0/2", password="my_pass", socket_connect_timeout=0.1, retry_on_timeout=True, secret="my_secret")
162-
cache.setup("redis://0.0.0.0", client_side=True, client_side_prefix="my_prefix:", pickle_type="dill")
161+
cache.setup("redis://0.0.0.0", client_side=True, client_side_prefix="my_prefix:", pickle_type="dill", compress_type="gzip")
163162
```
164163

165164
For using secure connections to redis (over ssl) uri should have `rediss` as schema
@@ -177,10 +176,12 @@ This will use local sqlite databases (with shards) as storage.
177176
It is a good choice if you don't want to use redis, but you need a shared storage, or your cache takes a lot of local memory.
178177
Also, it is a good choice for client side local storage.
179178

180-
You can setup disk cache with [FanoutCache parameters](http://www.grantjenks.com/docs/diskcache/api.html#fanoutcache)
179+
You can setup disk cache with [Cache parameters](https://grantjenks.com/docs/diskcache/api.html#diskcache.diskcache.DEFAULT_SETTINGS)
181180

182181
** Warning ** `cache.scan` and `cache.get_match` does not work with this storage (works only if shards are disabled)
183182

183+
** Warning ** Be careful with the [default settings](https://grantjenks.com/docs/diskcache/api.html#diskcache.diskcache.DEFAULT_SETTINGS) as they contain parameters such as `size_limit`
184+
184185
```python
185186
cache.setup("disk://")
186187
cache.setup("disk://?directory=/tmp/cache&timeout=1&shards=0") # disable shards

cashews/backends/interface.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ def __init__(self, *args, serializer: Serializer | None = None, **kwargs) -> Non
236236
self._serializer = serializer
237237
self._on_remove_callbacks: list[OnRemoveCallback] = []
238238

239+
@property
240+
def serializer(self) -> Serializer | None:
241+
return self._serializer
242+
239243
def on_remove_callback(self, callback: OnRemoveCallback) -> None:
240244
self._on_remove_callbacks.append(callback)
241245

cashews/compresors.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import gzip
4+
import zlib
5+
from enum import Enum
6+
7+
from .exceptions import DecompressionError, UnsupportedCompressorError
8+
9+
10+
class CompressType(Enum):
11+
NULL = "null"
12+
GZIP = "gzip"
13+
ZLIB = "zlib"
14+
15+
16+
class Compressor:
17+
@staticmethod
18+
def compress(value: bytes) -> bytes:
19+
return value
20+
21+
@staticmethod
22+
def decompress(value: bytes) -> bytes:
23+
return value
24+
25+
26+
class GzipCompressor(Compressor):
27+
@staticmethod
28+
def compress(value: bytes) -> bytes:
29+
return gzip.compress(value)
30+
31+
@staticmethod
32+
def decompress(value: bytes) -> bytes:
33+
try:
34+
return gzip.decompress(value)
35+
except gzip.BadGzipFile as exc:
36+
raise DecompressionError from exc
37+
38+
39+
class ZlibCompressor(Compressor):
40+
@staticmethod
41+
def compress(value: bytes) -> bytes:
42+
return zlib.compress(value)
43+
44+
@staticmethod
45+
def decompress(value: bytes) -> bytes:
46+
try:
47+
return zlib.decompress(value)
48+
except zlib.error as exc:
49+
raise DecompressionError from exc
50+
51+
52+
_compressors = {
53+
CompressType.NULL: Compressor,
54+
CompressType.GZIP: GzipCompressor,
55+
CompressType.ZLIB: ZlibCompressor,
56+
}
57+
58+
59+
def get_compressor(compress_type: CompressType | None) -> type[Compressor]:
60+
if compress_type is None:
61+
return Compressor
62+
if compress_type not in _compressors:
63+
raise UnsupportedCompressorError
64+
return _compressors[compress_type]

cashews/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ class UnsupportedPicklerError(CacheError):
1414
"""Unknown or unsupported pickle type."""
1515

1616

17+
class UnsupportedCompressorError(CacheError):
18+
"""Unknown or unsupported compress type."""
19+
20+
21+
class DecompressionError(CacheError):
22+
"""Wrong compress data"""
23+
24+
1725
class UnSecureDataError(CacheError):
1826
"""Unsecure data in cache storage"""
1927

cashews/picklers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class PicklerType(Enum):
9494
}
9595

9696

97-
def get_pickler(pickler_type: PicklerType):
97+
def get_pickler(pickler_type: PicklerType) -> type[Pickler]:
9898
if pickler_type not in _picklers:
9999
raise UnsupportedPicklerError()
100100

cashews/serialize.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import hashlib
44
import hmac
5+
from contextlib import suppress
56
from typing import TYPE_CHECKING
67

7-
from .exceptions import SignIsMissingError, UnSecureDataError
8+
from .compresors import Compressor, CompressType, get_compressor
9+
from .exceptions import DecompressionError, SignIsMissingError, UnSecureDataError
810
from .picklers import Pickler, PicklerType, get_pickler
911

1012
if TYPE_CHECKING: # pragma: no cover
@@ -30,7 +32,15 @@ def _to_bytes(value: str | bytes) -> bytes:
3032
return value
3133

3234

33-
class HashSigner:
35+
class Signer:
36+
def sign(self, key: Key, value: bytes) -> bytes:
37+
return value
38+
39+
def check_sign(self, key: Key, value: bytes) -> bytes:
40+
return value
41+
42+
43+
class HashSigner(Signer):
3444
_digestmods = {
3545
b"sha1": _seal(hashlib.sha1),
3646
b"md5": _seal(hashlib.md5),
@@ -71,14 +81,7 @@ def _get_sign_and_digestmod(self, sign: bytes) -> tuple[bytes, bytes]:
7181
return sign, digestmod
7282

7383

74-
class NullSigner:
75-
@staticmethod
76-
def sign(key: Key, value: bytes) -> bytes:
77-
return value
78-
79-
@staticmethod
80-
def check_sign(key: Key, value: bytes) -> bytes:
81-
return value
84+
NullSigner = Signer
8285

8386

8487
class Serializer:
@@ -88,24 +91,32 @@ def __init__(self, check_repr=False):
8891
self._check_repr = check_repr
8992
self._pickler = get_pickler(PicklerType.NULL)
9093
self._signer = NullSigner()
94+
self._compressor = get_compressor(CompressType.NULL)()
9195

92-
def set_signer(self, signer):
96+
def set_signer(self, signer: Signer) -> None:
9397
self._signer = signer
9498

95-
def set_pickler(self, pickler):
99+
def set_pickler(self, pickler: Pickler) -> None:
96100
self._pickler = pickler
97101

102+
def set_compression(self, compressor: Compressor) -> None:
103+
self._compressor = compressor
104+
98105
@classmethod
99106
def register_type(cls, klass: type, encoder, decoder):
100107
cls._type_mapping[bytes(klass.__name__, "utf8")] = (encoder, decoder)
101108

102109
async def encode(self, backend: Backend, key: Key, value: Value, expire: float | None) -> bytes: # on SET
103110
if isinstance(value, int) and not isinstance(value, bool):
104111
return value # type: ignore[return-value]
112+
113+
value = await self._encode(backend, key, value, expire)
114+
value = self._compressor.compress(value)
115+
return self._signer.sign(key, value)
116+
117+
async def _encode(self, backend: Backend, key: Key, value: Value, expire: float | None) -> bytes:
105118
_value = await self._custom_encode(backend, key, value, expire)
106-
if _value is not None:
107-
return self._signer.sign(key, _value)
108-
return self._signer.sign(key, self._pickler.dumps(value))
119+
return _value or self._pickler.dumps(value)
109120

110121
async def _custom_encode(self, backend, key: Key, value: Value, expire: float | None) -> bytes | None:
111122
value_type = bytes(type(value).__name__, "utf8")
@@ -127,6 +138,11 @@ async def decode(self, backend: Backend, key: Key, value: bytes, default: Value)
127138
except SignIsMissingError:
128139
return default
129140

141+
# for backword compatibility we ignore decompression error because
142+
# it is dynamic setting that can be changed by settings
143+
with suppress(DecompressionError):
144+
value = self._compressor.decompress(value)
145+
130146
try:
131147
value = self._decode(value)
132148
except self._pickler.UnpicklingError:
@@ -180,18 +196,22 @@ def get_serializer(
180196
digestmod: str | bytes = b"md5",
181197
check_repr: bool = True,
182198
pickle_type: PicklerType | None = None,
199+
compress_type: CompressType | str | None = None,
183200
) -> Serializer:
184201
_serializer = Serializer(check_repr=check_repr)
185202
if secret:
186203
_serializer.set_signer(HashSigner(secret, digestmod))
187204
_serializer.set_pickler(_get_pickler(pickle_type or PicklerType.NULL, bool(secret)))
205+
if isinstance(compress_type, str):
206+
compress_type = CompressType(compress_type)
207+
_serializer.set_compression(get_compressor(compress_type)())
188208
return _serializer
189209

190210

191211
def _get_pickler(pickle_type: PicklerType, hash_key: bool) -> Pickler:
192212
if pickle_type is PicklerType.NULL and hash_key:
193213
pickle_type = PicklerType.DEFAULT
194-
return get_pickler(pickle_type)
214+
return get_pickler(pickle_type)()
195215

196216

197217
DEFAULT_SERIALIZER = get_serializer(pickle_type=PicklerType.DEFAULT)

cashews/wrapper/wrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from cashews import validation
77
from cashews.backends.interface import Backend
88
from cashews.commands import Command
9+
from cashews.compresors import CompressType
910
from cashews.exceptions import NotConfiguredError
1011
from cashews.picklers import PicklerType
1112
from cashews.serialize import get_serializer
@@ -71,6 +72,7 @@ def setup(
7172
digestmod=params.pop("digestmod", b"md5"),
7273
check_repr=params.pop("check_repr", True),
7374
pickle_type=PicklerType(params.pop("pickle_type", pickle_type)),
75+
compress_type=CompressType(params.pop("compress_type", CompressType.NULL)),
7476
)
7577
backend = backend_class(**params, serializer=serializer)
7678
if disable:

tests/test_pickle_serializer.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,32 @@ class TestDC:
2424
@pytest.fixture(
2525
name="cache",
2626
params=[
27-
"default_md5",
28-
"default_sum",
29-
"default_sha256",
30-
pytest.param("redis_md5", marks=pytest.mark.redis),
31-
pytest.param("redis_sum", marks=pytest.mark.redis),
32-
pytest.param("dill_sum", marks=pytest.mark.integration),
33-
pytest.param("sqlalchemy_sha1", marks=pytest.mark.integration),
27+
"default_md5_null",
28+
"default_sum_zlib",
29+
"default_sha256_null",
30+
pytest.param("redis_md5_null", marks=pytest.mark.redis),
31+
pytest.param("redis_md5_gzip", marks=pytest.mark.redis),
32+
pytest.param("redis_sum_zlib", marks=pytest.mark.redis),
33+
pytest.param("dill_sum_null", marks=pytest.mark.integration),
34+
pytest.param("sqlalchemy_sha1_null", marks=pytest.mark.integration),
3435
],
3536
)
3637
async def _cache(request, redis_dsn):
37-
pickle_type, digestmod = request.param.split("_")
38+
pickle_type, digestmod, compress_type = request.param.split("_")
3839
if pickle_type == "redis":
3940
from cashews.backends.redis import Redis
4041

41-
redis = Redis(redis_dsn, suppress=False, serializer=get_serializer(secret=b"test", digestmod=digestmod))
42+
redis = Redis(
43+
redis_dsn,
44+
suppress=False,
45+
serializer=get_serializer(secret=b"test", digestmod=digestmod, compress_type=compress_type),
46+
)
4247
await redis.init()
4348
await redis.clear()
4449
yield redis
4550
await redis.close()
4651
else:
47-
yield Memory(serializer=get_serializer(secret=b"test", digestmod=digestmod))
52+
yield Memory(serializer=get_serializer(secret=b"test", digestmod=digestmod, compress_type=compress_type))
4853

4954

5055
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)