From 5ea48738f3958480acbdb98609f16e88dd34c140 Mon Sep 17 00:00:00 2001 From: Ryan Winchester Date: Sat, 25 Apr 2026 22:45:48 -0300 Subject: [PATCH] simplify UUIDv7 options --- lib/ecto/application.ex | 1 - lib/ecto/uuid.ex | 81 +++++++++++++++-------------------------- test/ecto/uuid_test.exs | 54 ++++++++++++++++++--------- 3 files changed, 66 insertions(+), 70 deletions(-) diff --git a/lib/ecto/application.ex b/lib/ecto/application.ex index bfe0be0e5e..a4b5701d77 100644 --- a/lib/ecto/application.ex +++ b/lib/ecto/application.ex @@ -3,7 +3,6 @@ defmodule Ecto.Application do use Application def start(_type, _args) do - :ok = :persistent_term.put({Ecto.UUID, :millisecond}, :atomics.new(1, signed: false)) :ok = :persistent_term.put({Ecto.UUID, :nanosecond}, :atomics.new(1, signed: false)) children = [ diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index 778feac964..5473972eac 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -19,7 +19,7 @@ defmodule Ecto.UUID do To use UUID v7 (time-ordered) monotonic: use Ecto.Schema - @primary_key {:id, Ecto.UUID, autogenerate: [version: 7, monotonic: true]} + @primary_key {:id, Ecto.UUID, autogenerate: [version: 7, precision: :monotonic]} According to [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562#name-monotonicity-and-counters): "Monotonicity (each subsequent value being greater than the last) is the @@ -39,12 +39,11 @@ defmodule Ecto.UUID do @type raw :: <<_::128>> @typedoc """ - Supported options: `:version`, `:precision` (v7-only), and `:monotonic` (v7-only). + Supported options: `:version` and `:precision` (v7-only). """ @type option :: {:version, 4 | 7} - | {:precision, :millisecond | :nanosecond} - | {:monotonic, boolean()} + | {:precision, :millisecond | :monotonic} @type options :: [option] @@ -233,24 +232,15 @@ defmodule Ecto.UUID do ## Options (version 7 only) - * `:precision` - The timestamp precision for version 7 UUIDs. Supported values - are `:millisecond` and `:nanosecond`. Defaults to `:millisecond` if - monotonic is `false` and `:nanosecond` if `:monotonic` is `true`. - When using `:nanosecond`, the sub-millisecond precision is encoded in the - `rand_a` field. NOTE: Due to the 12-bit space available, nanosecond - precision is limited to 4096 (2^12) distinct values per millisecond. - - * `:monotonic` - When `true`, ensures that generated version 7 UUIDs are - strictly monotonically increasing, even when multiple UUIDs are generated - within the same timestamp. This is useful for maintaining insertion order - in databases. Defaults to `false`. - NOTE: With `:millisecond` precision, generating multiple UUIDs within the - same millisecond increments the timestamp by 1ms for each UUID, causing the - embedded timestamp to drift ahead of real time under high throughput. - Using `precision: :nanosecond` reduces this drift significantly, as - timestamps only advance by 244ns per UUID when generation outpaces real - time. When monotonic UUIDs are desired, it is recommended to also use - `precision: :nanosecond`. + * `:precision` - The timestamp precision for version 7 UUIDs. Supported + values are `:millisecond` and `:monotonic`. Defaults to `:millisecond`. + + > #### Monotonic precision {: .info} + > + > When using `:monotonic`, sub-millisecond precision is encoded in the + > `rand_a` field. The generated version 7 UUIDs are strictly monotonically + > increasing (per node), even when multiple UUIDs are generated within the same + > timestamp. This is useful for maintaining insertion order in databases. ## Examples @@ -260,10 +250,10 @@ defmodule Ecto.UUID do > Ecto.UUID.generate(version: 7) "018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a" - > Ecto.UUID.generate(version: 7, precision: :nanosecond) + > Ecto.UUID.generate(version: 7, precision: :millisecond) "018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a" - > Ecto.UUID.generate(version: 7, monotonic: true) + > Ecto.UUID.generate(version: 7, precision: :monotonic) "018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a" """ @@ -291,7 +281,7 @@ defmodule Ecto.UUID do end # The bits available for sub-millisecond fractions when using increased clock - # precision based on nanoseconds. + # precision for monotonicity (based on nanoseconds). @ns_sub_ms_bits 12 # The number of values that can be represented in the bit space (2^12). @ns_possible_values Bitwise.bsl(1, @ns_sub_ms_bits) @@ -302,22 +292,17 @@ defmodule Ecto.UUID do @ns_minimal_step div(@ns_per_ms, @ns_possible_values) defp bingenerate_v7(opts) do - monotonic = Keyword.get(opts, :monotonic, false) - time_unit = Keyword.get(opts, :precision, if(monotonic, do: :nanosecond, else: :millisecond)) - - timestamp = - case monotonic do - true -> next_ascending(time_unit) - false -> System.system_time(time_unit) - monotonic -> raise ArgumentError, "invalid monotonic value: #{inspect(monotonic)}" - end + {precision, rest} = Keyword.pop(opts, :precision, :millisecond) + if rest != [], do: raise(ArgumentError, "unsupported options for v7: #{inspect(rest)}") - case time_unit do + case precision do :millisecond -> + timestamp = System.system_time(:millisecond) <> = :crypto.strong_rand_bytes(10) <> - :nanosecond -> + :monotonic -> + timestamp = next_ascending() milliseconds = div(timestamp, @ns_per_ms) clock_precision = @@ -326,39 +311,33 @@ defmodule Ecto.UUID do <<_::2, rand_b::62>> = :crypto.strong_rand_bytes(8) <> - time_unit -> - raise ArgumentError, "unsupported precision: #{inspect(time_unit)}" + precision -> + raise ArgumentError, "unsupported precision: #{inspect(precision)}" end end - defp next_ascending(time_unit) when time_unit in [:millisecond, :nanosecond] do + defp next_ascending do timestamp_ref = - :persistent_term.get({__MODULE__, time_unit}, nil) || raise "Ecto has not been started" - - step = - case time_unit do - :millisecond -> 1 - :nanosecond -> @ns_minimal_step - end + :persistent_term.get({__MODULE__, :nanosecond}, nil) || raise "Ecto has not been started" previous_ts = :atomics.get(timestamp_ref, 1) - min_step_ts = previous_ts + step - current_ts = System.system_time(time_unit) + min_step_ts = previous_ts + @ns_minimal_step + current_ts = System.system_time(:nanosecond) # If the current timestamp is not at least the minimal step greater than the # previous step, then we make it so. new_ts = max(current_ts, min_step_ts) - compare_exchange(timestamp_ref, previous_ts, new_ts, step) + compare_exchange(timestamp_ref, previous_ts, new_ts) end - defp compare_exchange(timestamp_ref, previous_ts, new_ts, step) do + defp compare_exchange(timestamp_ref, previous_ts, new_ts) do case :atomics.compare_exchange(timestamp_ref, 1, previous_ts, new_ts) do # If the new value was written, then we return it. :ok -> new_ts # Otherwise, the atomic value has changed in the meantime. We add the # minimal step value to that and try again. - updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + step, step) + updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + @ns_minimal_step) end end diff --git a/test/ecto/uuid_test.exs b/test/ecto/uuid_test.exs index 096d84f897..b50cc1409c 100644 --- a/test/ecto/uuid_test.exs +++ b/test/ecto/uuid_test.exs @@ -61,6 +61,25 @@ defmodule Ecto.UUIDTest do end end + test "bingenerate returns 16-byte binary with correct v4 version and variant bits" do + assert <<_::48, 4::4, _::12, 2::2, _::62>> = Ecto.UUID.bingenerate() + end + + test "bingenerate v7 returns 16-byte binary with correct version and variant bits" do + assert <<_::48, 7::4, _::12, 2::2, _::62>> = Ecto.UUID.bingenerate(version: 7) + end + + test "bingenerate v7 with precision: :monotonic returns correct version and variant bits" do + assert <<_::48, 7::4, _::12, 2::2, _::62>> = + Ecto.UUID.bingenerate(version: 7, precision: :monotonic) + end + + test "generate with invalid version raises an ArgumentError" do + assert_raise ArgumentError, ~r/unsupported UUID version/, fn -> + Ecto.UUID.generate(version: 99) + end + end + test "generate returns valid uuid_v4" do assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> = Ecto.UUID.generate() end @@ -70,13 +89,13 @@ defmodule Ecto.UUIDTest do Ecto.UUID.generate(version: 4) end - test "generate v4 with precision or monotonic raises an ArgumentError" do - assert_raise ArgumentError, fn -> + test "generate v4 with precision raises an ArgumentError" do + assert_raise ArgumentError, ~r/unsupported options for v4/, fn -> Ecto.UUID.generate(precision: :millisecond) end - assert_raise ArgumentError, fn -> - Ecto.UUID.generate(version: 4, monotonic: true) + assert_raise ArgumentError, ~r/unsupported options for v4/, fn -> + Ecto.UUID.generate(version: 4, precision: :monotonic) end end @@ -85,6 +104,11 @@ defmodule Ecto.UUIDTest do Ecto.UUID.generate(version: 7) end + test "generate v7 returns valid uuid_v7 with precision: :millisecond" do + assert <<_::64, ?-, _::32, ?-, ?7, _::24, ?-, _::32, ?-, _::96>> = + Ecto.UUID.generate(version: 7, precision: :millisecond) + end + test "generate v7 maintains time-based sortability across milliseconds" do uuid1 = Ecto.UUID.generate(version: 7) Process.sleep(1) @@ -92,29 +116,23 @@ defmodule Ecto.UUIDTest do assert uuid1 < uuid2 end - test "generate v7 with precision: :millisecond, monotonic: true maintains sortability" do - uuids = - for _ <- 0..5_000, - do: Ecto.UUID.generate(version: 7, precision: :millisecond, monotonic: true) - - assert uuids == Enum.sort(uuids) - end - - test "generate v7 with precision: :nanosecond, monotonic: true maintains sortability" do + test "generate v7 with precision: :monotonic maintains sortability" do uuids = for _ <- 0..20_000, - do: Ecto.UUID.generate(version: 7, precision: :nanosecond, monotonic: true) + do: Ecto.UUID.generate(version: 7, precision: :monotonic) assert uuids == Enum.sort(uuids) end - test "generate v7 with invalid precision or monotonic raises an ArgumentError" do - assert_raise ArgumentError, fn -> + test "generate v7 with invalid precision raises an ArgumentError" do + assert_raise ArgumentError, ~r/unsupported precision/, fn -> Ecto.UUID.generate(version: 7, precision: :foo) end + end - assert_raise ArgumentError, fn -> - Ecto.UUID.generate(version: 7, monotonic: :bar) + test "generate v7 with invalid opts raises an ArgumentError" do + assert_raise ArgumentError, ~r/unsupported options for v7/, fn -> + Ecto.UUID.generate(version: 7, precision: :monotonic, foo: :bar) end end end