nagisa-kunhah commented on PR #3481: URL: https://github.com/apache/kvrocks/pull/3481#issuecomment-4390189366
@jihuayu Hi, thank you for the review. I have summarized the design and implementation details below to explain the layering, responsibilities, and key storage decisions. # 1 Design Overview The design follows Kvrocks' existing layered architecture. Conceptually, the change can be divided into four layers: 1. Command layer 2. Type layer 3. Metadata/storage encoding layer 4. RocksDB persistence layer ## 1.1 Command layer The command layer introduces RedisBloom-compatible Cuckoo Filter commands, such as `CF.RESERVE`, `CF.ADD`, `CF.EXISTS`, and `CF.MEXISTS`. Following the existing architecture, each command is implemented as a `Commander` subclass and registered through the existing command registration mechanism. ## 1.2 Type layer The type layer introduces the new `CuckooChain` abstraction to represent each logical Cuckoo Filter. The reason for using a chain abstraction is that Cuckoo Filters are not easy to resize in place: the bucket positions depend on the current bucket count, so resizing a filter would require rebuilding existing data. Instead, the design appends new sub-filters when expansion is needed. This model is inspired by RedisBloom's scalable Cuckoo Filter design. `CuckooChain` implements the high-level operations for a logical Cuckoo Filter, including `RESERVE`, `ADD`, `EXISTS`, `MEXISTS`, and related key-level behavior. To keep the type layer separated from the algorithm details, `CuckooFilter` is introduced as an internal helper. It provides Cuckoo Filter-specific calculations used by `CuckooChain`, while `CuckooChain` remains responsible for the Redis/Kvrocks-facing behavior. ## 1.3 Metadata/storage encoding layer At the metadata/storage encoding layer, the implementation adds `CuckooChainMetadata` to store the state and parameters for each logical Cuckoo Filter. This metadata extends the existing Kvrocks metadata model, so common key-level fields such as type, size, expiration, and version are still handled consistently with other data types. The Cuckoo-specific fields describe the filter chain, including the number of sub-filters, base capacity, bucket size, expansion factor, and insertion iteration limit. This keeps the logical filter state compact while leaving the actual bucket contents in subkeys. ## 1.4 RocksDB persistence layer At the persistence layer, the implementation reuses Kvrocks' existing metadata/subkey model. The logical key metadata is stored as metadata, and individual buckets are stored as internal subkeys. This follows the same general pattern used by other complex Redis data structures in Kvrocks. The metadata entry and bucket entries are written through RocksDB write batches when they need to be updated together. This keeps the logical filter state and the modified bucket data consistent without introducing a separate persistence path for Cuckoo Filter. # 2 Logical Structure A logical Cuckoo Filter is associated with one user key. Internally, it is represented as a chain of sub-filters rather than a single resizable filter. The relationship between the main concepts is: <img width="1536" height="1024" alt="cuckoo_filter_logical_structure" src="https://github.com/user-attachments/assets/a58904fb-4528-47c6-822f-670ba21f84eb" /> - The logical filter is the user-visible Cuckoo Filter associated with a Redis key. - A sub-filter is one filter segment in the chain. Expansion appends new sub-filters to the chain. - Each sub-filter contains `num_buckets` buckets, and `num_buckets` is rounded to a power of two. - A bucket belongs to one sub-filter and contains a fixed number of slots. - A slot stores one fingerprint. A zero value means the slot is empty. - A fingerprint is a compact representation derived from the item hash. # 3 Implementation Details ## 3.1 Data Layout ### 3.1.1 Metadata Each logical Cuckoo Filter key has one `CuckooChainMetadata` entry. The metadata describes the filter-level state and configuration, while the actual bucket data is stored separately. The metadata is stored in the `metadata` column family. The RocksDB key is the namespace-prefixed logical Redis key, represented as `ns_key` in the implementation. Conceptually: ```text metadata CF: <ns_key> -> CuckooChainMetadata ``` The metadata contains the following fields: | Field Name | Data Type | Size (Bytes) | Description | |---|---|---:|---| | `size` | `uint64_t` | 8 | The total number of items recorded in the entire filter chain. This field is inherited from the base `Metadata`. | | `expire` | `uint64_t` | 8 | The expiration timestamp of the logical key. This field is inherited from the base `Metadata`. | | `version` | `uint64_t` | 8 | The metadata version used to separate current subkeys from stale subkeys. This field is inherited from the base `Metadata`. | | `n_filters` | `uint16_t` | 2 | The number of sub-filters in the chain. | | `expansion` | `uint16_t` | 2 | The growth factor used when a new sub-filter is appended. | | `base_capacity` | `uint64_t` | 8 | The capacity of the first sub-filter. The capacity of later sub-filters is derived from this value and `expansion`. | | `bucket_size` | `uint8_t` | 1 | The number of fingerprint slots each bucket can hold. | | `max_iterations` | `uint16_t` | 2 | The maximum number of relocation attempts during insertion. | | `num_deleted_items` | `uint64_t` | 8 | The number of deleted items recorded by the filter. | This metadata belongs to the logical Cuckoo Filter as a whole. It is not stored per bucket or per sub-filter. ### 3.1.2 Bucket Storage The bucket data is stored as internal subkeys under the logical Cuckoo Filter key. Each bucket is identified by both a `filter_index` and a `bucket_index`. The bucket subkey is constructed from: - `filter_index`: identifies which sub-filter in the chain the bucket belongs to. - `bucket_index`: identifies the bucket inside that sub-filter. The bucket value is a fixed-size byte array whose length is `bucket_size`. Each byte stores one fingerprint. A value of `0` represents an empty slot, while valid fingerprints are stored as non-zero values. Conceptually, the bucket layout is: ```text PrimarySubkey CF: <bucket_key> -> bucket_data bucket_key = InternalKey(<ns_key>, <encoded filter_index and bucket_index>, version) ``` Here, `filter_index` and `bucket_index` are encoded into the bucket subkey as binary fields, not as a numeric sum. This layout keeps the logical filter metadata separate from the bucket contents, while still reusing Kvrocks' existing internal subkey model. ## 3.2 Hashing Model ### 3.2.1 Item Hash Each item is first converted into a 64-bit hash using `HllMurMurHash64A` with seed `0`. This is the actual function name used in the codebase. It follows Redis' MurmurHash64A-style hash implementation and keeps the hashing model close to RedisBloom's Cuckoo Filter design. The item hash is the base value used to derive both the fingerprint and the candidate bucket positions. Since these values determine where the item is stored and looked up, the hash function is part of the persistent data layout and should remain stable once data has been written. ### 3.2.2 Fingerprint The fingerprint is generated from the item hash as `hash % 255 + 1`, producing an 8-bit non-zero value in the range `1..255`; `0` is reserved as the empty-slot marker. ### 3.2.3 Candidate Buckets For each sub-filter, an item has two candidate buckets. The first bucket is derived directly from the item hash: ```text bucket1 = hash % num_buckets ``` The second bucket is derived from both the hash and the fingerprint: ```text delta = fingerprint * 0x5bd1e995 bucket2 = (hash ^ delta) % num_buckets ``` The constant `0x5bd1e995` follows RedisBloom's Cuckoo Filter implementation and is used as the mixing constant for deriving the alternate bucket. During kick-out insertion, the original item hash of an evicted fingerprint is no longer available. Instead, the implementation uses the current bucket index and the fingerprint to compute the alternate bucket: ```text alternate_bucket = (current_bucket ^ delta) % num_buckets ``` This is valid because `num_buckets` is always rounded to a power of two. When `num_buckets = 2^k`, modulo is equivalent to keeping the lower `k` bits, so: ```text (hash ^ delta) % num_buckets == ((hash % num_buckets) ^ delta) % num_buckets ``` This lets the kick-out path move a fingerprint between its two candidate buckets using only the current bucket index and the fingerprint. ## 3.3 Current Write Path ### 3.3.1 CF.RESERVE `CF.RESERVE` creates the logical Cuckoo Filter key and initializes its metadata. It validates the requested capacity and configuration parameters, checks that the key does not already exist, and then creates a `CuckooChainMetadata` entry with the initial filter configuration. The initial metadata records the base capacity, bucket size, maximum insertion iterations, expansion factor, and initializes `n_filters` to `1`. The initial number of buckets is derived from the requested capacity and bucket size. The calculation uses a target load factor of `0.955`, which reserves extra slots instead of assuming that all slots can be filled successfully. The result is then rounded to a power of two. The implementation does not preallocate all buckets during reserve. Buckets are created lazily when they are first written. This keeps `CF.RESERVE` lightweight and avoids writing empty bucket data for sparse filters. ### 3.3.2 CF.ADD `CF.ADD` inserts an item into an existing logical Cuckoo Filter. It first loads and decodes the `CuckooChainMetadata`, then computes the item hash and fingerprint. For each sub-filter in the chain, the implementation derives the two candidate buckets for the item. It reads these buckets, treats missing buckets as empty buckets, and tries to place the fingerprint into any available slot in either bucket. For insertion, sub-filters are checked from the first one to the latest one, in `filter_index` order from `0` to `n_filters - 1`. This prioritizes reusing available slots in earlier sub-filters before placing data into newer sub-filters, which keeps the chain more compact and avoids expanding the effective write target too aggressively. **This is different from RedisBloom, which checks sub-filters from the latest one back to the first one.** If a free slot is found, the updated bucket data and the updated metadata are written in the same write batch. This keeps the bucket content and the logical filter state updated atomically. If no free slot is available in the candidate buckets, the implementation falls back to kick-out insertion on the latest sub-filter. The kick-out path relocates existing fingerprints between their candidate buckets and writes all modified buckets together when the insertion succeeds. ### 3.3.3 Expansion Expansion is triggered when insertion cannot find a free slot and kick-out insertion also fails. Instead of resizing an existing sub-filter in place, the implementation appends a new sub-filter to the chain. The new sub-filter is represented by increasing `n_filters` in `CuckooChainMetadata`. Its capacity is derived from `base_capacity`, `expansion`, and the new `filter_index`. The existing buckets are not rebuilt or moved during expansion. This avoids rewriting existing filter data. After expansion, the insertion is retried against the newly added sub-filter. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
