Skip to content
1 change: 1 addition & 0 deletions changelog.d/809.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make gzip compression deterministic by using a fixed mtime so identical inputs produce identical compressed bytes.
36 changes: 36 additions & 0 deletions django_redis/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@ def sunionstore(self, *args, **kwargs):
def hset(self, *args, **kwargs):
return self.client.hset(*args, **kwargs)

@omit_exception
def hsetnx(self, *args, **kwargs):
return self.client.hsetnx(*args, **kwargs)

@omit_exception
def hget(self, *args, **kwargs):
return self.client.hget(*args, **kwargs)

@omit_exception
def hgetall(self, *args, **kwargs):
return self.client.hgetall(*args, **kwargs)

@omit_exception
def hmset(self, *args, **kwargs):
return self.client.hmset(*args, **kwargs)

@omit_exception
def hmget(self, *args, **kwargs):
return self.client.hmget(*args, **kwargs)

@omit_exception
def hincrby(self, *args, **kwargs):
return self.client.hincrby(*args, **kwargs)

@omit_exception
def hincrbyfloat(self, *args, **kwargs):
return self.client.hincrbyfloat(*args, **kwargs)

@omit_exception
def hdel(self, *args, **kwargs):
return self.client.hdel(*args, **kwargs)
Expand All @@ -275,10 +303,18 @@ def hlen(self, *args, **kwargs):
def hkeys(self, *args, **kwargs):
return self.client.hkeys(*args, **kwargs)

@omit_exception
def hvals(self, *args, **kwargs):
return self.client.hvals(*args, **kwargs)

@omit_exception
def hexists(self, *args, **kwargs):
return self.client.hexists(*args, **kwargs)

@omit_exception
def hstrlen(self, *args, **kwargs):
return self.client.hstrlen(*args, **kwargs)

