Skip to content

Commit f390cc3

Browse files
Merge pull request #50 from borgbackup/items-prefix
items(): support iterating over a key-prefix partition
2 parents 6011236 + d97a70d commit f390cc3

5 files changed

Lines changed: 96 additions & 4 deletions

File tree

‎CHANGES.rst‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
Changelog
22
=========
33

4-
Version 0.1.2 (unreleased)
4+
Version 0.2.0 (unreleased)
55
--------------------------
66

7+
- ``HashTable.items()`` / ``HashTableNT.items()``: add optional ``prefix_bits`` /
8+
``prefix`` arguments to iterate only over the items whose key starts with the
9+
given bit prefix. As the keys are random bytes, this partitions the items into
10+
``2 ** prefix_bits`` roughly equally sized, disjoint sets, e.g. to process a
11+
huge hash table in batches with a small memory footprint, #49.
712
- Require ``key_size >= 4`` to avoid out-of-bounds reads in ``_get_index``, #42.
813

914
Version 0.1.1 (2026-02-09)

‎src/borghash/HashTable.pyx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,39 @@ cdef class HashTable:
226226
del self[key]
227227
return value
228228

229-
def items(self) -> Iterator[tuple[bytes, bytes]]:
229+
def items(self, *, prefix_bits: int = 0, prefix: int = 0) -> Iterator[tuple[bytes, bytes]]:
230+
"""
231+
Iterate over items, optionally only over items whose key starts with the given bit prefix.
232+
233+
prefix_bits: number of leading key bits to compare, 0..32 (0 means: no filtering).
234+
prefix: expected value of these leading key bits, given in the integer's low bits,
235+
thus: 0 <= prefix < 2 ** prefix_bits.
236+
237+
As the keys are expected to be random bytes, this partitions the keys into
238+
2 ** prefix_bits roughly equally sized, disjoint sets, while only creating
239+
bytes objects for the matching keys/values. As the prefix compares the keys'
240+
leading bits, each set is a contiguous range of the sorted key space.
241+
"""
242+
if not 0 <= prefix_bits <= 32:
243+
raise ValueError("prefix_bits must be in range 0..32.")
244+
if not 0 <= prefix < (1 << prefix_bits):
245+
raise ValueError("prefix must be in range 0..(2 ** prefix_bits - 1).")
230246
cdef size_t i
231247
cdef uint32_t kv_index
248+
cdef uint8_t* key_ptr
249+
cdef uint32_t key32
250+
cdef int shift = 32 - prefix_bits
251+
cdef uint32_t wanted = <uint32_t> prefix
252+
cdef bint filtering = prefix_bits > 0
232253
self.stats_iter += 1
233254
for i in range(self.capacity):
234255
kv_index = self.table[i]
235256
if kv_index not in (FREE_BUCKET, TOMBSTONE_BUCKET):
257+
if filtering:
258+
key_ptr = self.keys + kv_index * self.ksize
259+
key32 = (key_ptr[0] << 24) | (key_ptr[1] << 16) | (key_ptr[2] << 8) | key_ptr[3]
260+
if (key32 >> shift) != wanted:
261+
continue
236262
key = self.keys[kv_index * self.ksize:(kv_index + 1) * self.ksize]
237263
value = self.values[kv_index * self.vsize:(kv_index + 1) * self.vsize]
238264
yield key, value

‎src/borghash/HashTableNT.pyx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,13 @@ cdef class HashTableNT:
9090
self._check_key(key)
9191
return key in self.inner
9292

93-
def items(self) -> Iterator[tuple[bytes, Any]]:
94-
for key, binary_value in self.inner.items():
93+
def items(self, *, prefix_bits: int = 0, prefix: int = 0) -> Iterator[tuple[bytes, Any]]:
94+
"""
95+
Iterate over items, optionally only over items whose key starts with the given bit prefix.
96+
97+
See HashTable.items for the prefix_bits / prefix semantics.
98+
"""
99+
for key, binary_value in self.inner.items(prefix_bits=prefix_bits, prefix=prefix):
95100
yield (key, self._to_namedtuple_value(binary_value))
96101

97102
def __len__(self) -> int:

‎tests/hashtable_test.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,48 @@ def test_items(ht12):
6868
assert (key2, value2) in items
6969

7070

71+
@pytest.mark.parametrize("prefix_bits", [1, 2, 3, 8, 9, 32])
72+
def test_items_prefix(ht, prefix_bits):
73+
# pseudo-random keys, so (for small prefix_bits) all partitions should get some keys
74+
expected = {}
75+
for x in range(1000):
76+
key = H2(x)
77+
value = key[:4]
78+
ht[key] = value
79+
prefix = int.from_bytes(key[:4], "big") >> (32 - prefix_bits)
80+
expected.setdefault(prefix, set()).add((key, value))
81+
# only iterate over the actually occupied partitions (2 ** 32 would take a while)...
82+
collected = []
83+
for prefix in expected:
84+
items = set(ht.items(prefix_bits=prefix_bits, prefix=prefix))
85+
assert items == expected[prefix]
86+
collected.extend(items)
87+
# together, the occupied partitions have everything, exactly once:
88+
assert len(collected) == len(set(collected)) == 1000
89+
# ... but an unoccupied partition (if any) must yield nothing:
90+
unoccupied = next((p for p in range(2 ** prefix_bits) if p not in expected), None)
91+
if unoccupied is not None:
92+
assert list(ht.items(prefix_bits=prefix_bits, prefix=unoccupied)) == []
93+
94+
95+
def test_items_prefix_zero_bits(ht12):
96+
# prefix_bits=0 means: no filtering
97+
assert set(ht12.items(prefix_bits=0, prefix=0)) == set(ht12.items())
98+
99+
100+
def test_items_prefix_validation(ht12):
101+
with pytest.raises(ValueError):
102+
list(ht12.items(prefix_bits=-1))
103+
with pytest.raises(ValueError):
104+
list(ht12.items(prefix_bits=33))
105+
with pytest.raises(ValueError):
106+
list(ht12.items(prefix_bits=0, prefix=1))
107+
with pytest.raises(ValueError):
108+
list(ht12.items(prefix_bits=2, prefix=4))
109+
with pytest.raises(ValueError):
110+
list(ht12.items(prefix_bits=2, prefix=-1))
111+
112+
71113
def test_len(ht12):
72114
assert len(ht12) == 2
73115

‎tests/hashtablent_test.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ def test_items(ntht12):
6262
assert (key2, value2) in items
6363

6464

65+
def test_items_prefix(ntht):
66+
prefix_bits = 4
67+
expected = {}
68+
for x in range(100):
69+
key = H2(x)
70+
value = value_type(x, x + 1, x + 2)
71+
ntht[key] = value
72+
prefix = key[0] >> (8 - prefix_bits)
73+
expected.setdefault(prefix, set()).add((key, value))
74+
for prefix in range(2 ** prefix_bits):
75+
items = set(ntht.items(prefix_bits=prefix_bits, prefix=prefix))
76+
assert items == expected.get(prefix, set())
77+
78+
6579
def test_len(ntht12):
6680
assert len(ntht12) == 2
6781

0 commit comments

Comments
 (0)