Current section
Files
Jump to
Current section
Files
lib/aphora/worker.ex
# Copyright 2019 Schicksal
#
# Licensed 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.
defmodule Aphora.Worker do
@moduledoc """
This module implements a `GenServer`, which can generate guaranteed unique `t:aphora_id/0` on demand.
"""
use GenServer
@datacenter_range 0..31
@worker_range 0..1_023
@timestamp_range 0..35_184_372_088_831
@counter_overflow 4_096
@counter_range 0..(@counter_overflow - 1)
def start_link(%{epoch: epoch, datacenter: datacenter, worker: worker} = state)
when datacenter in @datacenter_range and is_integer(datacenter) and
worker in @worker_range and is_integer(worker) do
state = state |> Map.put(:timestamp, timestamp(epoch)) |> Map.put(:counter, 0)
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
def init(state), do: {:ok, state}
def handle_call(:new_id, from, state) do
case valid_state?(state) do
{:ok, state} ->
{:reply, {:ok, build_id(state)}, state}
{:error, :counter_overflow} ->
Process.sleep(1)
handle_call(:new_id, from, state)
{:error, :reverted_tick} ->
{:reply, {:error, :reverted_tick}, state}
end
end
defp valid_state?(%{epoch: epoch, timestamp: timestamp} = state) do
case timestamp(epoch) do
^timestamp -> old_timestamp(state)
new_timestamp -> new_timestamp(state, new_timestamp)
end
end
@typedoc """
A `t:counter/0` is a `12 Bits` long identifier, which by default has a value of `0`.
It only increments, if two `t:aphora_id/0` are to be generated within the same `t:timestamp/0`.
This allows for a total of `4_096` `t:aphora_id/0` to be generated within the same `t:timestamp/0` on the same unique `t:Aphora.Config.worker/0` & `t:Aphora.Config.datacenter/0` combination.
"""
@typedoc since: "0.2.0"
@type counter() :: non_neg_integer()
defp old_timestamp(%{counter: counter} = state) do
case counter + 1 do
@counter_overflow -> {:error, :counter_overflow}
new_counter -> {:ok, state |> Map.put(:counter, new_counter)}
end
end
defp new_timestamp(%{timestamp: timestamp} = state, new_timestamp) do
if new_timestamp < timestamp do
{:error, :reverted_tick}
else
{:ok, state |> Map.put(:timestamp, new_timestamp) |> Map.put(:counter, 0)}
end
end
@typedoc """
A `t:aphora_id/0` is a `72 Bits` long unique & sortable identifier, which doesn't require any centralised coordination.
It consists out of:
* `45 Bits` `t:timestamp/0`.
* `5 Bits` `t:Aphora.Config.datacenter/0`.
* `10 Bits` `t:Aphora.Config.worker/0`.
* `12 Bits` `t:counter/0`.
"""
@typedoc since: "0.1.0"
@type aphora_id() :: String.t()
@doc """
Returns the unique `t:aphora_id/0`.
It first combines the binary representation of the information within the state into a `72 Bits` binary.
Afterwards it gets encoded using `Base.url_encode64/2` without a padding, so it can be easily used for any `URI`.
## Examples
iex> Aphora.Worker.build_id(%{datacenter: 2, worker: 981_207, timestamp: 912_988_800_000_000, counter: 423})
"AAM-W8UboAAAAg741wABpw"
"""
@doc since: "0.1.0"
@spec build_id(%{
datacenter: Aphora.Config.datacenter(),
worker: Aphora.Config.worker(),
timestamp: timestamp(),
counter: counter()
}) ::
aphora_id()
def build_id(
%{datacenter: datacenter, worker: worker, timestamp: timestamp, counter: counter} = _state
)
when datacenter in @datacenter_range and is_integer(datacenter) and
worker in @worker_range and is_integer(worker) and
timestamp in @timestamp_range and is_integer(timestamp) and
counter in @counter_range and is_integer(counter) do
<<
timestamp::integer-size(45),
datacenter::integer-size(5),
worker::integer-size(10),
counter::integer-size(12)
>>
|> Aphora.Helper.encode()
end
@typedoc """
A `t:timestamp/0` is a positive integer, which is equals to the time in which it was generated within `timestamp/1`.
It consists of `System.os_time/1` minus the `t:Aphora.Config.epoch/0`.
It has a total size of `45 Bits` within the `t:aphora_id/0` and a `millisecond` precision.
This allows for `35_184_372_088_832 millisecond` or around `1115` years of millisecond precision timestamps.
"""
@typedoc since: "0.1.0"
@type timestamp() :: non_neg_integer()
@doc """
Returns the `t:timestamp/0`.
It gets calculated by taking the current time `System.os_time/1` and substracting the `t:Aphora.Config.epoch/0` of it.
## Examples
iex> Aphora.Worker.timestamp(0) == System.os_time(:millisecond)
true
"""
@doc since: "0.1.0"
@spec timestamp(Aphora.Config.epoch()) :: timestamp()
def timestamp(epoch) when 0 <= epoch and is_integer(epoch),
do: System.os_time(:millisecond) - epoch
end