GitHub user nagisa-kunhah created a discussion: Proposal for Cuckoo Filter

# Cuckoo Filter Design Proposal for Apache Kvrocks

## 1. Introduction

This proposal describes the design for adding RedisBloom-compatible Cuckoo 
Filter support to Apache Kvrocks.

The related tracking issue is apache/kvrocks#3123. The first implementation 
work is being developed in apache/kvrocks#3481, starting with `CF.RESERVE` and 
`CF.ADD`. This proposal updates the earlier Cuckoo Filter discussion in 
apache/kvrocks#3079 and reflects the design changes made during review, 
especially the move from one RocksDB key per bucket to paged bucket storage.

Cuckoo Filter is a probabilistic data structure for approximate set membership. 
It is similar to Bloom Filter in that it can return false positives, but it 
also supports deletion because each inserted item is represented by a small 
fingerprint stored in one of two candidate buckets. RedisBloom exposes Cuckoo 
Filter through commands such as `CF.RESERVE`, `CF.ADD`, `CF.ADDNX`, 
`CF.EXISTS`, `CF.MEXISTS`, `CF.COUNT`, `CF.DEL`, `CF.INFO`, `CF.INSERT`, 
`CF.INSERTNX`, `CF.SCANDUMP`, and `CF.LOADCHUNK`.

Kvrocks already supports RedisBloom-style Bloom Filter commands. Adding Cuckoo 
Filter support improves RedisBloom compatibility and provides users with a 
probabilistic structure that can later support duplicate counting and deletion 
semantics.

## 2. Design Summary

This proposal uses a chained Cuckoo Filter design. A logical Cuckoo Filter key 
stores compact metadata in the Metadata column family and stores bucket 
contents as paged subkeys in the PrimarySubkey column family. Each page 
contains multiple logical buckets to avoid the overhead of one RocksDB key per 
bucket. Expansion appends new sub-filters instead of rebuilding existing data.

## 3. Command Categories

The RedisBloom-compatible Cuckoo Filter command set can be implemented in 
several groups. This grouping also gives us a natural way to split the work 
into reviewable PRs.

1. Creation and insertion commands

   These commands create a filter or add items to it. They are the foundation 
of the data layout because they define how metadata, pages, buckets, 
fingerprints, duplicate items, and expansion are written.

   - `CF.RESERVE key capacity [BUCKETSIZE bucket_size] [MAXITERATIONS 
max_iterations] [EXPANSION expansion]`
   - `CF.ADD key item`
   - `CF.ADDNX key item`
   - `CF.INSERT key [CAPACITY capacity] [NOCREATE] ITEMS item [item ...]`
   - `CF.INSERTNX key [CAPACITY capacity] [NOCREATE] ITEMS item [item ...]`

2. Membership and count commands

   These commands read the candidate buckets of one or more items. They need to 
search all sub-filters in the chain because an item may have been inserted 
before or after expansion.

   - `CF.EXISTS key item`
   - `CF.MEXISTS key item [item ...]`
   - `CF.COUNT key item`

3. Delete command

   This command removes one matching fingerprint occurrence from the filter. It 
depends on the same lookup path as `CF.EXISTS`, but also needs to update the 
page and metadata atomically.

   - `CF.DEL key item`

4. Information command

   This command exposes the logical filter state and is useful for users and 
tests to inspect capacity, size, number of filters, expansion, bucket size, and 
related metadata.

   - `CF.INFO key`

5. Dump and load commands

   These commands export and restore the internal Cuckoo Filter representation. 
They are important for RedisBloom compatibility and for workflows that need 
chunked serialization.

   - `CF.SCANDUMP key iterator`
   - `CF.LOADCHUNK key iterator data`

## 4. High-Level Design

A logical Cuckoo Filter is represented by one Redis key. Internally, it is 
modeled as a chain of sub-filters:

- `CuckooChain` represents the logical Redis key and owns high-level operations 
such as reserve, add, expansion, metadata validation, and write-batch commit.
- `CuckooSubFilter` represents one sub-filter inside the chain. It implements 
normal insertion and kick-out insertion for one sub-filter.
- `CuckooPageCache` maps logical buckets to persisted page values, reads the 
required pages, stages dirty page updates, and writes modified pages into the 
final RocksDB write batch.
- `CuckooFilterHelper` provides stable algorithm-level helpers such as hashing, 
fingerprint generation, alternate-bucket calculation, capacity normalization, 
and bucket-count calculation.

<img width="1122" height="1402" alt="Cuckoo Filter" 
src="https://github.com/user-attachments/assets/adea604e-a5d6-468a-b605-d7420e46a4c1";
 />

The design uses chained expansion instead of in-place resizing. Resizing a 
Cuckoo Filter in place would change bucket indexes because bucket placement 
depends on the number of buckets, which would require rebuilding existing data. 
Instead, when the current chain cannot accept an item and scaling is enabled, 
Kvrocks appends a new sub-filter. Existing sub-filters remain valid and do not 
need to be rewritten.

The main storage decision is to persist pages instead of individual buckets. 
The Cuckoo algorithm still works on logical bucket indexes, but RocksDB stores 
fewer, larger values.