# Sorted Set Operations
@omit_exception
def zadd(self, *args, **kwargs):
Expand Down
158 changes: 157 additions & 1 deletion django_redis/client/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
import socket
from collections import OrderedDict
from collections.abc import Iterable, Iterator
from collections.abc import Iterable, Iterator, Mapping
from contextlib import suppress
from typing import (
Any,
Expand Down Expand Up @@ -508,6 +508,8 @@ def decode(self, value: EncodableT) -> Any:
try:
value = int(value)
except (ValueError, TypeError):
with suppress(ValueError, TypeError):
return float(value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do not have incrbyfloat, why add it to hash commands?

# Handle little values, chosen to be not compressed
with suppress(CompressorError):
value = self._compressor.decompress(value)
Expand Down Expand Up @@ -1147,6 +1149,133 @@ def hset(
nvalue = self.encode(value)
return int(client.hset(name, nkey, nvalue))

def hsetnx(
self,
name: str,
key: KeyT,
value: EncodableT,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> int:
"""
Set the value of hash name at key to value if it does not already exist.
Returns 1 if field was set, otherwise 0.
"""
if client is None:
client = self.get_client(write=True)
nkey = self.make_key(key, version=version)
nvalue = self.encode(value)
return int(client.hsetnx(name, nkey, nvalue))

def hget(
self,
name: str,
key: KeyT,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> Any:
"""
Return the value of key within hash name. Returns None if field is missing.
"""
if client is None:
client = self.get_client(write=False)
nkey = self.make_key(key, version=version)
result = client.hget(name, nkey)
if result is None:
return None
return self.decode(result)

def hgetall(
self,
name: str,
client: Optional[Redis] = None,
) -> dict[str, Any]:
"""
Return a dictionary of all fields and values stored in hash name.
"""
if client is None:
client = self.get_client(write=False)
result = client.hgetall(name)
return {self.reverse_key(k.decode()): self.decode(v) for k, v in result.items()}

def hmset(
self,
name: str,
mapping: Mapping[KeyT, EncodableT],
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> bool:
"""
Set multiple hash fields to multiple values.
"""
if client is None:
client = self.get_client(write=True)
nmapping = {
self.make_key(k, version=version): self.encode(v)
for k, v in mapping.items()
}
return bool(client.hset(name, mapping=nmapping))

def hmget(
self,
name: str,
keys: Iterable[KeyT],
*args: KeyT,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> list[Any]:
"""
Return a list of values ordered identically to keys.
"""
if client is None:
client = self.get_client(write=False)

if args:
if isinstance(keys, (str, CacheKey, bytes)):
key_iterable: Iterable[KeyT] = (keys, *args)
else:
key_iterable = (*keys, *args)
elif isinstance(keys, (str, CacheKey, bytes)):
key_iterable = (keys,)
else:
key_iterable = keys

nkeys = [self.make_key(key, version=version) for key in key_iterable]
results = client.hmget(name, nkeys)
return [self.decode(value) if value is not None else None for value in results]

def hincrby(
self,
name: str,
key: KeyT,
amount: int = 1,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> int:
"""
Increment the integer value of a hash field by the given amount.
"""
if client is None:
client = self.get_client(write=True)
nkey = self.make_key(key, version=version)
return int(client.hincrby(name, nkey, amount))

def hincrbyfloat(
self,
name: str,
key: KeyT,
amount: float = 1.0,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> float:
"""
Increment the float value of a hash field by the given amount.
"""
if client is None:
client = self.get_client(write=True)
nkey = self.make_key(key, version=version)
return float(client.hincrbyfloat(name, nkey, amount))

def hdel(
self,
name: str,
Expand Down Expand Up @@ -1190,6 +1319,18 @@ def hkeys(
except _main_exceptions as e:
raise ConnectionInterrupted(connection=client) from e

def hvals(
self,
name: str,
client: Optional[Redis] = None,
) -> list[Any]:
"""
Return a list of values in hash name.
"""
if client is None:
client = self.get_client(write=False)
return [self.decode(value) for value in client.hvals(name)]

def hexists(
self,
name: str,
Expand All @@ -1204,3 +1345,18 @@ def hexists(
client = self.get_client(write=False)
nkey = self.make_key(key, version=version)
return bool(client.hexists(name, nkey))

def hstrlen(
self,
name: str,
key: KeyT,
version: Optional[int] = None,
client: Optional[Redis] = None,
) -> int:
"""
Return the string length of the value stored at key in hash name.
"""
if client is None:
client = self.get_client(write=False)
nkey = self.make_key(key, version=version)
return int(client.hstrlen(name, nkey))
4 changes: 3 additions & 1 deletion django_redis/compressors/gzip.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ class GzipCompressor(BaseCompressor):

def compress(self, value: bytes) -> bytes:
if len(value) > self.min_length:
return gzip.compress(value)
# Use a fixed mtime so repeated compressions of the same value
# produce identical bytes (important when the result is used as a key).
return gzip.compress(value, mtime=0)
return value

def decompress(self, value: bytes) -> bytes:
Expand Down
61 changes: 59 additions & 2 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import threading
import time
from collections.abc import Iterable
from contextlib import suppress
from datetime import timedelta
from typing import Union, cast
from unittest.mock import patch
Expand All @@ -22,11 +23,13 @@
@pytest.fixture
def patch_itersize_setting() -> Iterable[None]:
# destroy cache to force recreation with overriden settings
del caches["default"]
with suppress(AttributeError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps this should be solved somehow different? why is it needed? which Django version introduced this error?

del caches["default"]
with override_settings(DJANGO_REDIS_SCAN_ITERSIZE=30):
yield
# destroy cache to force recreation with original settings
del caches["default"]
with suppress(AttributeError):
del caches["default"]


class TestDjangoRedisCache:
Expand Down Expand Up @@ -871,6 +874,60 @@ def test_hexists(self, cache: RedisCache):
assert cache.hexists("foo_hash5", "foo1")
assert not cache.hexists("foo_hash5", "foo")

def test_hget_and_hgetall(self, cache: RedisCache):
if isinstance(cache.client, ShardClient):
pytest.skip("ShardClient doesn't support get_client")
cache.hset("foo_hash6", "foo1", "bar1")
cache.hset("foo_hash6", "foo2", 2)

assert cache.hget("foo_hash6", "foo1") == "bar1"
assert cache.hget("foo_hash6", "foo2") == 2
assert cache.hget("foo_hash6", "missing") is None

assert cache.hgetall("foo_hash6") == {"foo1": "bar1", "foo2": 2}

def test_hmset_and_hmget(self, cache: RedisCache):
if isinstance(cache.client, ShardClient):
pytest.skip("ShardClient doesn't support get_client")
data = {"foo1": "bar1", "foo2": 2, "foo3": 3}
assert cache.hmset("foo_hash7", data)
values = cache.hmget("foo_hash7", ["foo1", "foo2", "foo3", "foo4"])
assert values == ["bar1", 2, 3, None]
assert cache.hmget("foo_hash7", "foo1", "foo3") == ["bar1", 3]

def test_hsetnx(self, cache: RedisCache):
if isinstance(cache.client, ShardClient):
pytest.skip("ShardClient doesn't support get_client")
assert cache.hsetnx("foo_hash8", "foo1", "bar1") == 1
assert cache.hsetnx("foo_hash8", "foo1", "bar2") == 0
assert cache.hget("foo_hash8", "foo1") == "bar1"
assert cache.hsetnx("foo_hash8", "foo2", "bar2") == 1
assert cache.hget("foo_hash8", "foo2") == "bar2"

def test_hincrby_and_hincrbyfloat(self, cache: RedisCache):
if isinstance(cache.client, ShardClient):
pytest.skip("ShardClient doesn't support get_client")
cache.hset("foo_hash9", "foo1", 1)
assert cache.hincrby("foo_hash9", "foo1", 2) == 3
assert cache.hget("foo_hash9", "foo1") == 3

new_value = cache.hincrbyfloat("foo_hash9", "foo1", 0.5)
assert new_value == pytest.approx(3.5)
assert cache.hget("foo_hash9", "foo1") == pytest.approx(3.5)

def test_hvals_and_hstrlen(self, cache: RedisCache):
if isinstance(cache.client, ShardClient):
pytest.skip("ShardClient doesn't support get_client")
cache.hset("foo_hash10", "foo1", "bar1")
cache.hset("foo_hash10", "foo2", 2)

assert set(cache.hvals("foo_hash10")) == {"bar1", 2}

cache.hset("foo_hash10", "foo3", 12345)
assert set(cache.hvals("foo_hash10")) == {"bar1", 2, 12345}
assert cache.hstrlen("foo_hash10", "foo3") == 5
assert cache.hstrlen("foo_hash10", "foo_missing") == 0

def test_sadd(self, cache: RedisCache):
assert cache.sadd("foo", "bar") == 1
assert cache.smembers("foo") == {"bar"}
Expand Down
Loading