This is an automated email from the ASF dual-hosted git repository.

jihuayu pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new 8e0fdefd4 feat(cf): add cf.add command (#3481)
8e0fdefd4 is described below

commit 8e0fdefd4c304ab6c0f81f83c0ba405a545b2e06
Author: hurukawa <[email protected]>
AuthorDate: Mon Jun 29 09:55:27 2026 +0800

    feat(cf): add cf.add command (#3481)
    
    task id: #3351
    
    ---------
    
    Co-authored-by: qiang_liu <[email protected]>
    Co-authored-by: 纪华裕 <[email protected]>
---
 src/commands/cmd_cuckoo_filter.cc                  | 138 ++++
 src/commands/commander.h                           |   1 +
 src/stats/disk_stats.cc                            |   9 +
 src/stats/disk_stats.h                             |   1 +
 src/storage/redis_metadata.cc                      |  49 +-
 src/storage/redis_metadata.h                       |  49 +-
 src/types/cuckoo_filter.h                          | 141 ++++
 src/types/cuckoo_filter_page.cc                    | 237 +++++++
 src/types/cuckoo_filter_page.h                     |  93 +++
 src/types/cuckoo_filter_sub_filter.cc              | 106 +++
 src/types/cuckoo_filter_sub_filter.h               |  57 ++
 src/types/redis_cuckoo_chain.cc                    | 294 ++++++++
 src/types/redis_cuckoo_chain.h                     |  67 ++
 tests/cppunit/disk_test.cc                         |  17 +
 tests/cppunit/types/cuckoo_filter_page_test.cc     | 387 +++++++++++
 tests/cppunit/types/cuckoo_filter_test.cc          | 745 +++++++++++++++++++++
 .../integration/replication/replication_test.go    |  13 +
 .../integration/slotmigrate/slotmigrate_test.go    |  37 +
 tests/gocase/unit/disk/disk_test.go                |  11 +
 tests/gocase/unit/scan/scan_test.go                |   6 +
 tests/gocase/unit/type/bloom/cuckoo_filter_test.go | 190 ++++++
 21 files changed, 2645 insertions(+), 3 deletions(-)

diff --git a/src/commands/cmd_cuckoo_filter.cc 
b/src/commands/cmd_cuckoo_filter.cc
new file mode 100644
index 000000000..2002912f9
--- /dev/null
+++ b/src/commands/cmd_cuckoo_filter.cc
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include "command_parser.h"
+#include "commander.h"
+#include "error_constants.h"
+#include "server/server.h"
+#include "types/redis_cuckoo_chain.h"
+
+namespace redis {
+
+class CommandCFReserve : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    // CF.RESERVE key capacity [BUCKETSIZE bs] [MAXITERATIONS mi] [EXPANSION 
ex]
+    if (args.size() < 3) {
+      return {Status::RedisParseErr, errWrongNumOfArguments};
+    }
+
+    // Parse capacity (required)
+    auto parse_capacity = ParseInt<uint64_t>(args[2], 10);
+    if (!parse_capacity) {
+      return {Status::RedisParseErr, "invalid capacity"};
+    }
+    capacity_ = *parse_capacity;
+    if (capacity_ <= 0) {
+      return {Status::RedisParseErr, "capacity must be larger than 0"};
+    }
+
+    // Parse optional parameters
+    CommandParser parser(args, 3);
+    while (parser.Good()) {
+      if (parser.EatEqICase("BUCKETSIZE")) {
+        auto parse_bucket_size = parser.TakeInt<uint8_t>();
+        if (!parse_bucket_size.IsOK()) {
+          return {Status::RedisParseErr, "invalid bucket size"};
+        }
+        bucket_size_ = parse_bucket_size.GetValue();
+        if (bucket_size_ == 0 || bucket_size_ > 255) {
+          return {Status::RedisParseErr, "bucket size must be between 1 and 
255"};
+        }
+      } else if (parser.EatEqICase("MAXITERATIONS")) {
+        auto parse_max_iterations = parser.TakeInt<uint16_t>();
+        if (!parse_max_iterations.IsOK()) {
+          return {Status::RedisParseErr, "invalid max iterations"};
+        }
+        max_iterations_ = parse_max_iterations.GetValue();
+        if (max_iterations_ == 0) {
+          return {Status::RedisParseErr, "max iterations must be larger than 
0"};
+        }
+      } else if (parser.EatEqICase("EXPANSION")) {
+        auto parse_expansion = parser.TakeInt<uint16_t>();
+        if (!parse_expansion.IsOK()) {
+          return {Status::RedisParseErr, "invalid expansion factor"};
+        }
+        expansion_ = parse_expansion.GetValue();
+        if (expansion_ > kCFMaxExpansion) {
+          return {Status::RedisParseErr, "expansion must be between 0 and 
32768"};
+        }
+      } else {
+        return {Status::RedisParseErr, errInvalidSyntax};
+      }
+    }
+
+    return Commander::Parse(args);
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    redis::CuckooChain cuckoo_db(srv->storage, conn->GetNamespace());
+    auto s = cuckoo_db.Reserve(ctx, args_[1], capacity_, bucket_size_, 
max_iterations_, expansion_,
+                               kCuckooFilterDefaultPageSize);
+
+    if (!s.ok()) {
+      if (s.IsInvalidArgument()) {
+        // Return error message to client
+        return {Status::RedisExecErr, s.ToString()};
+      }
+      return {Status::RedisExecErr, "failed to create cuckoo filter"};
+    }
+
+    *output = redis::SimpleString("OK");
+    return Status::OK();
+  }
+
+ private:
+  uint64_t capacity_ = kCFDefaultCapacity;
+  uint8_t bucket_size_ = kCFDefaultBucketSize;
+  uint16_t max_iterations_ = kCFDefaultMaxIterations;
+  uint16_t expansion_ = kCFDefaultExpansion;
+};
+
+class CommandCFAdd : public Commander {
+ public:
+  Status Parse(const std::vector<std::string> &args) override {
+    // CF.ADD key item
+    if (args.size() != 3) {
+      return {Status::RedisParseErr, errWrongNumOfArguments};
+    }
+    return Commander::Parse(args);
+  }
+
+  Status Execute(engine::Context &ctx, Server *srv, Connection *conn, 
std::string *output) override {
+    redis::CuckooChain cuckoo_db(srv->storage, conn->GetNamespace());
+    bool added = false;
+    auto s = cuckoo_db.Add(ctx, args_[1], args_[2], &added);
+
+    if (!s.ok()) {
+      return {Status::RedisExecErr, s.ToString()};
+    }
+
+    // Duplicate items are allowed, so successful insertions return 1.
+    *output = redis::Integer(added ? 1 : 0);
+    return Status::OK();
+  }
+};
+
+// Register the CF.RESERVE and CF.ADD commands
+REDIS_REGISTER_COMMANDS(CuckooFilter, 
MakeCmdAttr<CommandCFReserve>("cf.reserve", -3, "write", 1, 1, 1),
+                        MakeCmdAttr<CommandCFAdd>("cf.add", 3, "write", 1, 1, 
1))
+
+}  // namespace redis
diff --git a/src/commands/commander.h b/src/commands/commander.h
index 3f38db025..cee52bbdb 100644
--- a/src/commands/commander.h
+++ b/src/commands/commander.h
@@ -96,6 +96,7 @@ enum class CommandCategory : uint8_t {
   Bit,
   BloomFilter,
   Cluster,
+  CuckooFilter,
   Function,
   Geo,
   Hash,
diff --git a/src/stats/disk_stats.cc b/src/stats/disk_stats.cc
index 330a8a777..d61f9d79b 100644
--- a/src/stats/disk_stats.cc
+++ b/src/stats/disk_stats.cc
@@ -64,6 +64,8 @@ rocksdb::Status Disk::GetKeySize(engine::Context &ctx, const 
Slice &user_key, Re
       return GetZsetSize(ctx, ns_key, key_size);
     case RedisType::kRedisStream:
       return GetStreamSize(ctx, ns_key, key_size);
+    case RedisType::kRedisCuckooFilter:
+      return GetCuckooFilterSize(ctx, ns_key, key_size);
     default:
       return rocksdb::Status::NotFound("Not found ", user_key);
   }
@@ -135,4 +137,11 @@ rocksdb::Status Disk::GetStreamSize(engine::Context &ctx, 
const Slice &ns_key, u
   return GetApproximateSizes(metadata, ns_key, 
storage_->GetCFHandle(ColumnFamilyID::Stream), key_size);
 }
 
+rocksdb::Status Disk::GetCuckooFilterSize(engine::Context &ctx, const Slice 
&ns_key, uint64_t *key_size) {
+  CuckooChainMetadata metadata(false);
+  rocksdb::Status s = Database::GetMetadata(ctx, {kRedisCuckooFilter}, ns_key, 
&metadata);
+  if (!s.ok()) return s.IsNotFound() ? rocksdb::Status::OK() : s;
+  return GetApproximateSizes(metadata, ns_key, 
storage_->GetCFHandle(ColumnFamilyID::PrimarySubkey), key_size);
+}
+
 }  // namespace redis
diff --git a/src/stats/disk_stats.h b/src/stats/disk_stats.h
index d5733c2fd..b0f6831ca 100644
--- a/src/stats/disk_stats.h
+++ b/src/stats/disk_stats.h
@@ -44,6 +44,7 @@ class Disk : public Database {
   rocksdb::Status GetBitmapSize(engine::Context &ctx, const Slice &ns_key, 
uint64_t *key_size);
   rocksdb::Status GetSortedintSize(engine::Context &ctx, const Slice &ns_key, 
uint64_t *key_size);
   rocksdb::Status GetStreamSize(engine::Context &ctx, const Slice &ns_key, 
uint64_t *key_size);
+  rocksdb::Status GetCuckooFilterSize(engine::Context &ctx, const Slice 
&ns_key, uint64_t *key_size);
   rocksdb::Status GetKeySize(engine::Context &ctx, const Slice &user_key, 
RedisType type, uint64_t *key_size);
 
  private:
diff --git a/src/storage/redis_metadata.cc b/src/storage/redis_metadata.cc
index d62e566ff..2aecfdab5 100644
--- a/src/storage/redis_metadata.cc
+++ b/src/storage/redis_metadata.cc
@@ -335,7 +335,7 @@ bool Metadata::IsSingleKVType() const { return Type() == 
kRedisString || Type()
 
 bool Metadata::IsEmptyableType() const {
   return IsSingleKVType() || Type() == kRedisStream || Type() == 
kRedisBloomFilter || Type() == kRedisHyperLogLog ||
-         Type() == kRedisTDigest || Type() == kRedisTimeSeries;
+         Type() == kRedisTDigest || Type() == kRedisTimeSeries || Type() == 
kRedisCuckooFilter;
 }
 
 bool Metadata::Expired() const { return ExpireAt(util::GetTimeStampMS()); }
@@ -644,3 +644,50 @@ rocksdb::Status TimeSeriesMetadata::Decode(Slice *input) {
 
   return rocksdb::Status::OK();
 }
+
+void CuckooChainMetadata::Encode(std::string *dst) const {
+  Metadata::Encode(dst);
+
+  PutFixed16(dst, n_filters);
+  PutFixed16(dst, expansion);
+  PutFixed64(dst, base_capacity);
+  PutFixed8(dst, bucket_size);
+  PutFixed16(dst, max_iterations);
+  PutFixed64(dst, num_deleted_items);
+  PutFixed32(dst, page_size);
+}
+
+rocksdb::Status CuckooChainMetadata::Decode(Slice *input) {
+  if (auto s = Metadata::Decode(input); !s.ok()) {
+    return s;
+  }
+
+  if (input->size() < sizeof(uint16_t) * 3 + sizeof(uint64_t) * 2 + 
sizeof(uint32_t) + sizeof(uint8_t)) {
+    return rocksdb::Status::InvalidArgument(kErrMetadataTooShort);
+  }
+
+  GetFixed16(input, &n_filters);
+  GetFixed16(input, &expansion);
+  GetFixed64(input, &base_capacity);
+  GetFixed8(input, &bucket_size);
+  GetFixed16(input, &max_iterations);
+  GetFixed64(input, &num_deleted_items);
+  GetFixed32(input, &page_size);
+
+  return rocksdb::Status::OK();
+}
+
+uint64_t CuckooChainMetadata::GetTotalCapacity() const {
+  if (expansion == 0 || n_filters == 1) {
+    return base_capacity;
+  }
+
+  // Calculate total capacity across all filters
+  uint64_t total = 0;
+  uint64_t filter_capacity = base_capacity;
+  for (uint16_t i = 0; i < n_filters; i++) {
+    total += filter_capacity;
+    filter_capacity *= expansion;
+  }
+  return total;
+}
diff --git a/src/storage/redis_metadata.h b/src/storage/redis_metadata.h
index abfc60989..5ad5677a1 100644
--- a/src/storage/redis_metadata.h
+++ b/src/storage/redis_metadata.h
@@ -54,12 +54,13 @@ enum RedisType : uint8_t {
   kRedisHyperLogLog = 11,
   kRedisTDigest = 12,
   kRedisTimeSeries = 13,
+  kRedisCuckooFilter = 14,
   kRedisTypeMax
 };
 
 inline constexpr const std::array<std::string_view, kRedisTypeMax> 
RedisTypeNames = {
-    "none",      "string", "hash",      "list",      "set",         "zset",    
  "bitmap",
-    "sortedint", "stream", "MBbloom--", "ReJSON-RL", "hyperloglog", 
"TDIS-TYPE", "timeseries"};
+    "none",   "string",    "hash",      "list",        "set",       "zset",    
   "bitmap",   "sortedint",
+    "stream", "MBbloom--", "ReJSON-RL", "hyperloglog", "TDIS-TYPE", 
"timeseries", "MBbloomCF"};
 
 struct RedisTypes {
   RedisTypes(std::initializer_list<RedisType> list) {
@@ -334,6 +335,50 @@ class BloomChainMetadata : public Metadata {
   bool IsScaling() const { return expansion != 0; };
 };
 
+constexpr uint32_t kCuckooFilterDefaultPageSize = 2048;  // bytes
+
+class CuckooChainMetadata : public Metadata {
+ public:
+  /// The number of sub-filters in the chain
+  uint16_t n_filters;
+
+  /// Expansion factor for new filters
+  /// When a filter is full, a new one is created with capacity = 
base_capacity * expansion^n
+  uint16_t expansion;
+
+  /// The capacity of the first filter.
+  uint64_t base_capacity;
+
+  /// Number of fingerprints per bucket
+  uint8_t bucket_size;
+
+  /// Maximum number of cuckoo kicks before considering filter full
+  uint16_t max_iterations;
+
+  /// Track number of deleted items for maintenance
+  uint64_t num_deleted_items;
+
+  /// Target maximum payload size for each persisted Cuckoo Filter page, in 
bytes
+  uint32_t page_size;
+
+  explicit CuckooChainMetadata(bool generate_version = true)
+      : Metadata(kRedisCuckooFilter, generate_version),
+        n_filters(0),
+        expansion(0),
+        base_capacity(0),
+        bucket_size(0),
+        max_iterations(0),
+        num_deleted_items(0),
+        page_size(kCuckooFilterDefaultPageSize) {}
+
+  void Encode(std::string *dst) const override;
+  using Metadata::Decode;
+  rocksdb::Status Decode(Slice *input) override;
+
+  uint64_t GetTotalCapacity() const;
+  bool IsScaling() const { return expansion > 0; }
+};
+
 enum class JsonStorageFormat : uint8_t {
   JSON = 0,
   CBOR = 1,
diff --git a/src/types/cuckoo_filter.h b/src/types/cuckoo_filter.h
new file mode 100644
index 000000000..a885a4c08
--- /dev/null
+++ b/src/types/cuckoo_filter.h
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#pragma once
+
+#include <rocksdb/status.h>
+
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "vendor/murmurhash2.h"
+
+namespace redis {
+
+constexpr long double kCuckooFilterLoadFactor = 0.955L;
+constexpr uint64_t kCuckooFilterMaxSupportedBuckets = 
std::numeric_limits<uint32_t>::max() / 2 + 1ULL;
+constexpr uint64_t kCuckooFilterFingerprintModulus = 255;
+constexpr uint64_t kCuckooFilterAltHashMultiplier = 0x5bd1e995ULL;
+
+// Cuckoo filter implementation from the paper:
+// "Cuckoo Filter: Practically Better Than Bloom" by Fan et al.
+// Buckets are grouped into page values in RocksDB. The Cuckoo algorithm still
+// works with logical bucket indexes, while the storage layer maps buckets to 
pages.
+//
+// Hash calculation follows RedisBloom's design:
+// - fp = hash % 255 + 1 (fingerprint, non-zero, range: 1-255)
+// - h1 = hash (primary hash)
+// - h2 = h1 ^ (fp * 0x5bd1e995) (alternate hash via XOR)
+// - bucket_index = hash % num_buckets (only apply modulo when indexing)
+class CuckooFilterHelper {
+ public:
+  static bool IsCapacitySupported(uint64_t capacity, uint8_t bucket_size) {
+    uint32_t num_buckets = 0;
+    return CalculateRequiredBuckets(capacity, bucket_size, &num_buckets).ok();
+  }
+
+  static uint16_t NormalizeExpansion(uint16_t expansion) {
+    if (expansion <= 1) return expansion;
+
+    uint32_t normalized = 1;
+    while (normalized < expansion) normalized <<= 1;
+    return static_cast<uint16_t>(normalized);
+  }
+
+  // Returns the power-of-two bucket count required for the requested capacity.
+  static rocksdb::Status CalculateRequiredBuckets(uint64_t capacity, uint8_t 
bucket_size, uint32_t *num_buckets) {
+    if (bucket_size == 0) {
+      return rocksdb::Status::InvalidArgument("bucket_size must be larger than 
0");
+    }
+
+    auto max_supported_capacity =
+        static_cast<uint64_t>(kCuckooFilterMaxSupportedBuckets * bucket_size * 
kCuckooFilterLoadFactor);
+    if (capacity > max_supported_capacity) {
+      return rocksdb::Status::InvalidArgument("capacity is too large");
+    }
+
+    auto exact_buckets = static_cast<long double>(capacity) / bucket_size / 
kCuckooFilterLoadFactor;
+    auto required_buckets = static_cast<uint64_t>(exact_buckets);
+    if (static_cast<long double>(required_buckets) < exact_buckets) 
required_buckets++;
+    if (required_buckets == 0) required_buckets = 1;
+
+    // Round up to next power of 2 for better hash distribution.
+    uint32_t power = 1;
+    while (power < required_buckets) power <<= 1;
+    *num_buckets = power;
+    return rocksdb::Status::OK();
+  }
+
+  // Following RedisBloom: fp = hash % 255 + 1.
+  static uint8_t GenerateFingerprint(uint64_t hash) {
+    return static_cast<uint8_t>(hash % kCuckooFilterFingerprintModulus + 1);
+  }
+
+  // Calculate alternate hash using XOR (following RedisBloom)
+  // h2 = h1 ^ (fp * kCuckooFilterAltHashMultiplier)
+  // This preserves symmetry: GetAltHash(fp, GetAltHash(fp, h)) == h
+  static uint64_t GetAltHash(uint8_t fingerprint, uint64_t hash) {
+    return hash ^ (static_cast<uint64_t>(fingerprint) * 
kCuckooFilterAltHashMultiplier);
+  }
+
+  // Calculate an alternate bucket from a bucket index and fingerprint.
+  static uint32_t GetAltBucketIndex(uint32_t bucket_idx, uint8_t fingerprint, 
uint32_t num_buckets) {
+    uint64_t hash = bucket_idx;
+    uint64_t alt_hash = GetAltHash(fingerprint, hash);
+    return static_cast<uint32_t>(alt_hash % num_buckets);
+  }
+
+  // Compute hash for a given item using MurmurHash2 (compatible with 
RedisBloom).
+  static uint64_t Hash(const char *data, size_t length) { return 
HllMurMurHash64A(data, static_cast<int>(length), 0); }
+
+  // Convenience overload for std::string
+  static uint64_t Hash(const std::string &item) { return Hash(item.data(), 
item.size()); }
+
+  // Calculate the capacity of a sub-filter at a given index in the chain.
+  // Returns false if overflow would occur.
+  static bool CalculateFilterCapacity(uint64_t base_capacity, uint16_t 
expansion, uint16_t filter_index,
+                                      uint64_t *filter_capacity) {
+    uint64_t capacity = base_capacity;
+    for (uint16_t i = 0; i < filter_index; ++i) {
+      if (expansion != 0 && capacity > std::numeric_limits<uint64_t>::max() / 
expansion) return false;
+      capacity *= expansion;
+    }
+    *filter_capacity = capacity;
+    return true;
+  }
+
+  // Calculate the number of buckets for a sub-filter at a given index.
+  static rocksdb::Status GetFilterNumBuckets(uint64_t base_capacity, uint16_t 
expansion, uint8_t bucket_size,
+                                             uint16_t filter_index, uint32_t 
*num_buckets) {
+    uint64_t filter_capacity = 0;
+    if (!CalculateFilterCapacity(base_capacity, expansion, filter_index, 
&filter_capacity)) {
+      return rocksdb::Status::Corruption("invalid metadata: filter capacity is 
too large");
+    }
+
+    auto s = CalculateRequiredBuckets(filter_capacity, bucket_size, 
num_buckets);
+    if (!s.ok()) return rocksdb::Status::Corruption("invalid metadata: filter 
capacity is too large");
+    return rocksdb::Status::OK();
+  }
+};
+
+}  // namespace redis
diff --git a/src/types/cuckoo_filter_page.cc b/src/types/cuckoo_filter_page.cc
new file mode 100644
index 000000000..1ef4a4c80
--- /dev/null
+++ b/src/types/cuckoo_filter_page.cc
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include "cuckoo_filter_page.h"
+
+#include <algorithm>
+
+#include "common/encoding.h"
+#include "storage/redis_db.h"
+
+namespace redis {
+
+namespace {
+
+uint32_t GetBucketsPerPage(uint32_t page_size, uint8_t bucket_size) {
+  return std::max<uint32_t>(1, page_size / bucket_size);
+}
+
+uint32_t GetPageIndex(uint32_t bucket_index, uint32_t buckets_per_page) { 
return bucket_index / buckets_per_page; }
+
+uint32_t GetBucketOffset(uint32_t bucket_index, uint32_t buckets_per_page, 
uint8_t bucket_size) {
+  return (bucket_index % buckets_per_page) * bucket_size;
+}
+
+uint32_t GetExpectedPageSize(uint32_t page_index, uint32_t num_buckets, 
uint32_t buckets_per_page,
+                             uint8_t bucket_size) {
+  uint32_t first_bucket = page_index * buckets_per_page;
+  uint32_t page_bucket_count = std::min(buckets_per_page, num_buckets - 
first_bucket);
+  return page_bucket_count * bucket_size;
+}
+
+std::string GetCuckooPageKey(const Slice &ns_key, uint64_t version, bool 
slot_id_encoded, uint16_t filter_index,
+                             uint32_t page_index) {
+  std::string sub_key;
+  PutFixed16(&sub_key, filter_index);
+  PutFixed32(&sub_key, page_index);
+  return InternalKey(ns_key, sub_key, version, slot_id_encoded).Encode();
+}
+
+}  // namespace
+
+CuckooPageCache::CuckooPageCache(engine::Storage *storage, engine::Context 
&ctx, const Slice &ns_key,
+                                 bool slot_id_encoded, uint64_t version, 
uint8_t bucket_size, uint32_t page_size)
+    : storage_(storage),
+      ctx_(ctx),
+      ns_key_(ns_key.ToString()),
+      slot_id_encoded_(slot_id_encoded),
+      version_(version),
+      bucket_size_(bucket_size),
+      page_size_(page_size) {}
+
+rocksdb::Status CuckooPageCache::PrefetchBuckets(uint16_t filter_index, 
uint32_t num_buckets, uint32_t bucket1_index,
+                                                 uint32_t bucket2_index) {
+  BucketLocation location1, location2;
+  auto s = resolveBucketLocation(filter_index, num_buckets, bucket1_index, 
&location1);
+  if (!s.ok()) return s;
+  s = resolveBucketLocation(filter_index, num_buckets, bucket2_index, 
&location2);
+  if (!s.ok()) return s;
+
+  std::vector<BucketLocation> missing_locations;
+  if (pages_.find(location1.page_key) == pages_.end()) 
missing_locations.push_back(location1);
+  if (location2.page_key != location1.page_key && 
pages_.find(location2.page_key) == pages_.end()) {
+    missing_locations.push_back(location2);
+  }
+  return loadPages(missing_locations);
+}
+
+rocksdb::Status CuckooPageCache::TryInsertInBucket(uint16_t filter_index, 
uint32_t num_buckets, uint32_t bucket_index,
+                                                   uint8_t fingerprint, bool 
*inserted) {
+  *inserted = false;
+  BucketRef bucket;
+  auto s = ensureBucketLoaded(filter_index, num_buckets, bucket_index, 
&bucket);
+  if (!s.ok()) return s;
+
+  size_t slot_idx = 0;
+  *inserted = tryInsertInBucketRef(bucket, fingerprint, &slot_idx);
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::GetBucketSlot(uint16_t filter_index, uint32_t 
num_buckets, uint32_t bucket_index,
+                                               uint32_t slot_idx, uint8_t 
*fingerprint) {
+  if (slot_idx >= bucket_size_) return 
rocksdb::Status::InvalidArgument("invalid cuckoo filter bucket slot");
+
+  BucketRef bucket;
+  auto s = ensureBucketLoaded(filter_index, num_buckets, bucket_index, 
&bucket);
+  if (!s.ok()) return s;
+  *fingerprint = getBucketRefSlot(bucket, slot_idx);
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::SetBucketSlot(uint16_t filter_index, uint32_t 
num_buckets, uint32_t bucket_index,
+                                               uint32_t slot_idx, uint8_t 
fingerprint) {
+  if (slot_idx >= bucket_size_) return 
rocksdb::Status::InvalidArgument("invalid cuckoo filter bucket slot");
+
+  BucketRef bucket;
+  auto s = ensureBucketLoaded(filter_index, num_buckets, bucket_index, 
&bucket);
+  if (!s.ok()) return s;
+  setBucketRefSlot(bucket, slot_idx, fingerprint);
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::WriteBackDirtyPages(rocksdb::WriteBatchBase 
*batch) {
+  for (const auto &entry : pages_) {
+    if (!entry.second.is_dirty) continue;
+    auto s = batch->Put(entry.first, entry.second.data);
+    if (!s.ok()) return s;
+  }
+  return rocksdb::Status::OK();
+}
+
+void CuckooPageCache::DiscardCachedPages() { pages_.clear(); }
+
+rocksdb::Status CuckooPageCache::resolveBucketLocation(uint16_t filter_index, 
uint32_t num_buckets,
+                                                       uint32_t bucket_index, 
BucketLocation *location) const {
+  if (bucket_size_ == 0 || num_buckets == 0 || bucket_index >= num_buckets) {
+    return rocksdb::Status::Corruption("invalid cuckoo filter bucket 
location");
+  }
+
+  uint32_t buckets_per_page = GetBucketsPerPage(page_size_, bucket_size_);
+  uint32_t page_index = GetPageIndex(bucket_index, buckets_per_page);
+  location->page_key = GetCuckooPageKey(ns_key_, version_, slot_id_encoded_, 
filter_index, page_index);
+  location->offset = GetBucketOffset(bucket_index, buckets_per_page, 
bucket_size_);
+  location->expected_page_size = GetExpectedPageSize(page_index, num_buckets, 
buckets_per_page, bucket_size_);
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::ensureBucketLoaded(uint16_t filter_index, 
uint32_t num_buckets, uint32_t bucket_index,
+                                                    BucketRef *bucket) {
+  BucketLocation location;
+  auto s = resolveBucketLocation(filter_index, num_buckets, bucket_index, 
&location);
+  if (!s.ok()) return s;
+
+  PageEntry *page = nullptr;
+  s = loadPage(location, &page);
+  if (!s.ok()) return s;
+
+  bucket->page = page;
+  bucket->offset = location.offset;
+  bucket->size = bucket_size_;
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::loadPage(const BucketLocation &location, 
PageEntry **page) {
+  auto iter = pages_.find(location.page_key);
+  if (iter != pages_.end()) {
+    *page = &iter->second;
+    return rocksdb::Status::OK();
+  }
+
+  PageEntry page_entry;
+  auto s = storage_->Get(ctx_, ctx_.GetReadOptions(), location.page_key, 
&page_entry.data);
+  if (!s.ok() && !s.IsNotFound()) return s;
+  s = normalizePage(s, location.expected_page_size, &page_entry);
+  if (!s.ok()) return s;
+
+  auto result = pages_.emplace(location.page_key, std::move(page_entry));
+  *page = &result.first->second;
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::loadPages(const std::vector<BucketLocation> 
&locations) {
+  if (locations.empty()) return rocksdb::Status::OK();
+  if (locations.size() == 1) {
+    PageEntry *page = nullptr;
+    return loadPage(locations[0], &page);
+  }
+
+  std::vector<rocksdb::Slice> keys;
+  keys.reserve(locations.size());
+  for (const auto &location : locations) keys.emplace_back(location.page_key);
+
+  std::vector<rocksdb::PinnableSlice> values(locations.size());
+  std::vector<rocksdb::Status> statuses(locations.size());
+  storage_->MultiGet(ctx_, ctx_.DefaultMultiGetOptions(), 
storage_->GetDB()->DefaultColumnFamily(), keys.size(),
+                     keys.data(), values.data(), statuses.data());
+
+  for (size_t i = 0; i < locations.size(); ++i) {
+    PageEntry page_entry;
+    if (statuses[i].ok()) page_entry.data.assign(values[i].data(), 
values[i].size());
+    auto s = normalizePage(statuses[i], locations[i].expected_page_size, 
&page_entry);
+    if (!s.ok()) return s;
+    pages_.emplace(locations[i].page_key, std::move(page_entry));
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooPageCache::normalizePage(const rocksdb::Status &status, 
uint32_t expected_size, PageEntry *page) {
+  if (!status.ok() && !status.IsNotFound()) return status;
+  if (status.IsNotFound()) {
+    page->data.assign(expected_size, 0);
+    return rocksdb::Status::OK();
+  }
+  if (page->data.size() > expected_size) return 
rocksdb::Status::Corruption("invalid cuckoo filter page size");
+  if (page->data.size() < expected_size) return 
rocksdb::Status::Corruption("invalid cuckoo filter page size");
+  return rocksdb::Status::OK();
+}
+
+bool CuckooPageCache::tryInsertInBucketRef(const BucketRef &bucket, uint8_t 
fingerprint, size_t *slot_idx) {
+  for (size_t i = 0; i < bucket.size; ++i) {
+    size_t offset = bucket.offset + i;
+    if (static_cast<uint8_t>(bucket.page->data[offset]) == 0) {
+      bucket.page->data[offset] = static_cast<char>(fingerprint);
+      bucket.page->is_dirty = true;
+      *slot_idx = i;
+      return true;
+    }
+  }
+  return false;
+}
+
+uint8_t CuckooPageCache::getBucketRefSlot(const BucketRef &bucket, uint32_t 
slot_idx) {
+  return static_cast<uint8_t>(bucket.page->data[bucket.offset + slot_idx]);
+}
+
+void CuckooPageCache::setBucketRefSlot(const BucketRef &bucket, uint32_t 
slot_idx, uint8_t fingerprint) {
+  bucket.page->data[bucket.offset + slot_idx] = static_cast<char>(fingerprint);
+  bucket.page->is_dirty = true;
+}
+
+}  // namespace redis
diff --git a/src/types/cuckoo_filter_page.h b/src/types/cuckoo_filter_page.h
new file mode 100644
index 000000000..ff2efeb50
--- /dev/null
+++ b/src/types/cuckoo_filter_page.h
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#pragma once
+
+#include <rocksdb/status.h>
+#include <rocksdb/write_batch.h>
+
+#include <cstdint>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "storage/storage.h"
+
+namespace redis {
+
+class CuckooPageCache {
+ public:
+  CuckooPageCache(engine::Storage *storage, engine::Context &ctx, const Slice 
&ns_key, bool slot_id_encoded,
+                  uint64_t version, uint8_t bucket_size, uint32_t page_size);
+
+  rocksdb::Status PrefetchBuckets(uint16_t filter_index, uint32_t num_buckets, 
uint32_t bucket1_index,
+                                  uint32_t bucket2_index);
+  rocksdb::Status TryInsertInBucket(uint16_t filter_index, uint32_t 
num_buckets, uint32_t bucket_index,
+                                    uint8_t fingerprint, bool *inserted);
+  rocksdb::Status GetBucketSlot(uint16_t filter_index, uint32_t num_buckets, 
uint32_t bucket_index, uint32_t slot_idx,
+                                uint8_t *fingerprint);
+  rocksdb::Status SetBucketSlot(uint16_t filter_index, uint32_t num_buckets, 
uint32_t bucket_index, uint32_t slot_idx,
+                                uint8_t fingerprint);
+  rocksdb::Status WriteBackDirtyPages(rocksdb::WriteBatchBase *batch);
+
+  void DiscardCachedPages();
+
+ private:
+  struct PageEntry {
+    std::string data;
+    bool is_dirty = false;
+  };
+
+  struct BucketRef {
+    PageEntry *page = nullptr;
+    uint32_t offset = 0;
+    uint8_t size = 0;
+  };
+
+  struct BucketLocation {
+    std::string page_key;
+    uint32_t offset = 0;
+    uint32_t expected_page_size = 0;
+  };
+
+  rocksdb::Status resolveBucketLocation(uint16_t filter_index, uint32_t 
num_buckets, uint32_t bucket_index,
+                                        BucketLocation *location) const;
+  rocksdb::Status ensureBucketLoaded(uint16_t filter_index, uint32_t 
num_buckets, uint32_t bucket_index,
+                                     BucketRef *bucket);
+  rocksdb::Status loadPage(const BucketLocation &location, PageEntry **page);
+  rocksdb::Status loadPages(const std::vector<BucketLocation> &locations);
+  static rocksdb::Status normalizePage(const rocksdb::Status &status, uint32_t 
expected_size, PageEntry *page);
+
+  static bool tryInsertInBucketRef(const BucketRef &bucket, uint8_t 
fingerprint, size_t *slot_idx);
+  static uint8_t getBucketRefSlot(const BucketRef &bucket, uint32_t slot_idx);
+  static void setBucketRefSlot(const BucketRef &bucket, uint32_t slot_idx, 
uint8_t fingerprint);
+
+  engine::Storage *storage_ = nullptr;
+  engine::Context &ctx_;
+  std::string ns_key_;
+  bool slot_id_encoded_ = false;
+  uint64_t version_ = 0;
+  uint8_t bucket_size_ = 0;
+  uint32_t page_size_ = 0;
+  // Maps encoded cuckoo page keys to cached page entries.
+  std::unordered_map<std::string, PageEntry> pages_;
+};
+
+}  // namespace redis
diff --git a/src/types/cuckoo_filter_sub_filter.cc 
b/src/types/cuckoo_filter_sub_filter.cc
new file mode 100644
index 000000000..0a0748cc3
--- /dev/null
+++ b/src/types/cuckoo_filter_sub_filter.cc
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include "cuckoo_filter_sub_filter.h"
+
+#include "cuckoo_filter.h"
+
+namespace redis {
+
+CuckooSubFilter::CuckooSubFilter(engine::Storage *storage, engine::Context 
&ctx, const Slice &ns_key,
+                                 bool slot_id_encoded, uint64_t version, 
uint8_t bucket_size, uint32_t page_size,
+                                 uint16_t filter_index, uint32_t num_buckets)
+    : bucket_size_(bucket_size),
+      filter_index_(filter_index),
+      num_buckets_(num_buckets),
+      pages_(storage, ctx, ns_key, slot_id_encoded, version, bucket_size, 
page_size) {}
+
+rocksdb::Status CuckooSubFilter::TryInsert(uint64_t hash, uint8_t fingerprint, 
bool *inserted) {
+  *inserted = false;
+  uint32_t bucket1_idx = getPrimaryBucketIndex(hash);
+  uint32_t bucket2_idx = getSecondaryBucketIndex(hash, fingerprint);
+  auto s = pages_.PrefetchBuckets(filter_index_, num_buckets_, bucket1_idx, 
bucket2_idx);
+  if (!s.ok()) return s;
+
+  s = pages_.TryInsertInBucket(filter_index_, num_buckets_, bucket1_idx, 
fingerprint, inserted);
+  if (!s.ok() || *inserted || bucket1_idx == bucket2_idx) return s;
+
+  return pages_.TryInsertInBucket(filter_index_, num_buckets_, bucket2_idx, 
fingerprint, inserted);
+}
+
+rocksdb::Status CuckooSubFilter::TryKickOutInsert(uint64_t hash, uint8_t 
fingerprint, uint16_t max_iterations,
+                                                  bool *inserted) {
+  *inserted = false;
+
+  uint32_t current_bucket_idx = getPrimaryBucketIndex(hash);
+  uint8_t current_fp = fingerprint;
+  uint32_t victim_slot = 0;
+
+  for (uint16_t iteration = 0; iteration < max_iterations; ++iteration) {
+    uint8_t old_fp = 0;
+    auto s = pages_.GetBucketSlot(filter_index_, num_buckets_, 
current_bucket_idx, victim_slot, &old_fp);
+    if (!s.ok()) {
+      pages_.DiscardCachedPages();
+      return s;
+    }
+    s = pages_.SetBucketSlot(filter_index_, num_buckets_, current_bucket_idx, 
victim_slot, current_fp);
+    if (!s.ok()) {
+      pages_.DiscardCachedPages();
+      return s;
+    }
+    current_fp = old_fp;
+
+    if (current_fp == 0) {
+      *inserted = true;
+      return rocksdb::Status::OK();
+    }
+
+    uint32_t alt_bucket_idx = 
CuckooFilterHelper::GetAltBucketIndex(current_bucket_idx, current_fp, 
num_buckets_);
+
+    bool inserted_in_alt_bucket = false;
+    s = pages_.TryInsertInBucket(filter_index_, num_buckets_, alt_bucket_idx, 
current_fp, &inserted_in_alt_bucket);
+    if (!s.ok()) {
+      pages_.DiscardCachedPages();
+      return s;
+    }
+    if (inserted_in_alt_bucket) {
+      *inserted = true;
+      return rocksdb::Status::OK();
+    }
+
+    current_bucket_idx = alt_bucket_idx;
+    victim_slot = (victim_slot + 1) % bucket_size_;
+  }
+
+  pages_.DiscardCachedPages();
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooSubFilter::WriteToBatch(rocksdb::WriteBatchBase *batch) {
+  return pages_.WriteBackDirtyPages(batch);
+}
+
+uint32_t CuckooSubFilter::getPrimaryBucketIndex(uint64_t hash) const { return 
hash % num_buckets_; }
+
+uint32_t CuckooSubFilter::getSecondaryBucketIndex(uint64_t hash, uint8_t 
fingerprint) const {
+  return CuckooFilterHelper::GetAltHash(fingerprint, hash) % num_buckets_;
+}
+
+}  // namespace redis
diff --git a/src/types/cuckoo_filter_sub_filter.h 
b/src/types/cuckoo_filter_sub_filter.h
new file mode 100644
index 000000000..2bd26df85
--- /dev/null
+++ b/src/types/cuckoo_filter_sub_filter.h
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#pragma once
+
+#include <rocksdb/status.h>
+#include <rocksdb/write_batch.h>
+
+#include <cstdint>
+
+#include "cuckoo_filter_page.h"
+
+namespace redis {
+
+class CuckooSubFilter {
+ public:
+  CuckooSubFilter(engine::Storage *storage, engine::Context &ctx, const Slice 
&ns_key, bool slot_id_encoded,
+                  uint64_t version, uint8_t bucket_size, uint32_t page_size, 
uint16_t filter_index,
+                  uint32_t num_buckets);
+
+  uint16_t Index() const { return filter_index_; }
+  uint32_t NumBuckets() const { return num_buckets_; }
+
+  rocksdb::Status TryInsert(uint64_t hash, uint8_t fingerprint, bool 
*inserted);
+  // Performs speculative kick-out mutations in the page cache. On success, 
dirty pages remain staged for
+  // WriteToBatch(); on inserted=false or non-OK status, cached pages are 
discarded before returning.
+  rocksdb::Status TryKickOutInsert(uint64_t hash, uint8_t fingerprint, 
uint16_t max_iterations, bool *inserted);
+  rocksdb::Status WriteToBatch(rocksdb::WriteBatchBase *batch);
+
+ private:
+  uint32_t getPrimaryBucketIndex(uint64_t hash) const;
+  uint32_t getSecondaryBucketIndex(uint64_t hash, uint8_t fingerprint) const;
+
+  uint8_t bucket_size_ = 0;
+  uint16_t filter_index_ = 0;
+  uint32_t num_buckets_ = 0;
+  CuckooPageCache pages_;
+};
+
+}  // namespace redis
diff --git a/src/types/redis_cuckoo_chain.cc b/src/types/redis_cuckoo_chain.cc
new file mode 100644
index 000000000..f7074ce94
--- /dev/null
+++ b/src/types/redis_cuckoo_chain.cc
@@ -0,0 +1,294 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include "redis_cuckoo_chain.h"
+
+#include "cuckoo_filter.h"
+#include "cuckoo_filter_sub_filter.h"
+#include "logging.h"
+
+namespace redis {
+
+rocksdb::Status CuckooChain::getCuckooChainMetadata(engine::Context &ctx, 
const Slice &ns_key,
+                                                    CuckooChainMetadata 
*metadata) {
+  return Database::GetMetadata(ctx, {kRedisCuckooFilter}, ns_key, metadata);
+}
+
+rocksdb::Status CuckooChain::validateMetadata(const CuckooChainMetadata 
&metadata) {
+  if (metadata.n_filters == 0) {
+    return rocksdb::Status::Corruption("invalid metadata: n_filters is 0");
+  }
+  if (metadata.base_capacity == 0) {
+    return rocksdb::Status::Corruption("invalid metadata: base_capacity is 0");
+  }
+  if (metadata.bucket_size == 0) {
+    return rocksdb::Status::Corruption("invalid metadata: bucket_size is 0");
+  }
+  if (metadata.max_iterations == 0) {
+    return rocksdb::Status::Corruption("invalid metadata: max_iterations is 
0");
+  }
+  if (metadata.page_size < metadata.bucket_size) {
+    return rocksdb::Status::Corruption("invalid metadata: page_size is smaller 
than bucket_size");
+  }
+  if (!CuckooFilterHelper::IsCapacitySupported(metadata.base_capacity, 
metadata.bucket_size)) {
+    return rocksdb::Status::Corruption("invalid metadata: base_capacity is too 
large");
+  }
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooChain::Reserve(engine::Context &ctx, const Slice 
&user_key, uint64_t capacity,
+                                     uint8_t bucket_size, uint16_t 
max_iterations, uint16_t expansion,
+                                     uint32_t page_size) {
+  if (capacity == 0) {
+    return rocksdb::Status::InvalidArgument("capacity must be larger than 0");
+  }
+
+  // RedisBloom requires minimum capacity to ensure at least one bucket can be 
created
+  // With load factor 0.955, capacity=1 and bucket_size=4 results in 0 buckets
+  if (capacity < 2) {
+    return rocksdb::Status::InvalidArgument("capacity must be at least 2");
+  }
+
+  if (bucket_size == 0 || bucket_size > 255) {
+    return rocksdb::Status::InvalidArgument("bucket_size must be between 1 and 
255");
+  }
+
+  if (max_iterations == 0) {
+    return rocksdb::Status::InvalidArgument("max_iterations must be larger 
than 0");
+  }
+  if (page_size == 0) {
+    return rocksdb::Status::InvalidArgument("page_size must be larger than 0");
+  }
+  if (page_size < bucket_size) {
+    return rocksdb::Status::InvalidArgument("page_size must be at least 
bucket_size");
+  }
+  if (expansion > kCFMaxExpansion) {
+    return rocksdb::Status::InvalidArgument("expansion must be between 0 and 
32768");
+  }
+  if (!CuckooFilterHelper::IsCapacitySupported(capacity, bucket_size)) {
+    return rocksdb::Status::InvalidArgument("capacity is too large");
+  }
+  uint16_t normalized_expansion = 
CuckooFilterHelper::NormalizeExpansion(expansion);
+
+  std::string ns_key = AppendNamespacePrefix(user_key);
+
+  CuckooChainMetadata existing_metadata;
+  auto s = getCuckooChainMetadata(ctx, ns_key, &existing_metadata);
+  if (!s.ok() && !s.IsNotFound()) return s;
+  if (!s.IsNotFound()) {
+    return rocksdb::Status::InvalidArgument("the key already exists");
+  }
+
+  CuckooChainMetadata metadata;
+
+  metadata.size = 0;
+  metadata.base_capacity = capacity;
+  metadata.bucket_size = bucket_size;
+  metadata.max_iterations = max_iterations;
+  metadata.expansion = normalized_expansion;
+  metadata.n_filters = 1;
+  metadata.num_deleted_items = 0;
+  metadata.page_size = page_size;
+
+  // Create a write batch for atomic operation
+  auto batch = storage_->GetWriteBatchBase();
+  WriteBatchLogData log_data(kRedisCuckooFilter, 
std::vector<std::string>{"reserve", user_key.ToString()});
+  s = batch->PutLogData(log_data.Encode());
+  if (!s.ok()) return s;
+
+  std::string metadata_bytes;
+  metadata.Encode(&metadata_bytes);
+  s = batch->Put(metadata_cf_handle_, ns_key, metadata_bytes);
+  if (!s.ok()) return s;
+
+  // Pages are created lazily on first write. Reserve only persists metadata 
so sparse filters don't preallocate page
+  // values that may never be used.
+
+  return storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+}
+
+rocksdb::Status CuckooChain::Add(engine::Context &ctx, const Slice &user_key, 
const Slice &item, bool *added) {
+  std::string ns_key = AppendNamespacePrefix(user_key);
+
+  CuckooChainMetadata metadata(false);
+  auto s = getCuckooChainMetadata(ctx, ns_key, &metadata);
+  if (s.IsNotFound()) {
+    // RedisBloom CF.ADD auto-creates the filter when the key does not exist:
+    // https://redis.io/docs/latest/commands/cf.add/
+    metadata = CuckooChainMetadata();
+    metadata.size = 0;
+    metadata.base_capacity = kCFDefaultCapacity;
+    metadata.bucket_size = kCFDefaultBucketSize;
+    metadata.max_iterations = kCFDefaultMaxIterations;
+    metadata.expansion = kCFDefaultExpansion;
+    metadata.n_filters = 1;
+    metadata.num_deleted_items = 0;
+    metadata.page_size = kCuckooFilterDefaultPageSize;
+  } else if (!s.ok()) {
+    return s;
+  }
+
+  s = validateMetadata(metadata);
+  if (!s.ok()) return s;
+
+  // Calculate hash and fingerprint for the item
+  uint64_t hash = CuckooFilterHelper::Hash(item.data(), item.size());
+  uint8_t fingerprint = CuckooFilterHelper::GenerateFingerprint(hash);
+
+  bool inserted = false;
+  s = tryCuckooInsert(ctx, user_key, ns_key, &metadata, hash, fingerprint, 
&inserted);
+  if (!s.ok()) return s;
+  if (inserted) {
+    *added = true;
+    return rocksdb::Status::OK();
+  }
+
+  s = tryCuckooKickOut(ctx, user_key, ns_key, &metadata, hash, fingerprint, 
&inserted);
+  if (!s.ok()) return s;
+  if (inserted) {
+    *added = true;
+    return rocksdb::Status::OK();
+  }
+
+  s = expandAndInsertCuckooChain(ctx, user_key, ns_key, &metadata, hash, 
fingerprint, &inserted);
+  if (!s.ok()) return s;
+  if (inserted) {
+    *added = true;
+    return rocksdb::Status::OK();
+  }
+
+  // No expansion allowed and filter is full
+  *added = false;
+  return rocksdb::Status::Aborted("filter is full");
+}
+
+rocksdb::Status CuckooChain::tryCuckooInsert(engine::Context &ctx, const Slice 
&user_key, const std::string &ns_key,
+                                             CuckooChainMetadata *metadata, 
uint64_t hash, uint8_t fingerprint,
+                                             bool *inserted) {
+  *inserted = false;
+
+  // RedisBloom prioritizes the newest sub-filter to avoid repeatedly probing 
older, fuller filters.
+  for (int filter_idx = static_cast<int>(metadata->n_filters) - 1; filter_idx 
>= 0; --filter_idx) {
+    auto current_filter_idx = static_cast<uint16_t>(filter_idx);
+    uint32_t num_buckets = 0;
+    auto s = CuckooFilterHelper::GetFilterNumBuckets(metadata->base_capacity, 
metadata->expansion,
+                                                     metadata->bucket_size, 
current_filter_idx, &num_buckets);
+    if (!s.ok()) return s;
+
+    CuckooSubFilter sub_filter(storage_, ctx, ns_key, 
storage_->IsSlotIdEncoded(), metadata->version,
+                               metadata->bucket_size, metadata->page_size, 
current_filter_idx, num_buckets);
+    bool current_inserted = false;
+    s = sub_filter.TryInsert(hash, fingerprint, &current_inserted);
+    if (!s.ok()) return s;
+
+    if (current_inserted) {
+      s = commitSubFilterAndMetadata(ctx, user_key, ns_key, metadata, 
&sub_filter);
+      if (!s.ok()) return s;
+      *inserted = true;
+      return rocksdb::Status::OK();
+    }
+  }
+
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooChain::tryCuckooKickOut(engine::Context &ctx, const 
Slice &user_key, const std::string &ns_key,
+                                              CuckooChainMetadata *metadata, 
uint64_t hash, uint8_t fingerprint,
+                                              bool *inserted) {
+  *inserted = false;
+
+  // No space found in any filter, try kick-out on the last filter
+  uint16_t last_filter_idx = metadata->n_filters - 1;
+  uint32_t num_buckets = 0;
+  auto s = CuckooFilterHelper::GetFilterNumBuckets(metadata->base_capacity, 
metadata->expansion, metadata->bucket_size,
+                                                   last_filter_idx, 
&num_buckets);
+  if (!s.ok()) return s;
+
+  CuckooSubFilter last_filter(storage_, ctx, ns_key, 
storage_->IsSlotIdEncoded(), metadata->version,
+                              metadata->bucket_size, metadata->page_size, 
last_filter_idx, num_buckets);
+  bool kickout_inserted = false;
+  s = last_filter.TryKickOutInsert(hash, fingerprint, 
metadata->max_iterations, &kickout_inserted);
+  if (!s.ok()) return s;
+  if (kickout_inserted) {
+    s = commitSubFilterAndMetadata(ctx, user_key, ns_key, metadata, 
&last_filter);
+    if (!s.ok()) return s;
+    *inserted = true;
+    return rocksdb::Status::OK();
+  }
+
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooChain::expandAndInsertCuckooChain(engine::Context &ctx, 
const Slice &user_key,
+                                                        const std::string 
&ns_key, CuckooChainMetadata *metadata,
+                                                        uint64_t hash, uint8_t 
fingerprint, bool *inserted) {
+  *inserted = false;
+
+  // Kick-out failed, try to expand if allowed
+  if (metadata->expansion == 0) return rocksdb::Status::OK();
+
+  if (metadata->n_filters >= UINT16_MAX) return 
rocksdb::Status::Aborted("maximum number of filters reached");
+
+  // Retry insertion in the new expanded filter
+  uint16_t new_filter_idx = metadata->n_filters;
+  uint32_t new_num_buckets = 0;
+  auto s = CuckooFilterHelper::GetFilterNumBuckets(metadata->base_capacity, 
metadata->expansion, metadata->bucket_size,
+                                                   new_filter_idx, 
&new_num_buckets);
+  if (s.IsCorruption()) {
+    return rocksdb::Status::Aborted("maximum filter capacity reached");
+  }
+  if (!s.ok()) return s;
+
+  CuckooSubFilter new_filter(storage_, ctx, ns_key, 
storage_->IsSlotIdEncoded(), metadata->version,
+                             metadata->bucket_size, metadata->page_size, 
new_filter_idx, new_num_buckets);
+  bool new_filter_inserted = false;
+  s = new_filter.TryInsert(hash, fingerprint, &new_filter_inserted);
+  if (!s.ok()) return s;
+  if (!new_filter_inserted) return rocksdb::Status::Corruption("failed to 
insert into new cuckoo filter");
+
+  metadata->n_filters++;
+  s = commitSubFilterAndMetadata(ctx, user_key, ns_key, metadata, &new_filter);
+  if (!s.ok()) return s;
+
+  *inserted = true;
+  return rocksdb::Status::OK();
+}
+
+rocksdb::Status CuckooChain::commitSubFilterAndMetadata(engine::Context &ctx, 
const Slice &user_key,
+                                                        const std::string 
&ns_key, CuckooChainMetadata *metadata,
+                                                        CuckooSubFilter 
*sub_filter) {
+  auto batch = storage_->GetWriteBatchBase();
+  WriteBatchLogData log_data(kRedisCuckooFilter, 
std::vector<std::string>{"add", user_key.ToString()});
+  auto s = batch->PutLogData(log_data.Encode());
+  if (!s.ok()) return s;
+
+  s = sub_filter->WriteToBatch(batch.Get());
+  if (!s.ok()) return s;
+
+  metadata->size++;
+  std::string metadata_bytes;
+  metadata->Encode(&metadata_bytes);
+  s = batch->Put(metadata_cf_handle_, ns_key, metadata_bytes);
+  if (!s.ok()) return s;
+
+  return storage_->Write(ctx, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+}
+
+}  // namespace redis
diff --git a/src/types/redis_cuckoo_chain.h b/src/types/redis_cuckoo_chain.h
new file mode 100644
index 000000000..ae20b056c
--- /dev/null
+++ b/src/types/redis_cuckoo_chain.h
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#pragma once
+
+#include "cuckoo_filter.h"
+#include "storage/redis_db.h"
+#include "storage/redis_metadata.h"
+
+namespace redis {
+
+class CuckooSubFilter;
+
+// Default values for a newly created cuckoo filter.
+const uint32_t kCFDefaultCapacity = 1024;
+const uint8_t kCFDefaultBucketSize = 2;
+const uint16_t kCFDefaultMaxIterations = 20;
+const uint16_t kCFDefaultExpansion = 1;
+const uint16_t kCFMaxExpansion = 32768;
+
+class CuckooChain : public Database {
+ public:
+  CuckooChain(engine::Storage *storage, const std::string &ns) : 
Database(storage, ns) {}
+
+  // Creates a new cuckoo filter with the specified parameters.
+  rocksdb::Status Reserve(engine::Context &ctx, const Slice &user_key, 
uint64_t capacity, uint8_t bucket_size,
+                          uint16_t max_iterations, uint16_t expansion, 
uint32_t page_size);
+
+  // Adds one item to the cuckoo filter.
+  // Duplicate items are allowed, so added is true whenever insertion succeeds.
+  rocksdb::Status Add(engine::Context &ctx, const Slice &user_key, const Slice 
&item, bool *added);
+
+ private:
+  // Loads metadata for a cuckoo filter key.
+  rocksdb::Status getCuckooChainMetadata(engine::Context &ctx, const Slice 
&ns_key, CuckooChainMetadata *metadata);
+
+  static rocksdb::Status validateMetadata(const CuckooChainMetadata &metadata);
+
+  rocksdb::Status tryCuckooInsert(engine::Context &ctx, const Slice &user_key, 
const std::string &ns_key,
+                                  CuckooChainMetadata *metadata, uint64_t 
hash, uint8_t fingerprint, bool *inserted);
+  rocksdb::Status tryCuckooKickOut(engine::Context &ctx, const Slice 
&user_key, const std::string &ns_key,
+                                   CuckooChainMetadata *metadata, uint64_t 
hash, uint8_t fingerprint, bool *inserted);
+  rocksdb::Status expandAndInsertCuckooChain(engine::Context &ctx, const Slice 
&user_key, const std::string &ns_key,
+                                             CuckooChainMetadata *metadata, 
uint64_t hash, uint8_t fingerprint,
+                                             bool *inserted);
+  rocksdb::Status commitSubFilterAndMetadata(engine::Context &ctx, const Slice 
&user_key, const std::string &ns_key,
+                                             CuckooChainMetadata *metadata, 
CuckooSubFilter *sub_filter);
+};
+
+}  // namespace redis
diff --git a/tests/cppunit/disk_test.cc b/tests/cppunit/disk_test.cc
index 60085d593..58c605ceb 100644
--- a/tests/cppunit/disk_test.cc
+++ b/tests/cppunit/disk_test.cc
@@ -29,6 +29,7 @@
 #include "storage/redis_metadata.h"
 #include "test_base.h"
 #include "types/redis_bitmap.h"
+#include "types/redis_cuckoo_chain.h"
 #include "types/redis_list.h"
 #include "types/redis_set.h"
 #include "types/redis_sortedint.h"
@@ -252,3 +253,19 @@ TEST_F(RedisDiskTest, StreamDisk) {
   EXPECT_LE(key_size, approximate_size / estimation_factor_);
   auto s = stream->Del(*ctx_, key_);
 }
+
+TEST_F(RedisDiskTest, CuckooFilterDisk) {
+  std::unique_ptr<redis::CuckooChain> cuckoo =
+      std::make_unique<redis::CuckooChain>(storage_.get(), 
"disk_ns_cuckoo_filter");
+  std::unique_ptr<redis::Disk> disk = 
std::make_unique<redis::Disk>(storage_.get(), "disk_ns_cuckoo_filter");
+  key_ = "cuckoo_filter_disk_key";
+
+  EXPECT_TRUE(cuckoo->Reserve(*ctx_, key_, 1000, 4, 500, 2, 
kCuckooFilterDefaultPageSize).ok());
+  uint64_t key_size = 0;
+  EXPECT_TRUE(disk->GetKeySize(*ctx_, key_, kRedisCuckooFilter, 
&key_size).ok());
+
+  bool added = false;
+  EXPECT_TRUE(cuckoo->Add(*ctx_, key_, "item", &added).ok());
+  EXPECT_TRUE(added);
+  EXPECT_TRUE(disk->GetKeySize(*ctx_, key_, kRedisCuckooFilter, 
&key_size).ok());
+}
diff --git a/tests/cppunit/types/cuckoo_filter_page_test.cc 
b/tests/cppunit/types/cuckoo_filter_page_test.cc
new file mode 100644
index 000000000..8be87aefa
--- /dev/null
+++ b/tests/cppunit/types/cuckoo_filter_page_test.cc
@@ -0,0 +1,387 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include "types/cuckoo_filter_page.h"
+
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <string>
+
+#include "common/encoding.h"
+#include "storage/redis_metadata.h"
+#include "test_base.h"
+#include "types/redis_cuckoo_chain.h"
+
+class RedisCuckooPageCacheTest : public TestBase {
+ public:
+  RedisCuckooPageCacheTest(const RedisCuckooPageCacheTest &) = delete;
+  RedisCuckooPageCacheTest &operator=(const RedisCuckooPageCacheTest &) = 
delete;
+  RedisCuckooPageCacheTest(RedisCuckooPageCacheTest &&) = delete;
+  RedisCuckooPageCacheTest &operator=(RedisCuckooPageCacheTest &&) = delete;
+
+ protected:
+  explicit RedisCuckooPageCacheTest()
+      : TestBase(), db_(std::make_unique<redis::Database>(storage_.get(), 
"cuckoo_ns")) {}
+
+  ~RedisCuckooPageCacheTest() override { db_.reset(); }
+
+  void SetUp() override {
+    const ::testing::TestInfo *const test_info = 
::testing::UnitTest::GetInstance()->current_test_info();
+    ns_key_ = db_->AppendNamespacePrefix(std::string("cf_page_test_") + 
test_info->name());
+  }
+
+  static CuckooChainMetadata makeMetadata(uint8_t bucket_size, uint64_t 
version = 1,
+                                          uint32_t page_size = 
kCuckooFilterDefaultPageSize) {
+    CuckooChainMetadata metadata(false);
+    metadata.version = version;
+    metadata.size = 0;
+    metadata.base_capacity = 2;
+    metadata.bucket_size = bucket_size;
+    metadata.max_iterations = 500;
+    metadata.expansion = 0;
+    metadata.n_filters = 1;
+    metadata.num_deleted_items = 0;
+    metadata.page_size = page_size;
+    return metadata;
+  }
+
+  std::string makePageKey(const CuckooChainMetadata &metadata, uint16_t 
filter_index, uint32_t page_index) const {
+    std::string sub_key;
+    PutFixed16(&sub_key, filter_index);
+    PutFixed32(&sub_key, page_index);
+    return InternalKey(ns_key_, sub_key, metadata.version, 
storage_->IsSlotIdEncoded()).Encode();
+  }
+
+  rocksdb::Status readPage(const std::string &page_key, std::string *value) {
+    return storage_->Get(*ctx_, ctx_->GetReadOptions(), 
storage_->GetCFHandle(ColumnFamilyID::PrimarySubkey), page_key,
+                         value);
+  }
+
+  void writePage(const std::string &page_key, const std::string &value) {
+    auto batch = storage_->GetWriteBatchBase();
+    auto s = batch->Put(page_key, value);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    ASSERT_TRUE(s.ok()) << s.ToString();
+  }
+
+  void commitBatch(rocksdb::WriteBatchBase *batch) {
+    auto s = storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    ASSERT_TRUE(s.ok()) << s.ToString();
+  }
+
+  std::unique_ptr<redis::Database> db_;
+  std::string ns_key_;
+};
+
+TEST_F(RedisCuckooPageCacheTest, TryInsertWritesSmallPage) {
+  auto metadata = makeMetadata(4);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  bool inserted = false;
+  auto s = pages.TryInsertInBucket(0, 2, 1, 11, &inserted);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_TRUE(inserted);
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), 8);
+  EXPECT_EQ(page.substr(0, 4), std::string(4, 0));
+  EXPECT_EQ(static_cast<uint8_t>(page[4]), 11);
+  EXPECT_EQ(page.substr(5, 3), std::string(3, 0));
+}
+
+TEST_F(RedisCuckooPageCacheTest, TryInsertWritesLastPartialPage) {
+  auto metadata = makeMetadata(4);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  bool inserted = false;
+  auto s = pages.TryInsertInBucket(0, 513, 512, 33, &inserted);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_TRUE(inserted);
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 1), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), 4);
+  EXPECT_EQ(static_cast<uint8_t>(page[0]), 33);
+  EXPECT_EQ(page.substr(1, 3), std::string(3, 0));
+
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+}
+
+TEST_F(RedisCuckooPageCacheTest, PrefetchBucketsOnSamePageLoadsCachedPage) {
+  auto metadata = makeMetadata(2);
+  auto page_key = makePageKey(metadata, 0, 0);
+  writePage(page_key, std::string{static_cast<char>(11), 
static_cast<char>(12), static_cast<char>(21),
+                                  static_cast<char>(22), 0, 0, 0, 0});
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  auto s = pages.PrefetchBuckets(0, 4, 0, 1);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  writePage(page_key, std::string(8, static_cast<char>(99)));
+
+  uint8_t fingerprint = 0;
+  s = pages.GetBucketSlot(0, 4, 0, 0, &fingerprint);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(fingerprint, 11);
+  s = pages.GetBucketSlot(0, 4, 1, 1, &fingerprint);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(fingerprint, 22);
+}
+
+TEST_F(RedisCuckooPageCacheTest, 
PrefetchBucketsOnDifferentPagesLoadsBothPages) {
+  auto metadata = makeMetadata(4);
+  auto page0_key = makePageKey(metadata, 0, 0);
+  auto page1_key = makePageKey(metadata, 0, 1);
+  std::string page0(metadata.page_size, 0);
+  std::string page1(metadata.page_size, 0);
+  page0[0] = static_cast<char>(44);
+  page1[0] = static_cast<char>(55);
+  writePage(page0_key, page0);
+  writePage(page1_key, page1);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  auto s = pages.PrefetchBuckets(0, 1025, 0, 512);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  writePage(page0_key, std::string(metadata.page_size, static_cast<char>(99)));
+  writePage(page1_key, std::string(metadata.page_size, static_cast<char>(88)));
+
+  uint8_t fingerprint = 0;
+  s = pages.GetBucketSlot(0, 1025, 0, 0, &fingerprint);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(fingerprint, 44);
+  s = pages.GetBucketSlot(0, 1025, 512, 0, &fingerprint);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(fingerprint, 55);
+}
+
+TEST_F(RedisCuckooPageCacheTest, NonDefaultPageSizeControlsBucketMapping) {
+  auto metadata = makeMetadata(4, 1, 8);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  bool inserted = false;
+  auto s = pages.TryInsertInBucket(0, 3, 2, 66, &inserted);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_TRUE(inserted);
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+
+  s = readPage(makePageKey(metadata, 0, 1), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), 4);
+  EXPECT_EQ(static_cast<uint8_t>(page[0]), 66);
+  EXPECT_EQ(page.substr(1, 3), std::string(3, 0));
+}
+
+TEST_F(RedisCuckooPageCacheTest, SetBucketSlotWritesOnlyTargetSlot) {
+  auto metadata = makeMetadata(4);
+  std::string expected(16, static_cast<char>(7));
+  writePage(makePageKey(metadata, 0, 0), expected);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  auto s = pages.SetBucketSlot(0, 4, 2, 1, 88);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  expected[9] = static_cast<char>(88);
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(page, expected);
+}
+
+TEST_F(RedisCuckooPageCacheTest, InvalidBucketAndSlotArguments) {
+  auto metadata = makeMetadata(4);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  uint8_t fingerprint = 0;
+  auto s = pages.GetBucketSlot(0, 2, 0, 4, &fingerprint);
+  EXPECT_TRUE(s.IsInvalidArgument()) << s.ToString();
+
+  s = pages.SetBucketSlot(0, 2, 0, 4, 11);
+  EXPECT_TRUE(s.IsInvalidArgument()) << s.ToString();
+
+  bool inserted = true;
+  s = pages.TryInsertInBucket(0, 2, 2, 11, &inserted);
+  EXPECT_TRUE(s.IsCorruption()) << s.ToString();
+
+  s = pages.TryInsertInBucket(0, 0, 0, 11, &inserted);
+  EXPECT_TRUE(s.IsCorruption()) << s.ToString();
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+}
+
+TEST_F(RedisCuckooPageCacheTest, OversizedPageReturnsCorruption) {
+  auto metadata = makeMetadata(4);
+  writePage(makePageKey(metadata, 0, 0), std::string(9, static_cast<char>(1)));
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  uint8_t fingerprint = 0;
+  auto s = pages.GetBucketSlot(0, 2, 0, 0, &fingerprint);
+  EXPECT_TRUE(s.IsCorruption()) << s.ToString();
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(page, std::string(9, static_cast<char>(1)));
+}
+
+TEST_F(RedisCuckooPageCacheTest, UndersizedPageReturnsCorruption) {
+  auto metadata = makeMetadata(4);
+  auto page_key = makePageKey(metadata, 0, 0);
+  writePage(page_key, std::string{static_cast<char>(1), static_cast<char>(2), 
static_cast<char>(3)});
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  uint8_t fingerprint = 0;
+  auto s = pages.GetBucketSlot(0, 2, 0, 0, &fingerprint);
+  EXPECT_TRUE(s.IsCorruption()) << s.ToString();
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(page_key, &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(page, std::string({static_cast<char>(1), static_cast<char>(2), 
static_cast<char>(3)}));
+}
+
+TEST_F(RedisCuckooPageCacheTest, DirtyPagesAreDiscardedWithoutWriteBack) {
+  auto metadata = makeMetadata(4);
+  {
+    redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                                 metadata.bucket_size, metadata.page_size);
+    bool inserted = false;
+    auto s = pages.TryInsertInBucket(0, 2, 0, 11, &inserted);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    ASSERT_TRUE(inserted);
+  }
+
+  std::string page;
+  auto s = readPage(makePageKey(metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+}
+
+TEST_F(RedisCuckooPageCacheTest, DiscardCachedPagesDropsDirtyPages) {
+  auto metadata = makeMetadata(4);
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), metadata.version,
+                               metadata.bucket_size, metadata.page_size);
+
+  bool inserted = false;
+  auto s = pages.TryInsertInBucket(0, 2, 0, 11, &inserted);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_TRUE(inserted);
+
+  pages.DiscardCachedPages();
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+}
+
+TEST_F(RedisCuckooPageCacheTest, PageKeyUsesMetadataVersion) {
+  auto old_metadata = makeMetadata(4, 100);
+  auto new_metadata = makeMetadata(4, 101);
+  {
+    redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), old_metadata.version,
+                                 old_metadata.bucket_size, 
old_metadata.page_size);
+    bool inserted = false;
+    auto s = pages.TryInsertInBucket(0, 2, 0, 11, &inserted);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    ASSERT_TRUE(inserted);
+
+    auto batch = storage_->GetWriteBatchBase();
+    s = pages.WriteBackDirtyPages(batch.Get());
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    commitBatch(batch.Get());
+  }
+
+  redis::CuckooPageCache pages(storage_.get(), *ctx_, ns_key_, 
storage_->IsSlotIdEncoded(), new_metadata.version,
+                               new_metadata.bucket_size, 
new_metadata.page_size);
+  uint8_t fingerprint = 0;
+  auto s = pages.GetBucketSlot(0, 2, 0, 0, &fingerprint);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(fingerprint, 0);
+  s = pages.SetBucketSlot(0, 2, 0, 0, 22);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = pages.WriteBackDirtyPages(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  commitBatch(batch.Get());
+
+  std::string page;
+  s = readPage(makePageKey(old_metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), 8);
+  EXPECT_EQ(static_cast<uint8_t>(page[0]), 11);
+
+  s = readPage(makePageKey(new_metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), 8);
+  EXPECT_EQ(static_cast<uint8_t>(page[0]), 22);
+}
diff --git a/tests/cppunit/types/cuckoo_filter_test.cc 
b/tests/cppunit/types/cuckoo_filter_test.cc
new file mode 100644
index 000000000..66a24ad42
--- /dev/null
+++ b/tests/cppunit/types/cuckoo_filter_test.cc
@@ -0,0 +1,745 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <limits>
+#include <memory>
+#include <vector>
+
+#include "common/encoding.h"
+#include "storage/redis_db.h"
+#include "storage/redis_metadata.h"
+#include "test_base.h"
+#include "types/cuckoo_filter_page.h"
+#include "types/cuckoo_filter_sub_filter.h"
+#include "types/redis_cuckoo_chain.h"
+
+class RedisCuckooFilterTest : public TestBase {
+ public:
+  RedisCuckooFilterTest(const RedisCuckooFilterTest &) = delete;
+  RedisCuckooFilterTest &operator=(const RedisCuckooFilterTest &) = delete;
+  RedisCuckooFilterTest(RedisCuckooFilterTest &&) = delete;
+  RedisCuckooFilterTest &operator=(RedisCuckooFilterTest &&) = delete;
+
+ protected:
+  explicit RedisCuckooFilterTest() : TestBase() {
+    cuckoo_ = std::make_unique<redis::CuckooChain>(storage_.get(), 
"cuckoo_ns");
+    db_ = std::make_unique<redis::Database>(storage_.get(), "cuckoo_ns");
+  }
+  ~RedisCuckooFilterTest() override {
+    // Ensure cuckoo_ is destroyed before storage_
+    cuckoo_.reset();
+    db_.reset();
+  }
+
+  void SetUp() override {
+    const ::testing::TestInfo *const test_info = 
::testing::UnitTest::GetInstance()->current_test_info();
+    key_ = std::string("cf_test_") + test_info->name();
+  }
+
+  void verifyMetadata(const std::string &key, uint64_t capacity, uint8_t 
bucket_size, uint16_t max_iterations,
+                      uint16_t expansion, uint64_t size, uint16_t n_filters, 
uint64_t num_deleted_items = 0,
+                      uint32_t page_size = kCuckooFilterDefaultPageSize) {
+    std::string ns_key = db_->AppendNamespacePrefix(key);
+    CuckooChainMetadata metadata(false);
+    auto s = db_->GetMetadata(*ctx_, {kRedisCuckooFilter}, ns_key, &metadata);
+    ASSERT_TRUE(s.ok()) << key << ": metadata not found";
+    EXPECT_EQ(metadata.Type(), kRedisCuckooFilter) << key;
+    EXPECT_EQ(metadata.base_capacity, capacity) << key;
+    EXPECT_EQ(metadata.bucket_size, bucket_size) << key;
+    EXPECT_EQ(metadata.max_iterations, max_iterations) << key;
+    EXPECT_EQ(metadata.expansion, expansion) << key;
+    EXPECT_EQ(metadata.size, size) << key;
+    EXPECT_EQ(metadata.n_filters, n_filters) << key;
+    EXPECT_EQ(metadata.num_deleted_items, num_deleted_items) << key;
+    EXPECT_EQ(metadata.page_size, page_size) << key;
+  }
+
+  void reserveAndVerify(const std::string &key, uint64_t capacity, uint8_t 
bucket_size, uint16_t max_iterations,
+                        uint16_t expansion, uint32_t page_size = 
kCuckooFilterDefaultPageSize) {
+    auto s = cuckoo_->Reserve(*ctx_, key, capacity, bucket_size, 
max_iterations, expansion, page_size);
+    ASSERT_TRUE(s.ok()) << key << ": " << s.ToString();
+    verifyMetadata(key, capacity, bucket_size, max_iterations, expansion, 0, 
1, 0, page_size);
+  }
+
+  void addAndVerify(const std::string &key, const std::string &item, uint64_t 
capacity, uint8_t bucket_size,
+                    uint16_t max_iterations, uint16_t expansion, uint64_t 
expected_size, uint16_t n_filters = 1) {
+    bool added = false;
+    auto s = cuckoo_->Add(*ctx_, key, item, &added);
+    ASSERT_TRUE(s.ok()) << key << ": add '" << item << "' failed: " << 
s.ToString();
+    ASSERT_TRUE(added) << key << ": item '" << item << "' should have been 
added";
+    verifyMetadata(key, capacity, bucket_size, max_iterations, expansion, 
expected_size, n_filters, 0);
+  }
+
+  CuckooChainMetadata getMetadata(const std::string &key) {
+    std::string ns_key = db_->AppendNamespacePrefix(key);
+    CuckooChainMetadata metadata(false);
+    auto s = db_->GetMetadata(*ctx_, {kRedisCuckooFilter}, ns_key, &metadata);
+    EXPECT_TRUE(s.ok()) << key << ": metadata not found";
+    return metadata;
+  }
+
+  std::string makePageKey(const std::string &key, const CuckooChainMetadata 
&metadata, uint16_t filter_index,
+                          uint32_t page_index) {
+    std::string sub_key;
+    PutFixed16(&sub_key, filter_index);
+    PutFixed32(&sub_key, page_index);
+    return InternalKey(db_->AppendNamespacePrefix(key), sub_key, 
metadata.version, storage_->IsSlotIdEncoded())
+        .Encode();
+  }
+
+  rocksdb::Status readPage(const std::string &page_key, std::string *value) {
+    return storage_->Get(*ctx_, ctx_->GetReadOptions(), 
storage_->GetCFHandle(ColumnFamilyID::PrimarySubkey), page_key,
+                         value);
+  }
+
+  void writePage(const std::string &page_key, const std::string &value) {
+    auto batch = storage_->GetWriteBatchBase();
+    auto s = batch->Put(page_key, value);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    ASSERT_TRUE(s.ok()) << s.ToString();
+  }
+
+  void writeMetadata(const std::string &key, const CuckooChainMetadata 
&metadata) {
+    std::string metadata_bytes;
+    metadata.Encode(&metadata_bytes);
+    auto batch = storage_->GetWriteBatchBase();
+    auto s =
+        batch->Put(storage_->GetCFHandle(ColumnFamilyID::Metadata), 
db_->AppendNamespacePrefix(key), metadata_bytes);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    s = storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+    ASSERT_TRUE(s.ok()) << s.ToString();
+  }
+
+  std::unique_ptr<redis::CuckooChain> cuckoo_;
+  std::unique_ptr<redis::Database> db_;
+  std::string key_;
+};
+
+TEST_F(RedisCuckooFilterTest, ReserveInvalidParams) {
+  struct InvalidTestCase {
+    std::string key;
+    uint64_t capacity;
+    uint8_t bucket_size;
+    uint16_t max_iterations;
+    uint16_t expansion;
+    std::string err;
+  };
+
+  std::vector<InvalidTestCase> invalid_test_cases = {
+      {"zero_capacity", 0, 4, 500, 2, "capacity must be larger than 0"},
+      {"capacity_too_small", 1, 4, 500, 2, "capacity must be at least 2"},
+      {"zero_bucket_size", 1000, 0, 500, 2, "bucket_size must be between 1 and 
255"},
+      {"zero_max_iterations", 1000, 4, 0, 2, "max_iterations must be larger 
than 0"},
+      {"capacity_too_large", std::numeric_limits<uint64_t>::max(), 4, 500, 2, 
"capacity is too large"},
+      {"expansion_too_large", 1000, 4, 500, redis::kCFMaxExpansion + 1, 
"expansion must be between 0 and 32768"},
+      {"zero_page_size", 1000, 4, 500, 2, "page_size must be larger than 0"},
+      {"page_size_smaller_than_bucket", 1000, 4, 500, 2, "page_size must be at 
least bucket_size"},
+  };
+
+  for (const auto &test_case : invalid_test_cases) {
+    uint32_t page_size = kCuckooFilterDefaultPageSize;
+    if (test_case.key == "zero_page_size") {
+      page_size = 0;
+    } else if (test_case.key == "page_size_smaller_than_bucket") {
+      page_size = test_case.bucket_size - 1;
+    }
+    auto s = cuckoo_->Reserve(*ctx_, test_case.key, test_case.capacity, 
test_case.bucket_size, test_case.max_iterations,
+                              test_case.expansion, page_size);
+    ASSERT_FALSE(s.ok()) << test_case.key;
+    ASSERT_TRUE(s.IsInvalidArgument()) << test_case.key << ": " << 
s.ToString();
+    ASSERT_NE(s.ToString().find(test_case.err), std::string::npos) << 
test_case.key << ": " << s.ToString();
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveValidParams) {
+  struct TestCase {
+    std::string key;
+    uint64_t capacity;
+    uint8_t bucket_size;
+    uint16_t max_iterations;
+    uint16_t expansion;
+  };
+
+  std::vector<TestCase> test_cases = {
+      {"min_capacity", 2, 4, 500, 2},
+      {"min_bucket_size", 1000, 1, 500, 2},
+      {"max_bucket_size", 1000, 255, 500, 2},
+      {"min_max_iterations", 1000, 4, 1, 2},
+      {"max_max_iterations", 1000, 4, 65535, 2},
+      // RedisBloom allows expansion=0; it disables creating additional 
sub-filters.
+      {"no_auto_expansion", 2, 1, 1, 0},
+      {"capacity_3", 3, 4, 500, 2},
+      {"capacity_10", 10, 4, 500, 2},
+      {"capacity_100", 100, 4, 500, 2},
+      {"capacity_10000", 10000, 4, 500, 2},
+      {"capacity_100000", 100000, 4, 500, 2},
+      {"bucket_size_2", 1000, 2, 500, 2},
+      {"bucket_size_8", 1000, 8, 500, 2},
+      {"bucket_size_16", 1000, 16, 500, 2},
+      {"bucket_size_128", 1000, 128, 500, 2},
+      {"no_auto_expansion_regular", 1000, 4, 500, 0},
+      // expansion=1 is valid and creates additional sub-filters with the same 
capacity.
+      {"expansion_1", 1000, 4, 500, 1},
+      {"expansion_8", 1000, 4, 500, 8},
+      {"expansion_256", 1000, 4, 500, 256},
+      {"max_expansion", 1000, 4, 500, redis::kCFMaxExpansion},
+      {"capacity_10M", 10000000, 4, 500, 2},
+      {"capacity_100M", 100000000, 4, 500, 2},
+      {"max_params", 100000, 255, 65535, redis::kCFMaxExpansion},
+      {"mixed_params", 50000, 8, 1000, 4},
+  };
+
+  for (const auto &test_case : test_cases) {
+    reserveAndVerify(test_case.key, test_case.capacity, test_case.bucket_size, 
test_case.max_iterations,
+                     test_case.expansion);
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveRoundsExpansionToPowerOfTwo) {
+  auto s = cuckoo_->Reserve(*ctx_, key_, 1000, 4, 500, 3, 
kCuckooFilterDefaultPageSize);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  verifyMetadata(key_, 1000, 4, 500, 4, 0, 1);
+
+  EXPECT_EQ(redis::CuckooFilterHelper::NormalizeExpansion(0), 0);
+  EXPECT_EQ(redis::CuckooFilterHelper::NormalizeExpansion(1), 1);
+  EXPECT_EQ(redis::CuckooFilterHelper::NormalizeExpansion(3), 4);
+  EXPECT_EQ(redis::CuckooFilterHelper::NormalizeExpansion(5), 8);
+  EXPECT_EQ(redis::CuckooFilterHelper::NormalizeExpansion(32767), 32768);
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveKeepsZeroExpansionNonScaling) {
+  reserveAndVerify(key_, 2, 1, 1, 0);
+
+  uint64_t added_count = 0;
+  bool full = false;
+  for (int i = 0; i < 100; ++i) {
+    bool added = false;
+    auto s = cuckoo_->Add(*ctx_, key_, "item_" + std::to_string(i), &added);
+    if (!s.ok()) {
+      ASSERT_TRUE(s.IsAborted()) << s.ToString();
+      full = true;
+      break;
+    }
+    ASSERT_TRUE(added);
+    ++added_count;
+  }
+
+  ASSERT_TRUE(full);
+  verifyMetadata(key_, 2, 1, 1, 0, added_count, 1, 0);
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveDuplicate) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+
+  // Second reserve with same key should fail
+  auto s = cuckoo_->Reserve(*ctx_, key_, 2000, 4, 500, 2, 
kCuckooFilterDefaultPageSize);
+  ASSERT_FALSE(s.ok());
+  ASSERT_TRUE(s.IsInvalidArgument());
+  ASSERT_NE(s.ToString().find("already exists"), std::string::npos);
+}
+
+TEST_F(RedisCuckooFilterTest, CalculateRequiredBucketsCalculation) {
+  struct TestCase {
+    uint64_t capacity;
+    uint8_t bucket_size;
+    uint32_t expected_num_buckets;
+  };
+
+  std::vector<TestCase> test_cases = {
+      {0, 4, 1},     {1, 4, 1},     {2, 1, 4},      {4, 4, 2},      {488, 4, 
128},   {489, 4, 256},
+      {977, 4, 256}, {978, 4, 512}, {1000, 4, 512}, {1024, 4, 512}, {1000, 1, 
2048}, {1000, 16, 128},
+  };
+
+  for (const auto &test_case : test_cases) {
+    uint32_t num_buckets = 0;
+    auto s =
+        
redis::CuckooFilterHelper::CalculateRequiredBuckets(test_case.capacity, 
test_case.bucket_size, &num_buckets);
+    ASSERT_TRUE(s.ok()) << "capacity=" << test_case.capacity
+                        << ", bucket_size=" << 
static_cast<int>(test_case.bucket_size) << ": " << s.ToString();
+    ASSERT_EQ(num_buckets, test_case.expected_num_buckets)
+        << "capacity=" << test_case.capacity << ", bucket_size=" << 
static_cast<int>(test_case.bucket_size);
+    ASSERT_EQ(num_buckets & (num_buckets - 1), 0) << "Number of buckets should 
be power of 2";
+
+    auto expected_min =
+        static_cast<uint32_t>(static_cast<long double>(test_case.capacity) / 
test_case.bucket_size / 0.955L);
+    ASSERT_GE(num_buckets, expected_min) << "Number of buckets too small for 
capacity";
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, FingerprintGeneration) {
+  // Test fingerprint generation ensures non-zero values in range [1, 255]
+  // Following RedisBloom: fp = hash % 255 + 1
+  for (uint64_t hash = 0; hash < 1000; ++hash) {
+    uint8_t fp = redis::CuckooFilterHelper::GenerateFingerprint(hash);
+    ASSERT_GE(fp, 1) << "Fingerprint should be at least 1";
+    ASSERT_LE(fp, 255) << "Fingerprint should be at most 255";
+  }
+
+  // Verify the formula: fp = hash % 255 + 1
+  ASSERT_EQ(redis::CuckooFilterHelper::GenerateFingerprint(0), 1);
+  ASSERT_EQ(redis::CuckooFilterHelper::GenerateFingerprint(254), 255);
+  ASSERT_EQ(redis::CuckooFilterHelper::GenerateFingerprint(255), 1);
+  ASSERT_EQ(redis::CuckooFilterHelper::GenerateFingerprint(256), 2);
+}
+
+TEST_F(RedisCuckooFilterTest, AlternateBucketCalculation) {
+  std::vector<uint32_t> num_buckets_cases = {1, 2, 128, 256, 512, 1024, 2048};
+
+  // Test GetAltHash symmetry at hash level (following RedisBloom design)
+  // h2 = GetAltHash(fp, h1)
+  // h1 = GetAltHash(fp, h2)  <- this is the symmetry property
+  for (auto num_buckets : num_buckets_cases) {
+    for (uint64_t hash = 0; hash < 100; ++hash) {
+      for (uint16_t fp = 1; fp <= 255; ++fp) {
+        auto fingerprint = static_cast<uint8_t>(fp);
+        uint64_t alt_hash = redis::CuckooFilterHelper::GetAltHash(fingerprint, 
hash);
+
+        // Applying GetAltHash twice should return original hash
+        uint64_t double_alt_hash = 
redis::CuckooFilterHelper::GetAltHash(fingerprint, alt_hash);
+        ASSERT_EQ(double_alt_hash, hash) << "Double alternate hash should give 
original hash";
+
+        // Both hashes should map to valid bucket indices
+        uint32_t bucket1 = hash % num_buckets;
+        uint32_t bucket2 = alt_hash % num_buckets;
+        ASSERT_LT(bucket1, num_buckets) << "Bucket 1 out of range";
+        ASSERT_LT(bucket2, num_buckets) << "Bucket 2 out of range";
+      }
+    }
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, HashFunction) {
+  // Test that Hash function produces consistent 64-bit values
+  std::string test_item = "hello";
+  uint64_t hash1 = redis::CuckooFilterHelper::Hash(test_item);
+  uint64_t hash2 = redis::CuckooFilterHelper::Hash(test_item.data(), 
test_item.size());
+
+  // Both methods should produce the same result
+  ASSERT_EQ(hash1, hash2) << "Hash methods should be consistent";
+
+  // Hash should be deterministic
+  uint64_t hash3 = redis::CuckooFilterHelper::Hash(test_item);
+  ASSERT_EQ(hash1, hash3) << "Hash should be deterministic";
+
+  // Different items should produce different hashes (with high probability)
+  uint64_t hash_world = redis::CuckooFilterHelper::Hash("world");
+  ASSERT_NE(hash1, hash_world) << "Different items should have different 
hashes";
+
+  // Empty string produces hash value 0 (this is expected with MurmurHash)
+  uint64_t hash_empty = redis::CuckooFilterHelper::Hash("");
+  ASSERT_EQ(hash_empty, 0) << "Empty string should produce hash value 0 with 
MurmurHash";
+
+  // Even with hash=0, fingerprint should be non-zero
+  uint8_t fp_empty = 
redis::CuckooFilterHelper::GenerateFingerprint(hash_empty);
+  ASSERT_EQ(fp_empty, 1) << "Fingerprint of hash=0 should be 1 (0 % 255 + 1)";
+
+  // Test that hash can be used with fingerprint generation
+  uint8_t fp = redis::CuckooFilterHelper::GenerateFingerprint(hash1);
+  ASSERT_GE(fp, 1) << "Fingerprint should be at least 1";
+  ASSERT_LE(fp, 255) << "Fingerprint should be at most 255";
+}
+
+TEST_F(RedisCuckooFilterTest, MetadataEncodeDecodeRoundTrip) {
+  CuckooChainMetadata metadata(false);
+  metadata.expire = 2000;
+  metadata.version = 5678;
+  metadata.size = 42;
+  metadata.n_filters = 3;
+  metadata.expansion = 4;
+  metadata.base_capacity = 1000;
+  metadata.bucket_size = 7;
+  metadata.max_iterations = 20;
+  metadata.num_deleted_items = 5;
+  metadata.page_size = 4096;
+
+  std::string encoded;
+  metadata.Encode(&encoded);
+
+  Slice input(encoded);
+  CuckooChainMetadata decoded(false);
+  auto s = decoded.Decode(&input);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  EXPECT_EQ(decoded.Type(), kRedisCuckooFilter);
+  EXPECT_EQ(decoded.expire, metadata.expire);
+  EXPECT_EQ(decoded.version, metadata.version);
+  EXPECT_EQ(decoded.size, metadata.size);
+  EXPECT_EQ(decoded.n_filters, metadata.n_filters);
+  EXPECT_EQ(decoded.expansion, metadata.expansion);
+  EXPECT_EQ(decoded.base_capacity, metadata.base_capacity);
+  EXPECT_EQ(decoded.bucket_size, metadata.bucket_size);
+  EXPECT_EQ(decoded.max_iterations, metadata.max_iterations);
+  EXPECT_EQ(decoded.num_deleted_items, metadata.num_deleted_items);
+  EXPECT_EQ(decoded.page_size, metadata.page_size);
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveVerifyMetadata) {
+  uint64_t capacity = 1000;
+  uint8_t bucket_size = 4;
+  uint16_t max_iterations = 500;
+  uint16_t expansion = 2;
+
+  // Create the filter
+  reserveAndVerify(key_, capacity, bucket_size, max_iterations, expansion);
+
+  // Verify metadata was stored by trying to reserve again with same key
+  // This should fail with "already exists" error
+  auto s =
+      cuckoo_->Reserve(*ctx_, key_, capacity * 2, bucket_size, max_iterations, 
expansion, kCuckooFilterDefaultPageSize);
+  ASSERT_FALSE(s.ok()) << "Second reserve with same key should fail";
+  ASSERT_TRUE(s.IsInvalidArgument()) << "Should return InvalidArgument error";
+  ASSERT_NE(s.ToString().find("already exists"), std::string::npos) << "Error 
message should mention 'already exists'";
+
+  // Verify we can still create filters with different keys
+  reserveAndVerify("different_key", capacity, bucket_size, max_iterations, 
expansion);
+
+  // Verify the original key still exists (can't create it again)
+  s = cuckoo_->Reserve(*ctx_, key_, capacity, bucket_size, max_iterations, 
expansion, kCuckooFilterDefaultPageSize);
+  ASSERT_FALSE(s.ok()) << "Original key should still exist";
+  ASSERT_NE(s.ToString().find("already exists"), std::string::npos);
+}
+
+TEST_F(RedisCuckooFilterTest, ReservePersistsPageSize) {
+  constexpr uint32_t page_size = 4096;
+  reserveAndVerify(key_, 1000, 4, 500, 2, page_size);
+}
+
+TEST_F(RedisCuckooFilterTest, AddBasic) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+  addAndVerify(key_, "item1", 1000, 4, 500, 2, 1);
+}
+
+TEST_F(RedisCuckooFilterTest, ReserveDoesNotPreallocatePages) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+
+  auto metadata = getMetadata(key_);
+  std::string page;
+  auto s = readPage(makePageKey(key_, metadata, 0, 0), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+}
+
+TEST_F(RedisCuckooFilterTest, AddWritesPagedLayout) {
+  constexpr uint64_t capacity = 1000;
+  constexpr uint8_t bucket_size = 4;
+  reserveAndVerify(key_, capacity, bucket_size, 500, 2);
+
+  const std::string item = "item1";
+  addAndVerify(key_, item, capacity, bucket_size, 500, 2, 1);
+
+  auto metadata = getMetadata(key_);
+  uint32_t num_buckets = 0;
+  auto s = redis::CuckooFilterHelper::CalculateRequiredBuckets(capacity, 
bucket_size, &num_buckets);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  auto hash = redis::CuckooFilterHelper::Hash(item);
+  auto fingerprint = redis::CuckooFilterHelper::GenerateFingerprint(hash);
+  auto bucket1_idx = static_cast<uint32_t>(hash % num_buckets);
+  auto bucket2_idx = 
static_cast<uint32_t>(redis::CuckooFilterHelper::GetAltHash(fingerprint, hash) 
% num_buckets);
+  auto buckets_per_page = metadata.page_size / bucket_size;
+  auto page_index = bucket1_idx / buckets_per_page;
+  auto page_size = std::min(buckets_per_page, num_buckets - page_index * 
buckets_per_page) * bucket_size;
+
+  std::string page;
+  s = readPage(makePageKey(key_, metadata, 0, page_index), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), page_size);
+
+  auto bucket1_offset = (bucket1_idx % buckets_per_page) * bucket_size;
+  auto bucket2_offset = (bucket2_idx % buckets_per_page) * bucket_size;
+  bool found = false;
+  for (uint32_t i = 0; i < bucket_size; ++i) {
+    found = found || static_cast<uint8_t>(page[bucket1_offset + i]) == 
fingerprint;
+    if (bucket2_idx / buckets_per_page == page_index) {
+      found = found || static_cast<uint8_t>(page[bucket2_offset + i]) == 
fingerprint;
+    }
+  }
+  EXPECT_TRUE(found);
+}
+
+TEST_F(RedisCuckooFilterTest, AddToNonExistentFilter) {
+  std::string key = "nonexistent_key";
+  addAndVerify(key, "item1", redis::kCFDefaultCapacity, 
redis::kCFDefaultBucketSize, redis::kCFDefaultMaxIterations,
+               redis::kCFDefaultExpansion, 1);
+
+  auto s = cuckoo_->Reserve(*ctx_, key, 1000, 4, 500, 2, 
kCuckooFilterDefaultPageSize);
+  ASSERT_FALSE(s.ok());
+  ASSERT_TRUE(s.IsInvalidArgument());
+  ASSERT_NE(s.ToString().find("already exists"), std::string::npos);
+}
+
+TEST_F(RedisCuckooFilterTest, AddWithDifferentBucketSizes) {
+  std::vector<uint8_t> bucket_sizes = {1, 2, 4, 8, 16};
+
+  for (auto bs : bucket_sizes) {
+    std::string test_key = key_ + "_bucket_" + std::to_string(bs);
+    reserveAndVerify(test_key, 100, bs, 500, 2);
+    for (int j = 0; j < 10; ++j) {
+      addAndVerify(test_key, "item_" + std::to_string(j), 100, bs, 500, 2, j + 
1);
+    }
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, AddDuplicateItems) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+  for (int i = 0; i < 5; ++i) {
+    addAndVerify(key_, "duplicate_item", 1000, 4, 500, 2, i + 1);
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, AddPrioritizesNewestFilter) {
+  CuckooChainMetadata metadata;
+  metadata.size = 0;
+  metadata.base_capacity = 1000;
+  metadata.bucket_size = 4;
+  metadata.max_iterations = 500;
+  metadata.expansion = 2;
+  metadata.n_filters = 2;
+  metadata.num_deleted_items = 0;
+  metadata.page_size = kCuckooFilterDefaultPageSize;
+  writeMetadata(key_, metadata);
+
+  const std::string item = "item";
+  addAndVerify(key_, item, metadata.base_capacity, metadata.bucket_size, 
metadata.max_iterations, metadata.expansion, 1,
+               metadata.n_filters);
+
+  auto stored_metadata = getMetadata(key_);
+  ASSERT_EQ(stored_metadata.n_filters, 2);
+  ASSERT_EQ(stored_metadata.size, 1);
+
+  auto hash = redis::CuckooFilterHelper::Hash(item);
+  uint32_t old_num_buckets = 0;
+  auto s = 
redis::CuckooFilterHelper::CalculateRequiredBuckets(metadata.base_capacity, 
metadata.bucket_size,
+                                                               
&old_num_buckets);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  auto buckets_per_page = stored_metadata.page_size / 
stored_metadata.bucket_size;
+  auto old_page_idx = static_cast<uint32_t>(hash % old_num_buckets) / 
buckets_per_page;
+
+  std::string page;
+  s = readPage(makePageKey(key_, stored_metadata, 0, old_page_idx), &page);
+  EXPECT_TRUE(s.IsNotFound()) << s.ToString();
+
+  uint32_t num_buckets = 0;
+  s = 
redis::CuckooFilterHelper::CalculateRequiredBuckets(metadata.base_capacity * 
metadata.expansion,
+                                                          
metadata.bucket_size, &num_buckets);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  auto bucket1_idx = static_cast<uint32_t>(hash % num_buckets);
+  auto expected_page_idx = bucket1_idx / buckets_per_page;
+
+  s = readPage(makePageKey(key_, stored_metadata, 1, expected_page_idx), 
&page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+}
+
+TEST_F(RedisCuckooFilterTest, AddManyItems) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+  for (int i = 0; i < 100; ++i) {
+    addAndVerify(key_, "item_" + std::to_string(i), 1000, 4, 500, 2, i + 1);
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, AddSmallFilterCapacity) {
+  uint64_t small_capacity = 10;
+  reserveAndVerify(key_, small_capacity, 2, 500, 0);
+
+  uint64_t added_count = 0;
+  bool full = false;
+  for (int i = 0; i < 100; ++i) {
+    std::string item = "item_" + std::to_string(i);
+    bool added = false;
+    auto s = cuckoo_->Add(*ctx_, key_, item, &added);
+
+    if (!s.ok()) {
+      ASSERT_TRUE(s.IsAborted()) << "Should be Aborted status when full";
+      full = true;
+      break;
+    }
+
+    ASSERT_TRUE(added) << "Item should have been added before the filter is 
full";
+    ++added_count;
+  }
+
+  ASSERT_TRUE(full) << "Small filter should eventually become full";
+  verifyMetadata(key_, small_capacity, 2, 500, 0, added_count, 1, 0);
+}
+
+TEST_F(RedisCuckooFilterTest, AddEdgeCaseItems) {
+  reserveAndVerify(key_, 1000, 4, 500, 2);
+  std::vector<std::string> items = {
+      "",
+      std::string(10000, 'x'),
+      std::string("\x00\x01\x02\xFF\xFE", 5),
+  };
+  for (size_t i = 0; i < items.size(); ++i) {
+    addAndVerify(key_, items[i], 1000, 4, 500, 2, i + 1);
+  }
+}
+
+TEST_F(RedisCuckooFilterTest, KickOutSuccessWritesDirtyPages) {
+  constexpr uint64_t capacity = 2;
+  constexpr uint8_t bucket_size = 1;
+  constexpr uint32_t num_buckets = 4;
+  reserveAndVerify(key_, capacity, bucket_size, 1, 0);
+
+  struct Candidate {
+    std::string item;
+    uint8_t fingerprint = 0;
+    uint32_t bucket1 = 0;
+    uint32_t bucket2 = 0;
+  };
+
+  std::vector<Candidate> candidates;
+  for (int i = 0; i < 10000; ++i) {
+    std::string item = "kick_item_" + std::to_string(i);
+    auto hash = redis::CuckooFilterHelper::Hash(item);
+    auto fingerprint = redis::CuckooFilterHelper::GenerateFingerprint(hash);
+    candidates.push_back(
+        {item, fingerprint, static_cast<uint32_t>(hash % num_buckets),
+         
static_cast<uint32_t>(redis::CuckooFilterHelper::GetAltHash(fingerprint, hash) 
% num_buckets)});
+  }
+
+  Candidate first;
+  Candidate second;
+  Candidate kicked;
+  uint32_t evicted_bucket = 0;
+  bool found = false;
+  for (const auto &candidate : candidates) {
+    if (candidate.bucket1 == candidate.bucket2) continue;
+    for (const auto &first_candidate : candidates) {
+      if (first_candidate.item == candidate.item || first_candidate.bucket1 != 
candidate.bucket1) continue;
+      auto alt_for_victim =
+          redis::CuckooFilterHelper::GetAltBucketIndex(candidate.bucket1, 
first_candidate.fingerprint, num_buckets);
+      if (alt_for_victim == candidate.bucket1 || alt_for_victim == 
candidate.bucket2) continue;
+
+      for (const auto &second_candidate : candidates) {
+        if (second_candidate.item == candidate.item || second_candidate.item 
== first_candidate.item ||
+            second_candidate.bucket1 != candidate.bucket2) {
+          continue;
+        }
+        first = first_candidate;
+        second = second_candidate;
+        kicked = candidate;
+        evicted_bucket = alt_for_victim;
+        found = true;
+        break;
+      }
+      if (found) break;
+    }
+    if (found) break;
+  }
+  ASSERT_TRUE(found);
+
+  addAndVerify(key_, first.item, capacity, bucket_size, 1, 0, 1);
+  addAndVerify(key_, second.item, capacity, bucket_size, 1, 0, 2);
+  addAndVerify(key_, kicked.item, capacity, bucket_size, 1, 0, 3);
+
+  auto metadata = getMetadata(key_);
+  ASSERT_EQ(metadata.n_filters, 1);
+  ASSERT_EQ(metadata.size, 3);
+
+  std::string page;
+  auto s = readPage(makePageKey(key_, metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  ASSERT_EQ(page.size(), num_buckets * bucket_size);
+  EXPECT_EQ(static_cast<uint8_t>(page[kicked.bucket1]), kicked.fingerprint);
+  EXPECT_EQ(static_cast<uint8_t>(page[kicked.bucket2]), second.fingerprint);
+  EXPECT_EQ(static_cast<uint8_t>(page[evicted_bucket]), first.fingerprint);
+}
+
+TEST_F(RedisCuckooFilterTest, KickOutErrorDiscardsDirtyPages) {
+  constexpr uint8_t bucket_size = 1;
+  constexpr uint32_t page_size = 1;
+  constexpr uint32_t num_buckets = 2;
+  CuckooChainMetadata metadata(false);
+  metadata.version = 1;
+  metadata.size = 0;
+  metadata.base_capacity = 1;
+  metadata.bucket_size = bucket_size;
+  metadata.max_iterations = 2;
+  metadata.expansion = 0;
+  metadata.n_filters = 1;
+  metadata.num_deleted_items = 0;
+  metadata.page_size = page_size;
+  writeMetadata(key_, metadata);
+
+  constexpr uint64_t hash = 0;
+  constexpr uint8_t fingerprint = 2;
+  constexpr uint8_t existing_fingerprint = 1;
+  ASSERT_EQ(redis::CuckooFilterHelper::GetAltBucketIndex(0, 
existing_fingerprint, num_buckets), 1);
+  std::string original_page{static_cast<char>(existing_fingerprint)};
+  writePage(makePageKey(key_, metadata, 0, 0), original_page);
+  writePage(makePageKey(key_, metadata, 0, 1), std::string(2, 
static_cast<char>(9)));
+
+  redis::CuckooSubFilter sub_filter(storage_.get(), *ctx_, 
db_->AppendNamespacePrefix(key_),
+                                    storage_->IsSlotIdEncoded(), 
metadata.version, metadata.bucket_size,
+                                    metadata.page_size, 0, num_buckets);
+  bool inserted = true;
+  auto s = sub_filter.TryKickOutInsert(hash, fingerprint, 
metadata.max_iterations, &inserted);
+  ASSERT_TRUE(s.IsCorruption()) << s.ToString();
+  ASSERT_FALSE(inserted);
+
+  auto batch = storage_->GetWriteBatchBase();
+  s = sub_filter.WriteToBatch(batch.Get());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  s = storage_->Write(*ctx_, storage_->DefaultWriteOptions(), 
batch->GetWriteBatch());
+  ASSERT_TRUE(s.ok()) << s.ToString();
+
+  std::string page;
+  s = readPage(makePageKey(key_, metadata, 0, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(page, original_page);
+}
+
+TEST_F(RedisCuckooFilterTest, ExpansionWritesNewFilterIndexPage) {
+  constexpr uint64_t capacity = 2;
+  constexpr uint8_t bucket_size = 1;
+  reserveAndVerify(key_, capacity, bucket_size, 1, 2);
+
+  CuckooChainMetadata metadata(false);
+  uint64_t added_count = 0;
+  for (int i = 0; i < 100; ++i) {
+    bool added = false;
+    auto s = cuckoo_->Add(*ctx_, key_, "item_" + std::to_string(i), &added);
+    ASSERT_TRUE(s.ok()) << s.ToString();
+    ASSERT_TRUE(added);
+    ++added_count;
+
+    metadata = getMetadata(key_);
+    if (metadata.n_filters > 1) break;
+  }
+
+  ASSERT_GT(metadata.n_filters, 1);
+  EXPECT_EQ(metadata.size, added_count);
+
+  uint32_t num_buckets = 0;
+  uint64_t new_filter_capacity = capacity;
+  for (uint16_t i = 0; i < metadata.n_filters - 1; ++i) {
+    new_filter_capacity *= metadata.expansion;
+  }
+  auto s = 
redis::CuckooFilterHelper::CalculateRequiredBuckets(new_filter_capacity, 
bucket_size, &num_buckets);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  auto expected_page_size = std::min(metadata.page_size / bucket_size, 
num_buckets) * bucket_size;
+
+  std::string page;
+  s = readPage(makePageKey(key_, metadata, metadata.n_filters - 1, 0), &page);
+  ASSERT_TRUE(s.ok()) << s.ToString();
+  EXPECT_EQ(page.size(), expected_page_size);
+}
diff --git a/tests/gocase/integration/replication/replication_test.go 
b/tests/gocase/integration/replication/replication_test.go
index d21239ef2..1914d334c 100644
--- a/tests/gocase/integration/replication/replication_test.go
+++ b/tests/gocase/integration/replication/replication_test.go
@@ -200,6 +200,8 @@ func TestReplicationBasics(t *testing.T) {
                redis.Z{Score: 2, Member: "b"},
                redis.Z{Score: 3, Member: "c"},
        ).Err())
+       require.NoError(t, masterClient.Do(ctx, "cf.reserve", "mycf", 
"1000").Err())
+       require.NoError(t, masterClient.Do(ctx, "cf.add", "mycf", 
"item0").Err())
 
        slave := util.StartServer(t, map[string]string{})
        defer slave.Close()
@@ -225,6 +227,7 @@ func TestReplicationBasics(t *testing.T) {
                require.Equal(t, masterClient.ZRangeWithScores(ctx, "myzset", 
0, -1),
                        slaveClient.ZRangeWithScores(ctx, "myzset", 0, -1))
                require.Equal(t, masterClient.SMembers(ctx, "myhash"), 
slaveClient.SMembers(ctx, "myhash"))
+               require.Equal(t, "MBbloomCF", slaveClient.Type(ctx, 
"mycf").Val())
        })
 
        t.Run("The link status should be up", func(t *testing.T) {
@@ -238,6 +241,16 @@ func TestReplicationBasics(t *testing.T) {
                }, 50*time.Second, 100*time.Millisecond)
        })
 
+       t.Run("Cuckoo filter updates should replicate", func(t *testing.T) {
+               require.NoError(t, masterClient.Do(ctx, "cf.add", "mycf", 
"item1").Err())
+               util.WaitForOffsetSync(t, masterClient, slaveClient, 
5*time.Second)
+               require.Equal(t, "MBbloomCF", slaveClient.Type(ctx, 
"mycf").Val())
+
+               require.NoError(t, masterClient.Do(ctx, "cf.add", "mycf", 
"item2").Err())
+               util.WaitForOffsetSync(t, masterClient, slaveClient, 
5*time.Second)
+               require.Equal(t, "MBbloomCF", slaveClient.Type(ctx, 
"mycf").Val())
+       })
+
        t.Run("FLUSHALL should be replicated", func(t *testing.T) {
                require.NoError(t, masterClient.FlushAll(ctx).Err())
                time.Sleep(100 * time.Millisecond)
diff --git a/tests/gocase/integration/slotmigrate/slotmigrate_test.go 
b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
index 7f82885e6..5a214bb9d 100644
--- a/tests/gocase/integration/slotmigrate/slotmigrate_test.go
+++ b/tests/gocase/integration/slotmigrate/slotmigrate_test.go
@@ -1179,6 +1179,43 @@ func TestSlotMigrateTypeFallback(t *testing.T) {
        })
 }
 
+func TestSlotMigrateCuckooFilter(t *testing.T) {
+       ctx := context.Background()
+
+       srv0 := util.StartServer(t, map[string]string{"cluster-enabled": "yes"})
+       defer srv0.Close()
+       rdb0 := srv0.NewClient()
+       defer func() { require.NoError(t, rdb0.Close()) }()
+       id0 := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx00"
+       require.NoError(t, rdb0.Do(ctx, "clusterx", "setnodeid", id0).Err())
+
+       srv1 := util.StartServer(t, map[string]string{"cluster-enabled": "yes"})
+       defer srv1.Close()
+       rdb1 := srv1.NewClient()
+       defer func() { require.NoError(t, rdb1.Close()) }()
+       id1 := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx01"
+       require.NoError(t, rdb1.Do(ctx, "clusterx", "setnodeid", id1).Err())
+
+       clusterNodes := fmt.Sprintf("%s %s %d master - 0-10000\n", id0, 
srv0.Host(), srv0.Port())
+       clusterNodes += fmt.Sprintf("%s %s %d master - 10001-16383", id1, 
srv1.Host(), srv1.Port())
+       require.NoError(t, rdb0.Do(ctx, "clusterx", "setnodes", clusterNodes, 
"1").Err())
+       require.NoError(t, rdb1.Do(ctx, "clusterx", "setnodes", clusterNodes, 
"1").Err())
+
+       t.Run("MIGRATE - Cuckoo filter is preserved by raw-key-value 
migration", func(t *testing.T) {
+               slot := 31
+               key := fmt.Sprintf("cf_{%s}", util.SlotTable[slot])
+               require.NoError(t, rdb0.ConfigSet(ctx, "migrate-type", 
string(MigrationTypeRawKeyValue)).Err())
+               require.NoError(t, rdb0.Do(ctx, "cf.reserve", key, 
"1000").Err())
+               for i := 0; i < 20; i++ {
+                       require.NoError(t, rdb0.Do(ctx, "cf.add", key, 
fmt.Sprintf("item_%d", i)).Err())
+               }
+
+               require.Equal(t, "OK", rdb0.Do(ctx, "clusterx", "migrate", 
slot, id1, "sync").Val())
+               require.Equal(t, "MBbloomCF", rdb1.Type(ctx, key).Val())
+               require.Equal(t, int64(1), rdb1.Do(ctx, "cf.add", key, 
"new-item").Val())
+       })
+}
+
 func waitForMigrateState(t testing.TB, client *redis.Client, slot int, state 
SlotMigrationState) {
        waitForMigrateStateInDuration(t, client, slot, state, 5*time.Second)
 }
diff --git a/tests/gocase/unit/disk/disk_test.go 
b/tests/gocase/unit/disk/disk_test.go
index 9f0a27930..befeda91e 100644
--- a/tests/gocase/unit/disk/disk_test.go
+++ b/tests/gocase/unit/disk/disk_test.go
@@ -135,6 +135,17 @@ func TestDisk(t *testing.T) {
                require.LessOrEqual(t, val, 
int(float64(approximateSize)/estimationFactor))
        })
 
+       t.Run("Disk usage Cuckoo Filter", func(t *testing.T) {
+               key := "cuckoo_filter_key"
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "1000").Err())
+               _, err := rdb.Do(ctx, "Disk", "usage", key).Int()
+               require.NoError(t, err)
+
+               require.NoError(t, rdb.Do(ctx, "cf.add", key, "item").Err())
+               _, err = rdb.Do(ctx, "Disk", "usage", key).Int()
+               require.NoError(t, err)
+       })
+
        t.Run("Disk usage with typo ", func(t *testing.T) {
                require.ErrorContains(t, rdb.Do(ctx, "Disk", "usa", 
"sortedintkey").Err(), "Unknown operation")
        })
diff --git a/tests/gocase/unit/scan/scan_test.go 
b/tests/gocase/unit/scan/scan_test.go
index e0eb8e4d7..c20267f33 100644
--- a/tests/gocase/unit/scan/scan_test.go
+++ b/tests/gocase/unit/scan/scan_test.go
@@ -465,6 +465,12 @@ func ScanTest(t *testing.T, rdb *redis.Client, ctx 
context.Context) {
                require.NoError(t, rdb.Do(ctx, "bf.reserve", "MBbloomtype3", 
"0.02", "1000").Err())
                require.Equal(t, []string{"MBbloomtype1", "MBbloomtype2", 
"MBbloomtype3"}, scanAll(t, rdb, "match", "MBbloomtype*", "type", "MBbloom--"))
                require.Equal(t, []string{"MBbloomtype1", "MBbloomtype2", 
"MBbloomtype3"}, scanAll(t, rdb, "match", "MBbloomtype*", "count", "3", "type", 
"MBbloom--"))
+               //MBbloomCF type
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", "MBbloomCFtype1", 
"1000").Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", "MBbloomCFtype2", 
"1000").Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", "MBbloomCFtype3", 
"1000").Err())
+               require.Equal(t, []string{"MBbloomCFtype1", "MBbloomCFtype2", 
"MBbloomCFtype3"}, scanAll(t, rdb, "match", "MBbloomCFtype*", "type", 
"MBbloomCF"))
+               require.Equal(t, []string{"MBbloomCFtype1", "MBbloomCFtype2", 
"MBbloomCFtype3"}, scanAll(t, rdb, "match", "MBbloomCFtype*", "count", "3", 
"type", "MBbloomCF"))
                //ReJSON-RL type
                require.NoError(t, rdb.Do(ctx, "JSON.SET", "ReJSONtype1", "$", 
` {"x":1, "y":2} `).Err())
                require.NoError(t, rdb.Do(ctx, "JSON.SET", "ReJSONtype2", "$", 
` {"x":1, "y":2} `).Err())
diff --git a/tests/gocase/unit/type/bloom/cuckoo_filter_test.go 
b/tests/gocase/unit/type/bloom/cuckoo_filter_test.go
new file mode 100644
index 000000000..852dde75e
--- /dev/null
+++ b/tests/gocase/unit/type/bloom/cuckoo_filter_test.go
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package bloom
+
+import (
+       "context"
+       "fmt"
+       "testing"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+       "github.com/stretchr/testify/require"
+)
+
+func TestCuckooFilter(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       t.Run("Add creates filter", func(t *testing.T) {
+               key := "test_cuckoo_filter_add_create"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.Equal(t, int64(1), rdb.Do(ctx, "cf.add", key, 
"item").Val())
+               require.Equal(t, "MBbloomCF", rdb.Type(ctx, key).Val())
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", key, 
"1000").Err(), "already exists")
+       })
+
+       t.Run("Wrong type", func(t *testing.T) {
+               key := "test_cuckoo_filter_wrong_type"
+               require.NoError(t, rdb.Set(ctx, key, "value", 0).Err())
+               require.ErrorContains(t, rdb.Do(ctx, "cf.add", key, 
"item").Err(), "WRONGTYPE")
+       })
+
+       t.Run("Reserve expansion", func(t *testing.T) {
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", 
"test_cuckoo_filter_expansion_256", "1000", "EXPANSION", "256").Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", 
"test_cuckoo_filter_expansion_max", "1000", "EXPANSION", "32768").Err())
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"test_cuckoo_filter_expansion_too_large", "1000", "EXPANSION", "32769").Err(), 
"expansion must be between 0 and 32768")
+       })
+
+       t.Run("Reserve creates cuckoo filter type", func(t *testing.T) {
+               key := "test_cuckoo_filter_reserve_type"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "1000").Err())
+               require.Equal(t, "MBbloomCF", rdb.Type(ctx, key).Val())
+       })
+
+       t.Run("Reserve invalid capacity", func(t *testing.T) {
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_cap_str", "abc").Err(), "invalid capacity")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_cap_neg", "-1").Err(), "invalid capacity")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_cap_zero", "0").Err(), "capacity must be larger than 0")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_cap_one", "1").Err(), "capacity must be at least 2")
+       })
+
+       t.Run("Reserve invalid bucket size", func(t *testing.T) {
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_bs_str", "1000", "BUCKETSIZE", "abc").Err(), "invalid bucket size")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_bs_zero", "1000", "BUCKETSIZE", "0").Err(), "bucket size must be 
between 1 and 255")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_bs_neg", "1000", "BUCKETSIZE", "-1").Err(), "invalid bucket size")
+       })
+
+       t.Run("Reserve invalid max iterations", func(t *testing.T) {
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_mi_str", "1000", "MAXITERATIONS", "abc").Err(), "invalid max 
iterations")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_mi_zero", "1000", "MAXITERATIONS", "0").Err(), "max iterations must be 
larger than 0")
+       })
+
+       t.Run("Reserve invalid expansion", func(t *testing.T) {
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_exp_str", "1000", "EXPANSION", "abc").Err(), "invalid expansion factor")
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_exp_big", "1000", "EXPANSION", "32769").Err(), "expansion must be 
between 0 and 32768")
+       })
+
+       t.Run("Reserve invalid syntax", func(t *testing.T) {
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", 
"cf_bad_syntax", "1000", "UNKNOWNOPT").Err(), "syntax error")
+       })
+
+       t.Run("Reserve duplicate key", func(t *testing.T) {
+               key := "test_cuckoo_filter_reserve_dup"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "1000").Err())
+               require.ErrorContains(t, rdb.Do(ctx, "cf.reserve", key, 
"2000").Err(), "already exists")
+       })
+
+       t.Run("Reserve wrong number of arguments", func(t *testing.T) {
+               require.Error(t, rdb.Do(ctx, "cf.reserve").Err())
+               require.Error(t, rdb.Do(ctx, "cf.reserve", "key_only").Err())
+       })
+
+       t.Run("Add wrong number of arguments", func(t *testing.T) {
+               require.Error(t, rdb.Do(ctx, "cf.add").Err())
+               require.Error(t, rdb.Do(ctx, "cf.add", "key_only").Err())
+               require.Error(t, rdb.Do(ctx, "cf.add", "key", "item1", 
"item2").Err())
+       })
+
+       t.Run("Add many items", func(t *testing.T) {
+               key := "test_cuckoo_filter_add_many"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "1000").Err())
+               for i := 0; i < 500; i++ {
+                       result := rdb.Do(ctx, "cf.add", key, 
fmt.Sprintf("item_%d", i))
+                       require.NoError(t, result.Err())
+                       require.Equal(t, int64(1), result.Val())
+               }
+       })
+
+       t.Run("Add duplicate items allowed", func(t *testing.T) {
+               key := "test_cuckoo_filter_add_dup"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "1000").Err())
+               for i := 0; i < 10; i++ {
+                       result := rdb.Do(ctx, "cf.add", key, "same_item")
+                       require.NoError(t, result.Err())
+                       require.Equal(t, int64(1), result.Val())
+               }
+       })
+
+       t.Run("Add triggers expansion", func(t *testing.T) {
+               key := "test_cuckoo_filter_add_expansion"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               // Small capacity with expansion enabled to trigger expansion 
quickly
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "4", 
"BUCKETSIZE", "1", "MAXITERATIONS", "1", "EXPANSION", "2").Err())
+               // Add enough items to trigger expansion
+               for i := 0; i < 20; i++ {
+                       result := rdb.Do(ctx, "cf.add", key, 
fmt.Sprintf("expand_item_%d", i))
+                       require.NoError(t, result.Err())
+                       require.Equal(t, int64(1), result.Val())
+               }
+       })
+
+       t.Run("Add to full non-scaling filter returns error", func(t 
*testing.T) {
+               key := "test_cuckoo_filter_full_nonscaling"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               // expansion=0 disables scaling; small capacity fills quickly
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "4", 
"BUCKETSIZE", "1", "MAXITERATIONS", "1", "EXPANSION", "0").Err())
+               full := false
+               for i := 0; i < 100; i++ {
+                       result := rdb.Do(ctx, "cf.add", key, 
fmt.Sprintf("full_item_%d", i))
+                       if result.Err() != nil {
+                               require.ErrorContains(t, result.Err(), "filter 
is full")
+                               full = true
+                               break
+                       }
+               }
+               require.True(t, full, "Non-scaling filter should eventually 
become full")
+       })
+
+       t.Run("Reserve with all optional params", func(t *testing.T) {
+               key := "test_cuckoo_filter_all_opts"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "5000", 
"BUCKETSIZE", "8", "MAXITERATIONS", "100", "EXPANSION", "4").Err())
+               require.Equal(t, "MBbloomCF", rdb.Type(ctx, key).Val())
+       })
+
+       t.Run("Add empty string item", func(t *testing.T) {
+               key := "test_cuckoo_filter_empty_item"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "100").Err())
+               result := rdb.Do(ctx, "cf.add", key, "")
+               require.NoError(t, result.Err())
+               require.Equal(t, int64(1), result.Val())
+       })
+
+       t.Run("Add large item", func(t *testing.T) {
+               key := "test_cuckoo_filter_large_item"
+               require.NoError(t, rdb.Del(ctx, key).Err())
+               require.NoError(t, rdb.Do(ctx, "cf.reserve", key, "100").Err())
+               largeItem := make([]byte, 10000)
+               for i := range largeItem {
+                       largeItem[i] = byte('a' + i%26)
+               }
+               result := rdb.Do(ctx, "cf.add", key, string(largeItem))
+               require.NoError(t, result.Err())
+               require.Equal(t, int64(1), result.Val())
+       })
+}

Reply via email to