Skip to content

Commit

Permalink
Extra config to set custom machine ID and start time (#1)
Browse files Browse the repository at this point in the history
Changes in this PR:
* Config validation helpers
* Add options to set machine_id and start_time
* Bump version to 0.2.0
  • Loading branch information
elciok authored Apr 4, 2024
1 parent dc54e11 commit 2d2c3c0
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 13 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Add `sonyflakex` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:sonyflakex, "~> 0.1.0"}
{:sonyflakex, "~> 0.2.0"}
]
end
```
Expand Down Expand Up @@ -72,7 +72,6 @@ If you need to generate a higher volume of IDs in short periods of time, then yo

## Pending

- [ ] Configuration options to set start_time and machine_id.
- [ ] Callback to check machine ID is unique.

## License
Expand Down
41 changes: 38 additions & 3 deletions lib/sonyflakex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,42 @@ defmodule Sonyflakex do

alias Sonyflakex.{State, Generator, Time}

def start_link(args) do
@doc """
Starts GenServer process that generates
Sonyflake IDs.
Options:
- start_time: UNIX timestamp used as starting point
for other timestamps used to compose IDs
- machine_id: integer that identifies the machine
generating IDs. It is also part of the ID
so it should fit in 16 bits.
Returns:
- `{:ok, pid}`: In case process is started successfully, it returns a tuple containing an ID.
- `{:error, error_detail}`: If the process can't be started due to invalid configuration options
it will return an error tuple containing details about the validation error.
An example of setting configuration options in an application:application:
```elixir
defmodule MyApp do
use Application
@impl Application
def start(_type, _args) do
children = [
{Sonyflakex, machine_id: 33, start_time: 1712269128},
# other dependencies
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
```
"""
@spec start_link(keyword()) :: :ignore | {:error, any()} | {:ok, pid()}
def start_link(args \\ []) do
GenServer.start_link(
__MODULE__,
args,
Expand All @@ -15,8 +50,8 @@ defmodule Sonyflakex do
end

@impl GenServer
def init(_args) do
{:ok, State.new()}
def init(opts) do
State.new(opts)
end

@impl GenServer
Expand Down
85 changes: 85 additions & 0 deletions lib/sonyflakex/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
defmodule Sonyflakex.Config do
@moduledoc """
Configuration validation helpers
"""

@doc """
Sets default value for an option if option is not set
in option list. Default value is set from a callback.
Args:
- opts: Configuration options.
- option_name: Key for configuration option being validated.
- default_callback: Function that returns a default value.
"""
@spec set_default(keyword(), atom(), (-> any())) :: {:ok, keyword()}
def set_default(opts, option_name, default_callback) do
case Keyword.fetch(opts, option_name) do
{:ok, _value} ->
{:ok, opts}

:error ->
new_opts = Keyword.put(opts, option_name, default_callback.())
{:ok, new_opts}
end
end

@doc """
Validates option value is an integer. This is checked only
if value is set.
"""
@spec validate_is_integer(keyword(), atom()) ::
{:error, {:non_integer, atom(), any()}} | {:ok, keyword()}
def validate_is_integer(opts, option_name) do
case Keyword.fetch(opts, option_name) do
{:ok, value} when is_integer(value) ->
{:ok, opts}

{:ok, value} ->
{:error, {:non_integer, option_name, value}}

:error ->
{:ok, opts}
end
end

@doc ~S"""
Validates option value integer can be set as a binary
in a field with a limited number of bits.
Args:
- opts: Configuration options
- option_name: Key for configuration option being validated.
- max_bits: Maximum number of bits that would fit the option value.
"""
@spec validate_bit_option_length(keyword(), atom(), non_neg_integer()) ::
{:error, {:value_too_big, atom(), integer()}} | {:ok, keyword()}
def validate_bit_option_length(opts, option_name, max_bits) do
case Keyword.fetch(opts, option_name) do
{:ok, value} ->
if value_fits_in_bits(value, max_bits) do
{:ok, opts}
else
{:error, {:value_too_big, option_name, value}}
end

:error ->
{:ok, opts}
end
end

@doc ~S"""
Checks if integer value binary representatino would fit in a number of bits.
## Examples
iex> Sonyflakex.Config.value_fits_in_bits(255, 8)
true
"""
@spec value_fits_in_bits(integer(), non_neg_integer()) :: boolean()
def value_fits_in_bits(value, length_bits) do
<<max::integer-size(length_bits + 1)>> = <<1::size(1), 0::size(length_bits)>>
value < max
end
end
38 changes: 31 additions & 7 deletions lib/sonyflakex/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ defmodule Sonyflakex.State do
import Sonyflakex.Time
import Sonyflakex.IpAddress

alias Sonyflakex.Config

@bits_time 39
@bits_sequence 8
@bits_machine_id 16

@mask_sequence (1 <<< @bits_sequence) - 1

@option_start_time :start_time
@option_machine_id :machine_id

@typedoc """
Represents internal ID generator state used to compute the next ID.
Expand Down Expand Up @@ -44,13 +49,32 @@ defmodule Sonyflakex.State do
machine this field will be set to the same value and duplicated
IDs might be generated.
"""
@spec new() :: t()
def new() do
# TODO: allow user to customize start_time, machine_id and checking machine_id uniqueness
start_time = default_epoch()
machine_id = lower_16_bit_ip_address(first_private_ipv4())
sequence = (1 <<< @bits_sequence) - 1
create_state(start_time, 0, machine_id, sequence)
@spec new(keyword()) :: {:ok, t()} | {:error, any()}
def new(opts) do
case validate_opts(opts) do
{:ok, opts} ->
start_time = Keyword.fetch!(opts, @option_start_time)
machine_id = Keyword.fetch!(opts, @option_machine_id)
sequence = (1 <<< @bits_sequence) - 1
{:ok, create_state(start_time, 0, machine_id, sequence)}

error ->
error
end
end

defp validate_opts(opts) do
with {:ok, opts} <- Config.set_default(opts, @option_start_time, &default_epoch/0),
{:ok, opts} <- Config.validate_is_integer(opts, @option_start_time),
{:ok, opts} <-
Config.set_default(opts, @option_machine_id, fn ->
lower_16_bit_ip_address(first_private_ipv4())
end),
{:ok, opts} <- Config.validate_is_integer(opts, @option_machine_id),
{:ok, opts} <-
Config.validate_bit_option_length(opts, @option_machine_id, @bits_machine_id) do
{:ok, opts}
end
end

@doc ~S"""
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Sonyflakex.MixProject do
use Mix.Project

@source_url "https://github.com/elciok/sonyflakex"
@version "0.1.0"
@version "0.2.0"

def project do
[
Expand Down
56 changes: 56 additions & 0 deletions test/sonyflakex/config_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Sonyflakex.ConfigTest do
use ExUnit.Case, async: true
doctest Sonyflakex.Config

alias Sonyflakex.Config

describe "set_default/3" do
test "sets integer value from callback function return if option is not set" do
{:ok, opts} = Config.set_default([age: 42], :size, fn -> 200 end)
assert {:ok, 200} = Keyword.fetch(opts, :size)
end

test "doesn't change option value if option is set" do
{:ok, opts} = Config.set_default([age: 42], :age, fn -> 200 end)
assert {:ok, 42} = Keyword.fetch(opts, :age)
end
end

describe "validate_is_integer/2" do
test "returns ok if value is not set" do
assert {:ok, []} = Config.validate_is_integer([], :size)
end

test "returns ok if value set is integer" do
assert {:ok, [size: 170]} = Config.validate_is_integer([size: 170], :size)
end

test "returns error if value set is not integer" do
assert {:error, {:non_integer, :size, :not_an_integer}} =
Config.validate_is_integer([size: :not_an_integer], :size)
end
end

describe "value_fits_in_bits/2" do
test "checks input value fits input number of bits" do
assert Config.value_fits_in_bits(1, 1) == true
assert Config.value_fits_in_bits(2, 1) == false
assert Config.value_fits_in_bits(2, 2) == true
assert Config.value_fits_in_bits(0, 1) == true
assert Config.value_fits_in_bits(0b10000000, 7) == false
assert Config.value_fits_in_bits(0b10000000, 8) == true
end
end

describe "validate_bit_option_length/3" do
test "keeps option with input value if is valid" do
{:ok, opts} = Config.validate_bit_option_length([age: 42], :age, 8)
assert {:ok, 42} = Keyword.fetch(opts, :age)
end

test "indicates validation error if input integer value can't fit in the max number of bits" do
{:error, which} = Config.validate_bit_option_length([age: 42], :age, 3)
assert which == {:value_too_big, :age, 42}
end
end
end
14 changes: 14 additions & 0 deletions test/sonyflakex_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ defmodule SonyflakexTest do
use ExUnit.Case
doctest Sonyflakex

describe "start_link/1" do
test "set custom options" do
{:ok, pid} = Sonyflakex.start_link(machine_id: 8, start_time: 3100)
{start_time, _, machine_id, _} = :sys.get_state(pid)
assert start_time == 3100
assert machine_id == 8
end

test "returns validation error if option value is not valid" do
assert {:error, {:value_too_big, :machine_id, 900_000}} =
Sonyflakex.start_link(machine_id: 900_000)
end
end

describe "next_id/0" do
test "generates a new id" do
{:ok, _pid} = Sonyflakex.start_link([])
Expand Down

0 comments on commit 2d2c3c0

Please sign in to comment.