## 5. Metadata Layout

Each logical Cuckoo Filter key has one metadata entry in the Metadata column 
family.

Conceptually:

```text
Metadata column family:
  <namespace-prefixed user key> -> CuckooChainMetadata
```

The Redis type name is `MBbloomCF`, matching RedisBloom's Cuckoo Filter type 
name.

`CuckooChainMetadata` extends the existing Kvrocks `Metadata` base class. The 
base metadata continues to provide common key-level fields such as type, 
expiration, version, and logical size. Cuckoo-specific fields are encoded after 
the base metadata.

| Field | Type | Description |
|---|---:|---|
| `size` | `uint64_t` | Total number of successful insertions recorded in the 
logical Cuckoo Filter chain. This is inherited from base metadata. |
| `expire` | `uint64_t` | Expiration timestamp inherited from base metadata. |
| `version` | `uint64_t` | Metadata version inherited from base metadata. Page 
subkeys include this version so stale pages from older versions can be ignored. 
|
| `n_filters` | `uint16_t` | Number of sub-filters in the chain. |
| `expansion` | `uint16_t` | Growth factor used when appending a new 
sub-filter. `0` means non-scaling mode. Non-zero values are normalized to a 
power of two. |
| `base_capacity` | `uint64_t` | Requested capacity of the first sub-filter. 
Later sub-filter capacity is derived from this value and `expansion`. |
| `bucket_size` | `uint8_t` | Number of fingerprint slots per bucket. |
| `max_iterations` | `uint16_t` | Maximum number of kick-out iterations before 
insertion is considered failed for a sub-filter. |
| `num_deleted_items` | `uint64_t` | Reserved for delete/count maintenance in 
later commands. |
| `page_size` | `uint32_t` | Target payload size for each persisted Cuckoo 
Filter page value. The current default is 2048 bytes. |

`page_size` is an internal Kvrocks storage parameter. It is not exposed as a 
Redis command option.

`CF.RESERVE` writes only metadata. It does not preallocate pages. Pages are 
created lazily when a bucket inside the page is first modified.

Because a reserved but empty Cuckoo Filter has `size == 0`, 
`kRedisCuckooFilter` must be treated as an emptyable type in metadata logic. 
Otherwise, the key could be incorrectly treated as expired or removable simply 
because the logical size is zero.

## 6. Bucket and Page Storage Layout

The earlier design stored each Cuckoo bucket as a separate RocksDB key. Review 
feedback pointed out that this would create too many tiny keys. For example, if 
a bucket contains only a few bytes, the RocksDB internal key, memtable, block 
index/filter, and compaction overhead can dominate the actual payload.

The updated design groups multiple logical buckets into one persisted page 
value.

Conceptually:

```text
PrimarySubkey column family:
  InternalKey(<ns_key>, <filter_index, page_index>, version) -> page_data
```

The internal page subkey contains:

- `filter_index`: the sub-filter index inside the chain.
- `page_index`: the page index inside that sub-filter.

The page value is a byte array. Each bucket occupies `bucket_size` bytes inside 
the page, and each byte stores one fingerprint slot:

```text
page_data:
  bucket 0: [fp0, fp1, ...]
  bucket 1: [fp0, fp1, ...]
  ...
```

A fingerprint value of `0` means the slot is empty. Valid fingerprints are in 
the range `1..255`.

The number of buckets per page is:

```text
buckets_per_page = max(1, page_size / bucket_size)
```

The mapping from a logical bucket to a page is:

```text
page_index = bucket_index / buckets_per_page
offset = (bucket_index % buckets_per_page) * bucket_size
```

The last page of a sub-filter may be smaller than `page_size` because it only 
needs to contain the remaining buckets. When a page is missing from RocksDB, it 
is treated as an all-zero page of the expected size. When an existing page is 
present but has an unexpected size, the implementation treats it as corruption.

<!-- TODO: Add diagram: one page containing many bucket byte ranges. -->

This page-based layout reduces the number of RocksDB keys and improves storage 
efficiency compared with one-key-per-bucket storage, while preserving the 
logical Cuckoo Filter algorithm based on individual bucket indexes.

## 7. Hashing and Fingerprints

The hashing model is part of the persistent data layout, so it must remain 
stable once Cuckoo Filter data has been written.

The current implementation follows RedisBloom-style hashing:

- The item hash uses `HllMurMurHash64A(data, length, 0)`.
- The fingerprint is generated as `hash % 255 + 1`.
- `0` is reserved as the empty-slot marker.
- The first bucket uses `hash % num_buckets`.
- The alternate hash uses `hash ^ (fingerprint * 0x5bd1e995)`.
- The second bucket uses `alternate_hash % num_buckets`.

For an item:

```text
hash = MurmurHash64A(item, seed = 0)
fingerprint = hash % 255 + 1
bucket1 = hash % num_buckets
bucket2 = (hash ^ (fingerprint * 0x5bd1e995)) % num_buckets
```

During kick-out insertion, the implementation may no longer have the original 
full item hash for an evicted fingerprint. It computes the alternate bucket 
from the current bucket index and the fingerprint:

```text
alternate_bucket = (current_bucket ^ (fingerprint * 0x5bd1e995)) % num_buckets
```

The bucket count is rounded to a power of two. With a power-of-two bucket 
count, using the bucket index in the alternate-bucket calculation preserves the 
required symmetry for moving a fingerprint between its two candidate buckets.

## 8. Capacity and Expansion

The requested capacity is converted into a bucket count using a target load 
factor of `0.955`.

```text
required_buckets = ceil(capacity / bucket_size / 0.955)
num_buckets = next_power_of_two(required_buckets)
```

The power-of-two bucket count is required for correct alternate-bucket behavior 
and better distribution.

The default parameters are aligned with RedisBloom compatibility:

| Parameter | Default |
|---|---:|
| `capacity` for auto-created filters | `1024` |
| `BUCKETSIZE` | `2` |
| `MAXITERATIONS` | `20` |
| `EXPANSION` | `1` |
| `page_size` | `2048` bytes |

`EXPANSION` behavior:

- `EXPANSION 0` disables scaling. If the filter becomes full, insertion returns 
an error.
- `EXPANSION 1` appends new sub-filters with the same capacity as the first 
sub-filter.
- Non-zero values greater than one are normalized to the next power of two 
before being stored.
- The maximum accepted `EXPANSION` value is `32768`.

The capacity of sub-filter `i` is derived as:

```text
filter_capacity(i) = base_capacity * expansion^i
```

For non-scaling filters, `n_filters` should remain `1`.

## 9. Atomicity, Expiration, and Versioning

All writes that modify bucket/page data and metadata are committed through one 
RocksDB write batch.

This is important because `metadata.size`, `metadata.n_filters`, and page 
contents must describe the same logical state. For example, after a successful 
`CF.ADD`, the modified page and the incremented metadata size should become 
visible together.

Kvrocks metadata versioning is reused for Cuckoo Filter page subkeys. The page 
key includes the current metadata version, so stale pages from older versions 
can be ignored after key overwrite or deletion. This follows the same general 
pattern used by other complex data types in Kvrocks.

Expiration is handled by the existing metadata layer. Cuckoo Filter-specific 
code should use `Database::GetMetadata`/type-aware metadata loading instead of 
raw metadata CF reads so expired keys and wrong-type cases are handled 
consistently.

## 10. Replication and Migration

Replication uses Kvrocks' existing write-batch propagation mechanism. Cuckoo 
Filter writes include write-batch log data with the `kRedisCuckooFilter` type. 
The actual metadata and page updates are then replayed as part of the 
replicated write batch.

For slot migration:

- Raw key-value migration is supported because it copies the metadata and page 
subkeys directly.
- Command-based migration is intentionally unsupported for now.

Command-based migration is difficult because Cuckoo Filter pages contain only 
fingerprints, not original items. Kvrocks cannot reconstruct a semantically 
equivalent sequence of `CF.ADD` commands from the persisted data.

The current behavior should explicitly return an unsupported error for 
`MBbloomCF` command migration and ask users to use raw key-value migration.

## 11. Disk Usage and Scan/Type Integration

The new type is registered as `kRedisCuckooFilter` with Redis type name 
`MBbloomCF`.

Integration points:

- `TYPE key` should return `MBbloomCF`.
- `SCAN ... TYPE MBbloomCF` should include Cuckoo Filter keys.
- `DISK USAGE key` should account for the metadata entry and page subkeys.
- Empty reserved Cuckoo Filters should be valid keys and should not be dropped 
only because `metadata.size == 0`.

## 12. Trade-Offs

### 12.1 Chained Sub-Filters Instead of Rehashing

Appending sub-filters avoids rebuilding existing data and keeps expansion 
cheap. The trade-off is that future lookup, count, and delete commands may need 
to inspect candidate buckets in multiple sub-filters.

For `CF.EXISTS`, this means checking the two candidate buckets in each 
sub-filter until a match is found or all sub-filters are exhausted.

For `CF.COUNT`, this means scanning the two candidate buckets in each 
sub-filter and summing matching fingerprints.

For `CF.DEL`, this means finding and removing one matching fingerprint 
occurrence across the chain.

### 12.2 Paged Buckets Instead of Bucket-Per-Key

Paged bucket storage reduces RocksDB key overhead and improves space 
efficiency. The trade-off is write amplification at the page level: modifying 
one bucket rewrites the containing page value. This is acceptable because the 
page size is bounded and the alternative bucket-per-key design would create a 
large number of very small RocksDB entries.

The default page size is currently 2048 bytes. This should be treated as an 
internal storage parameter, not a user-facing RedisBloom option.


## 13. References

- Tracking issue: https://github.com/apache/kvrocks/issues/3123
- Current implementation PR: https://github.com/apache/kvrocks/pull/3481
- Earlier discussion: https://github.com/apache/kvrocks/discussions/3079
- Redis Cuckoo Filter documentation: 
https://redis.io/docs/latest/develop/data-types/probabilistic/cuckoo-filter/
- Cuckoo Filter paper: https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf


GitHub link: https://github.com/apache/kvrocks/discussions/3530

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to