Skip to content

Commit 6f7ff4d

Browse files
uuidv7 monotonicity
updating uuidv7 options Update Ecto.UUID.generate/1 docs add tests for uuidv7 monotonicity default UUIDv7 precision to nanosecond when monotonic
1 parent e6da70d commit 6f7ff4d

File tree

3 files changed

+150
-11
lines changed

3 files changed

+150
-11
lines changed

lib/ecto/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ defmodule Ecto.Application do
33
use Application
44

55
def start(_type, _args) do
6+
:ok = :persistent_term.put({Ecto.UUID, :millisecond}, :atomics.new(1, signed: false))
7+
:ok = :persistent_term.put({Ecto.UUID, :nanosecond}, :atomics.new(1, signed: false))
8+
69
children = [
710
Ecto.Repo.Registry
811
]

lib/ecto/uuid.ex

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,16 @@ defmodule Ecto.UUID do
3232
@typedoc """
3333
currently supported option is version, it accepts 4 or 7.
3434
"""
35-
@type options :: [version: 4 | 7]
35+
@type option ::
36+
{:version, 4 | 7}
37+
| {:precision, :millisecond | :nanosecond}
38+
| {:monotonic, boolean()}
39+
40+
@type options :: [option]
41+
42+
@version_4 4
43+
@version_7 7
44+
@variant 2
3645

3746
@doc false
3847
def type, do: :uuid
@@ -206,34 +215,145 @@ defmodule Ecto.UUID do
206215

207216
@default_version 4
208217
@doc """
209-
Generates a uuid with the given options.
218+
Generates a UUID string.
219+
220+
## Options
221+
222+
* `:version` - The UUID version to generate. Supported values are `4` (random)
223+
and `7` (time-ordered). Defaults to `4`.
224+
225+
## Options (version 7)
226+
227+
* `:precision` - The timestamp precision for version 7 UUIDs. Supported values
228+
are `:millisecond` and `:nanosecond`. Defaults to `:millisecond` if
229+
monotonic is `false` and `:nanosecond` if `:monotonic` is `true`.
230+
When using `:nanosecond`, the sub-millisecond precision is encoded in the
231+
`rand_a` field. NOTE: Due to the 12-bit space available, nanosecond
232+
precision is limited to 4096 (2^12) distinct values per millisecond.
233+
234+
* `:monotonic` - When `true`, ensures that generated version 7 UUIDs are
235+
strictly monotonically increasing, even when multiple UUIDs are generated
236+
within the same timestamp. This is useful for maintaining insertion order
237+
in databases. Defaults to `false`.
238+
NOTE: With `:millisecond` precision, generating multiple UUIDs within the
239+
same millisecond increments the timestamp by 1ms for each UUID, causing the
240+
embedded timestamp to drift ahead of real time under high throughput.
241+
Using `precision: :nanosecond` reduces this drift significantly, as
242+
timestamps only advance by ~244ns per UUID when generation outpaces real
243+
time. When monotonic UUIDs are desired, it is recommended to also use
244+
`precision: :nanosecond`.
245+
246+
## Examples
247+
248+
> Ecto.UUID.generate()
249+
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
250+
251+
> Ecto.UUID.generate(version: 7)
252+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
253+
254+
> Ecto.UUID.generate(version: 7, precision: :nanosecond)
255+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
256+
257+
> Ecto.UUID.generate(version: 7, monotonic: true)
258+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
259+
210260
"""
211261
@spec generate() :: t
212262
@spec generate(options) :: t
213263
def generate(opts \\ []), do: encode(bingenerate(opts))
214264

215265
@doc """
216266
Generates a uuid with the given options in binary format.
267+
See `generate/1` for options.
217268
"""
218269
@spec bingenerate(options) :: raw
219270
def bingenerate(opts \\ []) do
220-
case Keyword.get(opts, :version, @default_version) do
221-
4 -> bingenerate_v4()
222-
7 -> bingenerate_v7()
223-
version -> raise ArgumentError, "unknown UUID version: #{inspect(version)}"
271+
case Keyword.pop(opts, :version, @default_version) do
272+
{4, _opts} -> bingenerate_v4()
273+
{7, opts} -> bingenerate_v7(opts)
274+
{version, _} -> raise ArgumentError, "unsupported UUID version: #{inspect(version)}"
224275
end
225276
end
226277

227278
defp bingenerate_v4 do
228279
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
229-
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
280+
<<u0::48, @version_4::4, u1::12, @variant::2, u2::62>>
230281
end
231282

