Packages
ferricstore
0.3.1
0.11.14
0.11.12
0.11.11
0.11.10
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.2.0
0.1.0
FerricFlow durable workflows and queues with native-protocol storage, Raft durability, and Bitcask persistence.
Current section
Files
Jump to
Current section
Files
lib/ferricstore/commands/bitmap.ex
defmodule Ferricstore.Commands.Bitmap do
alias Ferricstore.Store.Ops
@moduledoc """
Handles Redis bitmap commands: SETBIT, GETBIT, BITCOUNT, BITPOS, BITOP.
Each handler takes the uppercased command name, a list of string arguments,
and an injected store map. Returns plain Elixir terms — the connection layer
handles RESP encoding.
Bitmap commands operate on string values at the bit level. Bits are numbered
from the most significant bit (MSB) of the first byte: bit 0 is the MSB of
byte 0 (value 128), bit 7 is the LSB of byte 0 (value 1), bit 8 is the MSB
of byte 1, and so on. This matches Redis bit ordering.
Since FerricStore uses an append-only Bitcask storage engine, all write
operations (SETBIT, BITOP) perform a read-modify-write cycle.
## Supported commands
* `SETBIT key offset value` — set or clear the bit at `offset`; returns old bit
* `GETBIT key offset` — returns the bit value at `offset`
* `BITCOUNT key [start end [BYTE|BIT]]` — count set bits in a range
* `BITPOS key bit [start [end [BYTE|BIT]]]` — find first 0 or 1 bit
* `BITOP operation destkey key [key ...]` — bitwise AND/OR/XOR/NOT
"""
import Bitwise
# Redis limits bit offset to 2^32 - 1 (512MB value max)
@max_bit_offset 4_294_967_295
@doc """
Handles a bitmap command.
## Parameters
- `cmd` - Uppercased command name (e.g. `"SETBIT"`, `"GETBIT"`)
- `args` - List of string arguments
- `store` - Injected store map with `get`, `put` callbacks
## Returns
Plain Elixir term: integer, or `{:error, message}`.
"""
@spec handle(binary(), [binary()], map()) :: term()
def handle(cmd, args, store)
# ---------------------------------------------------------------------------
# SETBIT key offset value
# ---------------------------------------------------------------------------
def handle("SETBIT", [key, offset_str, bit_str], store) do
with {:ok, offset} <- parse_non_negative_integer(offset_str, "bit offset"),
:ok <- check_bit_offset(offset),
{:ok, bit_val} <- parse_bit_value(bit_str) do
current = Ops.get(store, key) || <<>>
byte_index = div(offset, 8)
# Extend the binary with zero bytes if needed
extended = extend_binary(current, byte_index + 1)
# Read the old bit
old_byte = :binary.at(extended, byte_index)
bit_position = 7 - rem(offset, 8)
old_bit = (old_byte >>> bit_position) &&& 1
# Set the new bit
new_byte =
case bit_val do
1 -> old_byte ||| (1 <<< bit_position)
0 -> old_byte &&& Bitwise.bnot(1 <<< bit_position)
end
# Replace the byte in the binary
<<prefix::binary-size(byte_index), _old::8, suffix::binary>> = extended
new_value = <<prefix::binary, new_byte::8, suffix::binary>>
Ops.put(store, key, new_value, 0)
old_bit
end
end
def handle("SETBIT", _args, _store) do
{:error, "ERR wrong number of arguments for 'setbit' command"}
end
# ---------------------------------------------------------------------------
# GETBIT key offset
# ---------------------------------------------------------------------------
def handle("GETBIT", [key, offset_str], store) do
with {:ok, offset} <- parse_non_negative_integer(offset_str, "bit offset"),
:ok <- check_bit_offset(offset) do
current = Ops.get(store, key) || <<>>
byte_index = div(offset, 8)
if byte_index >= byte_size(current) do
0
else
byte = :binary.at(current, byte_index)
bit_position = 7 - rem(offset, 8)
(byte >>> bit_position) &&& 1
end
end
end
def handle("GETBIT", _args, _store) do
{:error, "ERR wrong number of arguments for 'getbit' command"}
end
# ---------------------------------------------------------------------------
# BITCOUNT key [start end [BYTE|BIT]]
# ---------------------------------------------------------------------------
def handle("BITCOUNT", [key], store) do
current = Ops.get(store, key) || <<>>
popcount(current)
end
def handle("BITCOUNT", [key, start_str, end_str | rest], store) do
mode = parse_bitcount_mode(rest)
with {:ok, mode} <- mode,
{:ok, start_idx} <- parse_integer(start_str),
{:ok, end_idx} <- parse_integer(end_str) do
current = Ops.get(store, key) || <<>>
case mode do
:byte -> bitcount_byte_range(current, start_idx, end_idx)
:bit -> bitcount_bit_range(current, start_idx, end_idx)
end
end
end
def handle("BITCOUNT", [], _store) do
{:error, "ERR wrong number of arguments for 'bitcount' command"}
end
def handle("BITCOUNT", [_key, _start], _store) do
{:error, "ERR syntax error"}
end
# ---------------------------------------------------------------------------
# BITPOS key bit [start [end [BYTE|BIT]]]
# ---------------------------------------------------------------------------
def handle("BITPOS", [key, bit_str], store) do
with {:ok, bit_val} <- parse_bit_value(bit_str) do
current = Ops.get(store, key) || <<>>
bitpos_byte_range(current, bit_val, 0, byte_size(current) - 1, false)
end
end
def handle("BITPOS", [key, bit_str, start_str], store) do
with {:ok, bit_val} <- parse_bit_value(bit_str),
{:ok, start_idx} <- parse_integer(start_str) do
current = Ops.get(store, key) || <<>>
len = byte_size(current)
start_resolved = resolve_index(start_idx, len)
bitpos_byte_range(current, bit_val, start_resolved, len - 1, false)
end
end
def handle("BITPOS", [key, bit_str, start_str, end_str | rest], store) do
mode = parse_bitcount_mode(rest)
with {:ok, mode} <- mode,
{:ok, bit_val} <- parse_bit_value(bit_str),
{:ok, start_idx} <- parse_integer(start_str),
{:ok, end_idx} <- parse_integer(end_str) do
current = Ops.get(store, key) || <<>>
case mode do
:byte ->
len = byte_size(current)
s = resolve_index(start_idx, len)
e = resolve_index(end_idx, len)
bitpos_byte_range(current, bit_val, s, e, true)
:bit ->
total_bits = byte_size(current) * 8
s = resolve_index(start_idx, total_bits)
e = resolve_index(end_idx, total_bits)
bitpos_bit_range(current, bit_val, s, e)
end
end
end
def handle("BITPOS", [], _store) do
{:error, "ERR wrong number of arguments for 'bitpos' command"}
end
def handle("BITPOS", [_key], _store) do
{:error, "ERR wrong number of arguments for 'bitpos' command"}
end
# ---------------------------------------------------------------------------
# BITOP operation destkey key [key ...]
# ---------------------------------------------------------------------------
def handle("BITOP", [op_str, destkey | source_keys], store) when source_keys != [] do
op = String.upcase(op_str)
with {:ok, result} <- execute_bitop(op, source_keys, store) do
Ops.put(store, destkey, result, 0)
byte_size(result)
end
end
def handle("BITOP", _args, _store) do
{:error, "ERR wrong number of arguments for 'bitop' command"}
end
# ===========================================================================
# Private helpers
# ===========================================================================
# --- Parsing helpers -------------------------------------------------------
defp check_bit_offset(offset) when offset > @max_bit_offset do
{:error, "ERR bit offset is not an integer or out of range"}
end
defp check_bit_offset(_offset), do: :ok
@spec parse_non_negative_integer(binary(), binary()) :: {:ok, non_neg_integer()} | {:error, binary()}
defp parse_non_negative_integer(str, label) do
case Integer.parse(str) do
{n, ""} when n >= 0 -> {:ok, n}
{_n, ""} -> {:error, "ERR #{label} is not an integer or out of range"}
_ -> {:error, "ERR #{label} is not an integer or out of range"}
end
end
@spec parse_integer(binary()) :: {:ok, integer()} | {:error, binary()}
defp parse_integer(str) do
case Integer.parse(str) do
{n, ""} -> {:ok, n}
_ -> {:error, "ERR value is not an integer or out of range"}
end
end
@spec parse_bit_value(binary()) :: {:ok, 0 | 1} | {:error, binary()}
defp parse_bit_value("0"), do: {:ok, 0}
defp parse_bit_value("1"), do: {:ok, 1}
defp parse_bit_value(_) do
{:error, "ERR bit is not an integer or out of range"}
end
@spec parse_bitcount_mode([binary()]) :: {:ok, :byte | :bit} | {:error, binary()}
defp parse_bitcount_mode([]), do: {:ok, :byte}
defp parse_bitcount_mode([mode_str]) do
case String.upcase(mode_str) do
"BYTE" -> {:ok, :byte}
"BIT" -> {:ok, :bit}
_ -> {:error, "ERR syntax error"}
end
end
defp parse_bitcount_mode(_), do: {:error, "ERR syntax error"}
# --- Binary manipulation --------------------------------------------------
@spec extend_binary(binary(), non_neg_integer()) :: binary()
defp extend_binary(bin, min_size) when byte_size(bin) >= min_size, do: bin
defp extend_binary(bin, min_size) do
padding_size = min_size - byte_size(bin)
<<bin::binary, 0::size(padding_size * 8)>>
end
@spec pad_binary(binary(), non_neg_integer()) :: binary()
defp pad_binary(bin, target_size) when byte_size(bin) >= target_size, do: bin
defp pad_binary(bin, target_size) do
padding = target_size - byte_size(bin)
<<bin::binary, 0::size(padding * 8)>>
end
# --- Popcount (count set bits) ---------------------------------------------
@spec popcount(binary()) :: non_neg_integer()
defp popcount(<<>>), do: 0
defp popcount(binary) do
for <<byte::8 <- binary>>, reduce: 0 do
acc -> acc + byte_popcount(byte)
end
end
@spec byte_popcount(byte()) :: non_neg_integer()
defp byte_popcount(byte) do
# Kernighan's bit counting: clear lowest set bit each iteration
do_byte_popcount(byte, 0)
end
defp do_byte_popcount(0, count), do: count
defp do_byte_popcount(byte, count) do
do_byte_popcount(byte &&& (byte - 1), count + 1)
end
# --- BITCOUNT with byte range ----------------------------------------------
@spec bitcount_byte_range(binary(), integer(), integer()) :: non_neg_integer()
defp bitcount_byte_range(<<>>, _start, _stop), do: 0
defp bitcount_byte_range(bin, start_idx, end_idx) do
len = byte_size(bin)
s = resolve_index(start_idx, len)
e = resolve_index(end_idx, len)
if s > e or s >= len or e < 0 do
0
else
s = max(s, 0)
e = min(e, len - 1)
slice_size = e - s + 1
<<_::binary-size(s), slice::binary-size(slice_size), _::binary>> = bin
popcount(slice)
end
end
# --- BITCOUNT with bit range -----------------------------------------------
@spec bitcount_bit_range(binary(), integer(), integer()) :: non_neg_integer()
defp bitcount_bit_range(<<>>, _start, _stop), do: 0
defp bitcount_bit_range(bin, start_idx, end_idx) do
total_bits = byte_size(bin) * 8
s = resolve_index(start_idx, total_bits)
e = resolve_index(end_idx, total_bits)
if s > e or s >= total_bits or e < 0 do
0
else
s = max(s, 0)
e = min(e, total_bits - 1)
s..e
|> Enum.count(fn bit_offset ->
byte_idx = div(bit_offset, 8)
bit_pos = 7 - rem(bit_offset, 8)
byte = :binary.at(bin, byte_idx)
((byte >>> bit_pos) &&& 1) == 1
end)
end
end
# --- Index resolution (supports negative indexing) -------------------------
@spec resolve_index(integer(), non_neg_integer()) :: integer()
defp resolve_index(idx, _len) when idx >= 0, do: idx
defp resolve_index(idx, len), do: len + idx
# --- BITPOS helpers --------------------------------------------------------
@spec bitpos_byte_range(binary(), 0 | 1, integer(), integer(), boolean()) :: integer()
defp bitpos_byte_range(<<>>, 1, _start, _stop, _explicit_end), do: -1
defp bitpos_byte_range(<<>>, 0, _start, _stop, _explicit_end), do: 0
defp bitpos_byte_range(bin, bit_val, start_byte, end_byte, explicit_end) do
len = byte_size(bin)
s = max(start_byte, 0)
e = min(end_byte, len - 1)
if s > e or s >= len do
# Redis: when searching for 0 without explicit end, virtual bits past
# the string are all zeros. Return length * 8 as the first virtual 0.
if bit_val == 0 and not explicit_end, do: len * 8, else: -1
else
bin
|> scan_bytes_for_bit(bit_val, s, e)
|> bitpos_not_found_fallback(bit_val, e, explicit_end)
end
end
# When the scan found a bit, return its position.
defp bitpos_not_found_fallback(pos, _bit_val, _end_byte, _explicit_end) when pos >= 0, do: pos
# When looking for 0 without an explicit end range, Redis considers the
# first bit past the string as a virtual 0 bit.
defp bitpos_not_found_fallback(-1, 0 = _bit_val, end_byte, false = _explicit_end) do
(end_byte + 1) * 8
end
defp bitpos_not_found_fallback(-1, _bit_val, _end_byte, _explicit_end), do: -1
@spec bitpos_bit_range(binary(), 0 | 1, integer(), integer()) :: integer()
defp bitpos_bit_range(bin, bit_val, start_bit, end_bit) do
total_bits = byte_size(bin) * 8
s = max(start_bit, 0)
e = min(end_bit, total_bits - 1)
if s > e or s >= total_bits do
-1
else
Enum.find(s..e, -1, fn bit_offset ->
byte_idx = div(bit_offset, 8)
bit_pos = 7 - rem(bit_offset, 8)
byte = :binary.at(bin, byte_idx)
((byte >>> bit_pos) &&& 1) == bit_val
end)
end
end
@spec scan_bytes_for_bit(binary(), 0 | 1, non_neg_integer(), non_neg_integer()) :: integer()
defp scan_bytes_for_bit(bin, bit_val, byte_from, byte_to) do
# Determine which byte value means "all target bits absent"
skip_byte = if bit_val == 1, do: 0x00, else: 0xFF
Enum.reduce_while(byte_from..byte_to, -1, fn byte_idx, _acc ->
byte = :binary.at(bin, byte_idx)
if byte == skip_byte do
{:cont, -1}
else
{:halt, byte_idx * 8 + first_bit_in_byte(byte, bit_val)}
end
end)
end
# Finds the position (0-7) of the first bit matching `bit_val` within a byte.
@spec first_bit_in_byte(byte(), 0 | 1) :: 0..7
defp first_bit_in_byte(byte, bit_val) do
Enum.find(0..7, fn bit_pos ->
((byte >>> (7 - bit_pos)) &&& 1) == bit_val
end)
end
# --- BITOP dispatch --------------------------------------------------------
@spec execute_bitop(binary(), [binary()], map()) :: {:ok, binary()} | {:error, binary()}
defp execute_bitop("NOT", [src_key], store) do
src = Ops.get(store, src_key) || <<>>
{:ok, bitop_not(src)}
end
defp execute_bitop("NOT", _keys, _store) do
{:error, "ERR BITOP NOT requires one and only one key"}
end
defp execute_bitop(op, source_keys, store) when op in ~w(AND OR XOR) do
values = Enum.map(source_keys, fn k -> Ops.get(store, k) || <<>> end)
max_len = values |> Enum.map(&byte_size/1) |> Enum.max()
padded = Enum.map(values, &pad_binary(&1, max_len))
result =
case op do
"AND" -> bitop_combine(padded, &Bitwise.band/2)
"OR" -> bitop_combine(padded, &Bitwise.bor/2)
"XOR" -> bitop_combine(padded, &Bitwise.bxor/2)
end
{:ok, result}
end
defp execute_bitop(_op, _keys, _store), do: {:error, "ERR syntax error"}
# --- BITOP helpers ---------------------------------------------------------
@spec bitop_not(binary()) :: binary()
defp bitop_not(bin) do
for <<byte::8 <- bin>>, into: <<>> do
<<(Bitwise.bnot(byte) &&& 0xFF)::8>>
end
end
@spec bitop_combine([binary()], (byte(), byte() -> byte())) :: binary()
defp bitop_combine([], _op_fn), do: <<>>
defp bitop_combine([first | rest], op_fn) do
Enum.reduce(rest, first, fn bin, acc ->
combine_binaries(acc, bin, op_fn)
end)
end
@spec combine_binaries(binary(), binary(), (byte(), byte() -> byte())) :: binary()
defp combine_binaries(<<>>, <<>>, _op_fn), do: <<>>
defp combine_binaries(a, b, op_fn) do
len = byte_size(a)
for i <- 0..(len - 1), into: <<>> do
<<op_fn.(:binary.at(a, i), :binary.at(b, i))::8>>
end
end
end