Skip to content

Commit cf9df36

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 cf9df36

File tree

3 files changed

+179
-12
lines changed

3 files changed

+179
-12
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: 140 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,21 @@ defmodule Ecto.UUID do
3030
@type raw :: <<_::128>>
3131

3232
@typedoc """
33-
currently supported option is version, it accepts 4 or 7.
33+
Supported options:
34+
* `:version` (4 or 7)
35+
* `:precision` (`:millisecond` | `:nanosecond`, v7 only)
36+
* `:monotonic` (boolean, v7 only).
3437
"""
35-
@type options :: [version: 4 | 7]
38+
@type option ::
39+
{:version, 4 | 7}
40+
| {:precision, :millisecond | :nanosecond}
41+
| {:monotonic, boolean()}
42+
43+
@type options :: [option]
44+
45+
@version_4 4
46+
@version_7 7
47+
@variant 2
3648

3749
@doc false
3850
def type, do: :uuid
@@ -206,34 +218,150 @@ defmodule Ecto.UUID do
206218

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

215268
@doc """
216269
Generates a uuid with the given options in binary format.
270+
See `generate/1` for details and available options.
217271
"""
218272
@spec bingenerate(options) :: raw
219273
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)}"
274+
case Keyword.pop(opts, :version, @default_version) do
275+
{4, []} -> bingenerate_v4()
276+
{7, opts} -> bingenerate_v7(opts)
277+
{4, opts} -> raise ArgumentError, "unsupported options for v4: #{inspect(opts)}"
278+
{version, _} -> raise ArgumentError, "unsupported UUID version: #{inspect(version)}"
224279
end
225280
end
226281

227282
defp bingenerate_v4 do
228283
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
229-
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
284+
<<u0::48, @version_4::4, u1::12, @variant::2, u2::62>>
230285
end
231286

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

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

239367
# Callback invoked by autogenerate fields.

test/ecto/uuid_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ defmodule Ecto.UUIDTest do
7070
Ecto.UUID.generate(version: 4)
7171
end
7272

73+
test "generate v4 with precision or monotonic raises an ArgumentError" do
74+
assert_raise ArgumentError, fn ->
75+
Ecto.UUID.generate(precision: :millisecond)
76+
end
77+
78+
assert_raise ArgumentError, fn ->
79+
Ecto.UUID.generate(version: 4, monotonic: true)
80+
end
81+
end
82+
7383
test "generate v7 returns valid uuid_v7" do
7484
assert <<_::64, ?-, _::32, ?-, ?7, _::24, ?-, _::32, ?-, _::96>> =
7585
Ecto.UUID.generate(version: 7)
@@ -81,4 +91,30 @@ defmodule Ecto.UUIDTest do
8191
uuid2 = Ecto.UUID.generate(version: 7)
8292
assert uuid1 < uuid2
8393
end
94+
95+
test "generate v7 with precision: :millisecond, monotonic: true maintains sortability" do
96+
uuids =
97+
for _ <- 0..5_000,
98+
do: Ecto.UUID.generate(version: 7, precision: :millisecond, monotonic: true)
99+
100+
assert uuids == Enum.sort(uuids)
101+
end
102+
103+
test "generate v7 with precision: :nanosecond, monotonic: true maintains sortability" do
104+
uuids =
105+
for _ <- 0..20_000,
106+
do: Ecto.UUID.generate(version: 7, precision: :nanosecond, monotonic: true)
107+
108+
assert uuids == Enum.sort(uuids)
109+
end
110+
111+
test "generate v7 with invalid precision or monotonic raises an ArgumentError" do
112+
assert_raise ArgumentError, fn ->
113+
Ecto.UUID.generate(version: 7, precision: :foo)
114+
end
115+
116+
assert_raise ArgumentError, fn ->
117+
Ecto.UUID.generate(version: 7, monotonic: :bar)
118+
end
119+
end
84120
end

0 commit comments

Comments
 (0)