View Source Once (Once v0.0.5)

Once is an Ecto type for locally unique 64-bits IDs generated by multiple Elixir nodes. Locally unique IDs make it easier to keep things separated, simplify caching and simplify inserting related things (because you don't have to wait for the database to return the ID). A Once can be generated in multiple ways:

  • counter (default): really fast to generate, predictable, works well with b-tree indexes
  • encrypted: unique and unpredictable, like a UUIDv4 but shorter
  • sortable: time-sortable like a Snowflake ID

A Once can look however you want, and can be stored in multiple ways as well. By default, in Elixir it's a url64-encoded 11-char string, and in the database it's a signed bigint. By using the :ex_format and :db_format options, you can choose both the Elixir and storage format out of format/0. You can pick any combination and use to_format/2 to transform them as you wish!

Because a Once fits into an SQL bigint, they use little space and keep indexes small and fast. Because of their structure they have counter-like data locality, which helps your indexes perform well, unlike UUIDv4s. If you don't care about that and want unpredictable IDs, you can use encrypted IDs that seem random and are still unique.

The actual values are generated by NoNoncense, which performs incredibly well, hitting rates of tens of millions of nonces per second, and it also helps you to safeguard the uniqueness guarantees.

The library has only Ecto and its sibling NoNoncense as dependencies.

Usage

To get going, you need to set up a NoNoncense instance to generate the base unique values. Follow its documentation to do so. Once expects an instance with its own module name by default, like so:

# application.ex (read the NoNoncense docs!)
machine_id = NoNoncense.MachineId.id!(opts)
NoNoncense.init(name: Once, machine_id: machine_id)

In your Ecto schemas, you can then use the type:

schema "things" do
  field :id, Once
end

And that's it!

Options

The Ecto type takes a few optional parameters:

  • :no_noncense name of the NoNoncense instance used to generate new IDs (default Once)
  • :ex_format what an ID looks like in Elixir, one of format/0 (default :url64)
  • :db_format what an ID looks like in your database, one of format/0 (default :signed)
  • :type how the nonce is generated, one of nonce_type/0 (default :counter)
  • :get_key a zero-arity getter for the 192-bits encryption key, required if encryption is enabled
  • :encrypt? deprecated, use type: :encrypted (default false).

Data formats

There's a drawback to having different data formats for Elixir and SQL: it makes it harder to compare the two. The following are all the same ID:

-1
<<255, 255, 255, 255, 255, 255, 255, 255>>
"__________8"
18_446_744_073_709_551_615
"FFFFFFFFFFFFFFFF"

If you use the defaults :url64 as the Elixir format and :signed in your database, you could see "AAAAAACYloA" in Elixir and 10_000_000 in your database. The reasoning behind these defaults is that the encoded format is readable, short, and JSON safe by default, while the signed format means you can use a standard bigint column type.

The negative integers will not cause problems with Postgres and MySQL, they both happily swallow them. Also, negative integers will only start to appear after ~70 years of usage.

If you don't like the formats, it's really easy to change them! The Elixir format especially, which can be changed at any time. Be mindful of JSON limitations if you use integers.

The supported formats are:

  • :url64 a url64-encoded string of 11 characters, for example "AAjhfZyAAAE"
  • :hex a hex-encoded string of 16 characters, for example "E010831058218A39"
  • :raw a bitstring of 64 bits, for example <<0, 8, 225, 125, 156, 128, 0, 2>>
  • :signed a signed 64-bits integer, like -12345, between -(2^63) and 2^63-1
  • :unsigned an unsigned 64-bits integer, like 67890, between 0 and 2^64-1

On local uniqueness

By locally unique, we mean unique within your domain or application. UUIDs are globally unique across domains, servers and applications. A Once is not, because 64 bits is not enough to achieve that. It is enough for local uniqueness however: you can generate 8 million IDs per second on 512 machines in parallel for 140 years straight before you run out of bits, by which time your great-grandchildren will deal with the problem. Even higher burst rates are possible and you can use separate NoNoncense instanses for every table if you wish.

Encrypted IDs

By default, IDs are generated using a machine init timestamp, machine ID and counter (although they should be considered to be opague). This means they leak a little information and are somewhat predictable. If you don't like that, you can use encrypted IDs by passing options type: :encrypted and get_key: fn -> <<_::192>> end. Note that encrypted IDs will cost you the data locality and decrease index performance a little. The encryption algorithm is 3DES and that can't be changed. If you want to know why, take a look at NoNoncense.

Summary

Types

Formats in which a Once can be rendered. They are all equivalent and can be transformed to one another.

The way in which the underlying 64-bits nonce is generated.

Options to initialize Once.

Functions

Transform the different forms that a Once can take to one another. The formats can be found in format/0.

Same as to_format/2 but raises on error.

Types

format()

@type format() :: :url64 | :raw | :signed | :unsigned | :hex

Formats in which a Once can be rendered. They are all equivalent and can be transformed to one another.

  • :url64 a url64-encoded string of 11 characters, for example "AAjhfZyAAAE"
  • :hex a hex-encoded string of 16 characters, for example "E010831058218A39"
  • :raw a bitstring of 64 bits, for example <<0, 8, 225, 125, 156, 128, 0, 2>>
  • :signed a signed 64-bits integer, like -12345, between -(2^63) and 2^63-1
  • :unsigned an unsigned 64-bits integer, like 67890, between 0 and 2^64-1

nonce_type()

@type nonce_type() :: :counter | :encrypted | :sortable

The way in which the underlying 64-bits nonce is generated.

See NoNoncense for details.

opts()

@type opts() :: [
  no_noncense: module(),
  ex_format: format(),
  db_format: format(),
  encrypt?: boolean(),
  get_key: (-> <<_::24>>),
  type: nonce_type()
]

Options to initialize Once.

  • :no_noncense name of the NoNoncense instance used to generate new IDs (default Once)
  • :ex_format what an ID looks like in Elixir, one of format/0 (default :url64)
  • :db_format what an ID looks like in your database, one of format/0 (default :signed)
  • :type how the nonce is generated, one of nonce_type/0 (default :counter)
  • :get_key a zero-arity getter for the 192-bits encryption key, required if encryption is enabled
  • :encrypt? deprecated, use type: :encrypted (default false).

Functions

to_format(value, format)

@spec to_format(binary() | integer(), format()) ::
  {:ok, binary() | integer()} | :error

Transform the different forms that a Once can take to one another. The formats can be found in format/0.

iex> Once.to_format("4BCDEFghijk", :raw)
{:ok, <<224, 16, 131, 16, 88, 33, 138, 57>>}
iex> Once.to_format(<<224, 16, 131, 16, 88, 33, 138, 57>>, :signed)
{:ok, -2301195303365014983}
iex> Once.to_format(-2301195303365014983, :unsigned)
{:ok, 16145548770344536633}
iex> Once.to_format(16145548770344536633, :hex)
{:ok, "E010831058218A39"}
iex> Once.to_format("E010831058218a39", :url64)
{:ok, "4BCDEFghijk"}

iex> Once.to_format(-1, :url64)
{:ok, "__________8"}
iex> Once.to_format("__________8", :raw)
{:ok, <<255, 255, 255, 255, 255, 255, 255, 255>>}
iex> Once.to_format(<<255, 255, 255, 255, 255, 255, 255, 255>>, :unsigned)
{:ok, 18446744073709551615}
iex> Once.to_format(18446744073709551615, :hex)
{:ok, "FFFFFFFFFFFFFFFF"}
iex> Once.to_format("FFFFFFFFFFFFFFFF", :signed)
{:ok, -1}

iex> Once.to_format(Integer.pow(2, 64), :unsigned)
:error

to_format!(value, format)

@spec to_format!(binary() | integer(), format()) :: binary() | integer()

Same as to_format/2 but raises on error.

iex> -200
...> |> Once.to_format!(:url64)
...> |> Once.to_format!(:raw)
...> |> Once.to_format!(:unsigned)
...> |> Once.to_format!(:hex)
...> |> Once.to_format!(:signed)
-200

iex> Once.to_format!(Integer.pow(2, 64), :unsigned)
** (ArgumentError) value could not be parsed