Current section
Files
Jump to
Current section
Files
lib/cuckoo.ex
defmodule Cuckoo do
@moduledoc """
This module implements a [Cuckoo Filter](https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf).
## Implementation Details
The implementation follows the specification as per the paper above.
For hashing we use the x64_128 variant of Murmur3 and the Erlang phash2.
## Examples
iex> cf = Cuckoo.new(1000, 16, 4)
%Cuckoo.Filter{...}
iex> {:ok, cf} = Cuckoo.insert(cf, 5)
%Cuckoo.Filter{...}
iex> Cuckoo.contains?(cf, 5)
true
iex> {:ok, cf} = Cuckoo.delete(cf, 5)
%Cuckoo.Filter{...}
iex> Cuckoo.contains?(cf, 5)
false
"""
use Bitwise
alias Cuckoo.Bucket, as: Bucket
@max_kicks 500
defstruct [
:buckets,
:fingerprint_size,
:fingerprints_per_bucket,
:max_num_keys
]
@type t :: %Cuckoo{
buckets: :array.array(),
fingerprint_size: pos_integer,
fingerprints_per_bucket: pos_integer,
max_num_keys: pos_integer
}
defmodule Error do
defexception reason: nil, action: "", element: nil
def message(exception) do
"could not #{exception.action} #{exception.element}: #{exception.reason}"
end
end
@doc """
Creates a new Cuckoo Filter using the given `max_num_keys`, `fingerprint_size` and
`fingerprints_per_bucket`.
The suggested values for the last two according to one of the publications should
be `16` and `4` respectively, as it allows the Cuckoo Filter to achieve a sweet spot
in space effiency and table occupancy.
"""
@spec new(pos_integer, pos_integer, pos_integer) :: Cuckoo.t()
def new(max_num_keys, fingerprint_size, fingerprints_per_bucket \\ 4) when max_num_keys > 2 do
num_buckets = upper_power_2(max_num_keys / fingerprints_per_bucket)
frac = max_num_keys / num_buckets / fingerprints_per_bucket
%Cuckoo{
buckets:
:array.new([
if frac > 0.96 do
num_buckets <<< 1
else
num_buckets
end,
:fixed,
{:default, Bucket.new(fingerprints_per_bucket)}
]),
fingerprint_size: fingerprint_size,
fingerprints_per_bucket: fingerprints_per_bucket,
max_num_keys: max_num_keys
}
end
@doc """
Tries to insert `element` into the Cuckoo Filter.
Returns `{:ok, filter}` if successful, otherwise returns `{:error, :full}` from which
you should consider the Filter to be full.
"""
@spec insert(Cuckoo.t(), any) :: {:ok, Cuckoo.t()} | {:error, :full}
def insert(
%Cuckoo{
buckets: buckets,
fingerprint_size: bits_per_item,
fingerprints_per_bucket: fingerprints_per_bucket
} = filter,
element
) do
num_buckets = :array.size(buckets)
{fingerprint, i1} = fingerprint_and_index(element, num_buckets, bits_per_item)
i2 = alt_index(i1, fingerprint, num_buckets)
i1_bucket = :array.get(i1, buckets)
case Bucket.has_room?(i1_bucket) do
{:ok, index} ->
{:ok,
%{filter | buckets: :array.set(i1, Bucket.set(i1_bucket, index, fingerprint), buckets)}}
{:error, :full} ->
i2_bucket = :array.get(i2, buckets)
case Bucket.has_room?(i2_bucket) do
{:ok, index} ->
{:ok,
%{
filter
| buckets: :array.set(i2, Bucket.set(i2_bucket, index, fingerprint), buckets)
}}
{:error, :full} ->
random_i = Enum.random([i1, i2])
kickout(filter, random_i, fingerprint, fingerprints_per_bucket)
end
end
end
@doc """
Checks if the Cuckoo Filter contains `element`.
Returns `true` if does, otherwise returns `false`.
"""
@spec contains?(Cuckoo.t(), any) :: boolean
def contains?(%Cuckoo{buckets: buckets, fingerprint_size: bits_per_item}, element) do
num_buckets = :array.size(buckets)
{fingerprint, i1} = fingerprint_and_index(element, num_buckets, bits_per_item)
if Bucket.contains?(:array.get(i1, buckets), fingerprint) do
true
else
i2 = alt_index(i1, fingerprint, num_buckets)
Bucket.contains?(:array.get(i2, buckets), fingerprint)
end
end
@doc """
Attempts to delete `element` from the Cuckoo Filter if it contains it.
Returns `{:error, :inexistent}` if the element doesn't exist in the filter, otherwise
returns `{:ok, filter}`.
"""
@spec delete(Cuckoo.t(), any) :: {:ok, Cuckoo.t()} | {:error, :inexistent}
def delete(%Cuckoo{buckets: buckets, fingerprint_size: bits_per_item} = filter, element) do
num_buckets = :array.size(buckets)
{fingerprint, i1} = fingerprint_and_index(element, num_buckets, bits_per_item)
b1 = :array.get(i1, buckets)
case Bucket.find(b1, fingerprint) do
{:ok, index} ->
updated_bucket = Bucket.reset(b1, index)
{:ok, %{filter | buckets: :array.set(i1, updated_bucket, buckets)}}
{:error, :inexistent} ->
i2 = alt_index(i1, fingerprint, num_buckets)
b2 = :array.get(i2, buckets)
case Bucket.find(b2, fingerprint) do
{:ok, index} ->
updated_bucket = Bucket.reset(b2, index)
{:ok, %{filter | buckets: :array.set(i2, updated_bucket, buckets)}}
{:error, :inexistent} ->
{:error, :inexistent}
end
end
end
@doc """
Returns a filter with the inserted element or raises `Cuckoo.Error` if an error occurs.
"""
@spec insert!(Cuckoo.t(), any) :: Cuckoo.t() | no_return
def insert!(filter, element) do
case insert(filter, element) do
{:ok, filter} ->
filter
{:error, reason} ->
raise Cuckoo.Error, reason: reason, action: "insert element", element: element
end
end
@doc """
Returns a filter with the removed element or raises `Cuckoo.Error` if an error occurs.
"""
@spec delete!(Cuckoo.t(), any) :: Cuckoo.t() | no_return
def delete!(filter, element) do
case delete(filter, element) do
{:ok, filter} ->
filter
{:error, reason} ->
raise Cuckoo.Error, reason: reason, action: "delete element", element: element
end
end
# private helper functions
@spec kickout(Cuckoo.t(), non_neg_integer, pos_integer, pos_integer, pos_integer) ::
{:ok, Cuckoo.t()} | {:error, :full}
defp kickout(filter, index, fingerprint, fingerprints_per_bucket, current_kick \\ @max_kicks)
defp kickout(_, _, _, _, 0), do: {:error, :full}
defp kickout(
%Cuckoo{buckets: buckets} = filter,
index,
fingerprint,
fingerprints_per_bucket,
current_kick
) do
bucket = :array.get(index, buckets)
# randomly select an entry from the bucket
rand = :rand.uniform(fingerprints_per_bucket) - 1
# withdraw its fingerprint
old_fingerprint = Bucket.get(bucket, rand)
# replace it
bucket = Bucket.set(bucket, rand, fingerprint)
buckets = :array.set(index, bucket, buckets)
# find a place to put the old fingerprint
fingerprint = old_fingerprint
num_buckets = :array.size(buckets)
index = alt_index(index, fingerprint, num_buckets)
bucket = :array.get(index, buckets)
case Bucket.has_room?(bucket) do
{:ok, b_index} ->
bucket = Bucket.set(bucket, b_index, fingerprint)
buckets = :array.set(index, bucket, buckets)
{:ok, %{filter | buckets: buckets}}
{:error, :full} ->
kickout(
%{filter | buckets: buckets},
index,
fingerprint,
fingerprints_per_bucket,
current_kick - 1
)
end
end
@spec gen_index(pos_integer, pos_integer) :: non_neg_integer
defp gen_index(hash, num_buckets) do
rem(hash, num_buckets)
end
@spec fingerprint(pos_integer, pos_integer) :: pos_integer
defp fingerprint(hash, bits_per_item) do
hash &&& (1 <<< bits_per_item) - 1
end
# calculates the smallest power of 2 greater than or equal to n
@spec upper_power_2(float) :: pos_integer
defp upper_power_2(n) do
2
|> :math.pow(Float.ceil(log2(n)))
|> trunc
end
@spec log2(float) :: float
defp log2(n) do
:math.log(n) / :math.log(2)
end
@spec hash1(any) :: pos_integer
defp hash1(element), do: Murmur.hash_x64_128(element)
@spec hash2(pos_integer) :: pos_integer
defp hash2(fingerprint), do: :erlang.phash2(fingerprint)
@spec fingerprint_and_index(any, pos_integer, pos_integer) :: {pos_integer, non_neg_integer}
defp fingerprint_and_index(element, num_buckets, bits_per_item) do
hash = hash1(element)
fingerprint = fingerprint(hash, bits_per_item)
index = gen_index(hash >>> 32, num_buckets)
{fingerprint, index}
end
@spec alt_index(non_neg_integer, pos_integer, pos_integer) :: non_neg_integer
defp alt_index(i1, fingerprint, num_buckets) do
i1 ^^^ gen_index(hash2(fingerprint), num_buckets)
end
end