Skip to content

Commit 99a34d6

Browse files
authored
feat:implement Bloom filter commands (#240)
1 parent 7cced5f commit 99a34d6

File tree

11 files changed

+696
-23
lines changed

11 files changed

+696
-23
lines changed

docs/about/changelog.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ description: Change log of all fakeredis releases
55

66
## Next release
77

8-
## v2.18.2
8+
## v2.19.0
9+
10+
### 🚀 Features
11+
12+
- Implement Bloom filters commands #239
913

1014
### 🐛 Bug Fixes
1115

docs/redis-commands/RedisBloom.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,38 @@
11
# Probabilistic commands
22

3-
Module currently not implemented in fakeredis.
3+
## `bf` commands (5/10 implemented)
44

5+
### [BF.ADD](https://redis.io/commands/bf.add/)
56

6-
### Unsupported bf commands
7-
> To implement support for a command, see [here](../../guides/implement-command/)
7+
Adds an item to a Bloom Filter
88

9-
#### [BF.RESERVE](https://redis.io/commands/bf.reserve/) <small>(not implemented)</small>
9+
### [BF.MADD](https://redis.io/commands/bf.madd/)
1010

11-
Creates a new Bloom Filter
11+
Adds one or more items to a Bloom Filter. A filter will be created if it does not exist
1212

13-
#### [BF.ADD](https://redis.io/commands/bf.add/) <small>(not implemented)</small>
13+
### [BF.EXISTS](https://redis.io/commands/bf.exists/)
1414

15-
Adds an item to a Bloom Filter
15+
Checks whether an item exists in a Bloom Filter
1616

17-
#### [BF.MADD](https://redis.io/commands/bf.madd/) <small>(not implemented)</small>
17+
### [BF.MEXISTS](https://redis.io/commands/bf.mexists/)
1818

19-
Adds one or more items to a Bloom Filter. A filter will be created if it does not exist
19+
Checks whether one or more items exist in a Bloom Filter
2020

21-
#### [BF.INSERT](https://redis.io/commands/bf.insert/) <small>(not implemented)</small>
21+
### [BF.CARD](https://redis.io/commands/bf.card/)
2222

23-
Adds one or more items to a Bloom Filter. A filter will be created if it does not exist
23+
Returns the cardinality of a Bloom filter
2424

25-
#### [BF.EXISTS](https://redis.io/commands/bf.exists/) <small>(not implemented)</small>
2625

27-
Checks whether an item exists in a Bloom Filter
26+
### Unsupported bf commands
27+
> To implement support for a command, see [here](../../guides/implement-command/)
2828
29-
#### [BF.MEXISTS](https://redis.io/commands/bf.mexists/) <small>(not implemented)</small>
29+
#### [BF.RESERVE](https://redis.io/commands/bf.reserve/) <small>(not implemented)</small>
3030

31-
Checks whether one or more items exist in a Bloom Filter
31+
Creates a new Bloom Filter
32+
33+
#### [BF.INSERT](https://redis.io/commands/bf.insert/) <small>(not implemented)</small>
34+
35+
Adds one or more items to a Bloom Filter. A filter will be created if it does not exist
3236

3337
#### [BF.SCANDUMP](https://redis.io/commands/bf.scandump/) <small>(not implemented)</small>
3438

@@ -42,10 +46,6 @@ Restores a filter previously saved using SCANDUMP
4246

4347
Returns information about a Bloom Filter
4448

45-
#### [BF.CARD](https://redis.io/commands/bf.card/) <small>(not implemented)</small>
46-
47-
Returns the cardinality of a Bloom filter
48-
4949

5050

5151
### Unsupported cf commands

fakeredis/_fakesocket.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fakeredis.stack import JSONCommandsMixin
1+
from fakeredis.stack import JSONCommandsMixin, BFCommandsMixin
22
from ._basefakesocket import BaseFakeSocket
33
from .commands_mixins.bitmap_mixin import BitmapCommandsMixin
44
from .commands_mixins.connection_mixin import ConnectionCommandsMixin
@@ -33,6 +33,7 @@ class FakeSocket(
3333
StreamsCommandsMixin,
3434
JSONCommandsMixin,
3535
GeoCommandsMixin,
36+
BFCommandsMixin,
3637
):
3738
def __init__(self, server, db):
3839
super(FakeSocket, self).__init__(server, db)

fakeredis/_msgs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,7 @@
9393
"or use negative to start from the end of the list"
9494
)
9595
NUMKEYS_GREATER_THAN_ZERO_MSG = "numkeys should be greater than 0"
96+
FILTER_FULL_MSG = ""
97+
NONSCALING_FILTERS_CANNOT_EXPAND_MSG = "Nonscaling filters cannot expand"
98+
ITEM_EXISTS_MSG = "item exists"
99+
NOT_FOUND_MSG = "not found"