232-
defp bingenerate_v7 do
233-
milliseconds = System.system_time(:millisecond)
234-
<<u0::12, u1::62, _::6>> = :crypto.strong_rand_bytes(10)
283+
# The bits available for sub-millisecond fractions when using increased clock
284+
# precision based on nanoseconds.
285+
@ns_sub_ms_bits 12
286+
# The number of values that can be represented in the bit space (2^12).
287+
@ns_possible_values Bitwise.bsl(1, @ns_sub_ms_bits)
288+
# The number of nanoseconds in a millisecond.
289+
@ns_per_ms 1_000_000
290+
# The minimum step when using increased clock precision with fractional
291+
# milliseconds based on nanoseconds.
292+
@ns_minimal_step div(@ns_per_ms, @ns_possible_values)
293+
294+
defp bingenerate_v7(opts) do
295+
monotonic = Keyword.get(opts, :monotonic, false)
296+
time_unit = Keyword.get(opts, :precision, if(monotonic, do: :nanosecond, else: :millisecond))
297+
298+
timestamp =
299+
case monotonic do
300+
true -> next_ascending(time_unit)
301+
false -> System.system_time(time_unit)
302+
monotonic -> raise ArgumentError, "invalid monotonic value: #{inspect(monotonic)}"
303+
end
304+
305+
case time_unit do
306+
:millisecond ->
307+
<<rand_a::12, _::6, rand_b::62>> = :crypto.strong_rand_bytes(10)
308+
<<timestamp::48, @version_7::4, rand_a::12, @variant::2, rand_b::62>>
309+
310+
:nanosecond ->
311+
milliseconds = div(timestamp, @ns_per_ms)
312+
313+
clock_precision =
314+
(rem(timestamp, @ns_per_ms) * @ns_possible_values) |> div(@ns_per_ms)
315+
316+
<<rand_b::62, _::2>> = :crypto.strong_rand_bytes(8)
317+
<<milliseconds::48, @version_7::4, clock_precision::12, @variant::2, rand_b::62>>
318+
319+
time_unit ->
320+
raise ArgumentError, "unsupported precision: #{inspect(time_unit)}"
321+
end
322+
end
235323

236-
<<milliseconds::48, 7::4, u0::12, 2::2, u1::62>>
324+
defp next_ascending(time_unit) when time_unit in [:millisecond, :nanosecond] do
325+
timestamp_ref = :persistent_term.get({__MODULE__, time_unit}, nil)
326+
327+
step =
328+
case time_unit do
329+
:millisecond -> 1
330+
:nanosecond -> @ns_minimal_step
331+
end
332+
333+
previous_ts = :atomics.get(timestamp_ref, 1)
334+
min_step_ts = previous_ts + step
335+
current_ts = System.system_time(time_unit)
336+
337+
# If the current timestamp is not at least the minimal step greater than the
338+
# previous step, then we make it so.
339+
new_ts =
340+
if current_ts > min_step_ts do
341+
current_ts
342+
else
343+
min_step_ts
344+
end
345+
346+
compare_exchange(timestamp_ref, previous_ts, new_ts, step)
347+
end
348+
349+
defp compare_exchange(timestamp_ref, previous_ts, new_ts, step) do
350+
case :atomics.compare_exchange(timestamp_ref, 1, previous_ts, new_ts) do
351+
# If the new value was written, then we return it.
352+
:ok -> new_ts
353+
# If the atomic value has changed in the meantime, we add the minimal step
354+
# nanoseconds value to that and try again.
355+
updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + step, step)
356+
end
237357
end
238358

239359
# Callback invoked by autogenerate fields.

test/ecto/uuid_test.exs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,20 @@ defmodule Ecto.UUIDTest do
8181
uuid2 = Ecto.UUID.generate(version: 7)
8282
assert uuid1 < uuid2
8383
end
84+
85+
test "generate v7 with precision: :millisecond, monotonic: true maintains sortability" do
86+
uuids =
87+
for _ <- 0..10_000,
88+
do: Ecto.UUID.generate(version: 7, precision: :millisecond, monotonic: true)
89+
90+
assert uuids == Enum.sort(uuids)
91+
end
92+
93+
test "generate v7 with precision: :nanosecond, monotonic: true maintains sortability" do
94+
uuids =
95+
for _ <- 0..100_000,
96+
do: Ecto.UUID.generate(version: 7, precision: :nanosecond, monotonic: true)
97+
98+
assert uuids == Enum.sort(uuids)
99+
end
84100
end

0 commit comments

Comments
 (0)