fakeredis/stack/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,18 @@
66
if e.name == "fakeredis.stack._json_mixin":
77
raise e
88

9+
910
class JSONCommandsMixin: # type: ignore
1011
pass
12+
13+
try:
14+
import pybloom_live # noqa: F401
15+
16+
from ._bf_mixin import BFCommandsMixin # noqa: F401
17+
except ImportError as e:
18+
if e.name == "fakeredis.stack._bf_mixin":
19+
raise e
20+
21+
22+
class BFCommandsMixin: # type: ignore
23+
pass

fakeredis/stack/_bf_mixin.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Command mixin for emulating `redis-py`'s BF functionality."""
2+
import io
3+
4+
import pybloom_live
5+
6+
from fakeredis import _msgs as msgs
7+
from fakeredis._command_args_parsing import extract_args
8+
from fakeredis._commands import command, Key, CommandItem, Float, Int
9+
from fakeredis._helpers import SimpleError, OK, casematch
10+
11+
12+
class ScalableBloomFilter(pybloom_live.ScalableBloomFilter):
13+
NO_GROWTH = 0
14+
15+
def __init__(self, *args, **kwargs):
16+
super().__init__(*args, **kwargs)
17+
self.filters.append(
18+
pybloom_live.BloomFilter(
19+
capacity=self.initial_capacity,
20+
error_rate=self.error_rate * self.ratio))
21+
22+
def add(self, key):
23+
if key in self:
24+
return True
25+
if self.scale == self.NO_GROWTH and self.filters and self.filters[-1].count >= self.filters[-1].capacity:
26+
raise SimpleError(msgs.FILTER_FULL_MSG)
27+
return super(ScalableBloomFilter, self).add(key)
28+
29+
30+
class BFCommandsMixin:
31+
32+
@staticmethod
33+
def _bf_add(key: CommandItem, item: bytes) -> int:
34+
res = key.value.add(item)
35+
key.updated()
36+
return 0 if res else 1
37+
38+
@staticmethod
39+
def _bf_exist(key: CommandItem, item: bytes) -> int:
40+
return 1 if (item in key.value) else 0
41+
42+
@command(
43+
name="BF.ADD",
44+
fixed=(Key(ScalableBloomFilter), bytes),
45+
repeat=(),
46+
)
47+
def bf_add(self, key, value: bytes):
48+
return BFCommandsMixin._bf_add(key, value)
49+
50+
@command(
51+
name="BF.MADD",
52+
fixed=(Key(ScalableBloomFilter), bytes),
53+
repeat=(bytes,),
54+
)
55+
def bf_madd(self, key, *values):
56+
res = list()
57+
for value in values:
58+
res.append(BFCommandsMixin._bf_add(key, value))
59+
return res
60+
61+
@command(
62+
name="BF.CARD",
63+
fixed=(Key(ScalableBloomFilter),),
64+
repeat=(),
65+
)
66+
def bf_card(self, key):
67+
return len(key.value)
68+
69+
@command(
70+
name="BF.EXISTS",
71+
fixed=(Key(ScalableBloomFilter), bytes),
72+
repeat=(),
73+
)
74+
def bf_exist(self, key, value: bytes):
75+
return BFCommandsMixin._bf_exist(key, value)
76+
77+
@command(
78+
name="BF.MEXISTS",
79+
fixed=(Key(ScalableBloomFilter), bytes),
80+
repeat=(bytes,),
81+
)
82+
def bf_mexists(self, key, *values: bytes):
83+
res = list()
84+
for value in values:
85+
res.append(BFCommandsMixin._bf_exist(key, value))
86+
return res
87+
88+
@command(
89+
name="BF.RESERVE",
90+
fixed=(Key(), Float, Int,),
91+
repeat=(bytes,),
92+
flags=msgs.FLAG_LEAVE_EMPTY_VAL,
93+
)
94+
def bf_reserve(self, key: CommandItem, error_rate, capacity, *args: bytes):
95+
if key.value is not None:
96+
raise SimpleError(msgs.ITEM_EXISTS_MSG)
97+
(expansion, non_scaling), _ = extract_args(args, ("+expansion", "nonscaling"))
98+
if expansion is not None and non_scaling:
99+
raise SimpleError(msgs.NONSCALING_FILTERS_CANNOT_EXPAND_MSG)
100+
if expansion is None:
101+
expansion = 2
102+
scale = ScalableBloomFilter.NO_GROWTH if non_scaling else expansion
103+
key.update(ScalableBloomFilter(capacity, error_rate, scale))
104+
return OK
105+
106+
@command(
107+
name="BF.INSERT",
108+
fixed=(Key(),),
109+
repeat=(bytes,),
110+
)
111+
def bf_insert(self, key: CommandItem, *args: bytes):
112+
(capacity, error_rate, expansion, non_scaling, no_create), left_args = extract_args(
113+
args, ("+capacity", ".error", "+expansion", "nonscaling", "nocreate"),
114+
error_on_unexpected=False, left_from_first_unexpected=True)
115+
# if no_create and (capacity is not None or error_rate is not None):
116+
# raise SimpleError("...")
117+
if len(left_args) < 2 or not casematch(left_args[0], b'items'):
118+
raise SimpleError("...")
119+
items = left_args[1:]
120+
121+
error_rate = error_rate or 0.001
122+
capacity = capacity or 100
123+
if key.value is None and no_create:
124+
raise SimpleError(msgs.NOT_FOUND_MSG)
125+
if expansion is not None and non_scaling:
126+
raise SimpleError(msgs.NONSCALING_FILTERS_CANNOT_EXPAND_MSG)
127+
if expansion is None:
128+
expansion = 2
129+
scale = ScalableBloomFilter.NO_GROWTH if non_scaling else expansion
130+
if key.value is None:
131+
key.value = ScalableBloomFilter(capacity, error_rate, scale)
132+
res = list()
133+
for item in items:
134+
res.append(self._bf_add(key, item))
135+
key.updated()
136+
return res
137+
138+
@command(
139+
name="BF.INFO",
140+
fixed=(Key(),),
141+
repeat=(bytes,),
142+
)
143+
def bf_info(self, key: CommandItem, *args: bytes):
144+
if key.value is None or type(key.value) != ScalableBloomFilter:
145+
raise SimpleError('...')
146+
if len(args) > 1:
147+
raise SimpleError(msgs.SYNTAX_ERROR_MSG)
148+
if len(args) == 0:
149+
return [
150+
b'Capacity', key.value.capacity,
151+
b'Size', key.value.capacity,
152+
b'Number of filters', len(key.value.filters),
153+
b'Number of items inserted', key.value.count,
154+
b'Expansion rate', key.value.scale if key.value.scale > 0 else None,
155+
]
156+
if casematch(args[0], b'CAPACITY'):
157+
return key.value.capacity
158+
elif casematch(args[0], b'SIZE'):
159+
return key.value.capacity
160+
elif casematch(args[0], b'FILTERS'):
161+
return len(key.value.filters)
162+
elif casematch(args[0], b'ITEMS'):
163+
return key.value.count
164+
elif casematch(args[0], b'EXPANSION'):
165+
return key.value.scale if key.value.scale > 0 else None
166+
else:
167+
raise SimpleError(msgs.SYNTAX_ERROR_MSG)
168+
169+
@command(
170+
name="BF.SCANDUMP",
171+
fixed=(Key(), Int,),
172+
repeat=(),
173+
flags=msgs.FLAG_LEAVE_EMPTY_VAL,
174+
)
175+
def bf_scandump(self, key: CommandItem, iterator: int):
176+
if key.value is None:
177+
raise SimpleError(msgs.NOT_FOUND_MSG)
178+
f = io.BytesIO()
179+
180+
if iterator == 0:
181+
key.value.tofile(f)
182+
f.seek(0)
183+
s = f.read()
184+
f.close()
185+
return [1, s]
186+
else:
187+
return [0, None]
188+
189+
@command(
190+
name="BF.LOADCHUNK",
191+
fixed=(Key(), Int, bytes),
192+
repeat=(),
193+
flags=msgs.FLAG_LEAVE_EMPTY_VAL,
194+
)
195+
def bf_loadchunk(self, key: CommandItem, iterator: int, data: bytes):
196+
if key.value is not None and type(key.value) != ScalableBloomFilter:
197+
raise SimpleError(msgs.NOT_FOUND_MSG)
198+
f = io.BytesIO(data)
199+
key.value = ScalableBloomFilter.fromfile(f)
200+
f.close()
201+
key.updated()
202+
return OK

0 commit comments

Comments
 (0)