Skip to content

Commit 9fee945

Browse files
Simplify UUIDv7 options (#4721)
1 parent 28d9282 commit 9fee945

3 files changed

Lines changed: 66 additions & 70 deletions

File tree

lib/ecto/application.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ 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))
76
:ok = :persistent_term.put({Ecto.UUID, :nanosecond}, :atomics.new(1, signed: false))
87

98
children = [

lib/ecto/uuid.ex

Lines changed: 30 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Ecto.UUID do
1919
To use UUID v7 (time-ordered) monotonic:
2020
2121
use Ecto.Schema
22-
@primary_key {:id, Ecto.UUID, autogenerate: [version: 7, monotonic: true]}
22+
@primary_key {:id, Ecto.UUID, autogenerate: [version: 7, precision: :monotonic]}
2323
2424
According to [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562#name-monotonicity-and-counters):
2525
"Monotonicity (each subsequent value being greater than the last) is the
@@ -39,12 +39,11 @@ defmodule Ecto.UUID do
3939
@type raw :: <<_::128>>
4040

4141
@typedoc """
42-
Supported options: `:version`, `:precision` (v7-only), and `:monotonic` (v7-only).
42+
Supported options: `:version` and `:precision` (v7-only).
4343
"""
4444
@type option ::
4545
{:version, 4 | 7}
46-
| {:precision, :millisecond | :nanosecond}
47-
| {:monotonic, boolean()}
46+
| {:precision, :millisecond | :monotonic}
4847

4948
@type options :: [option]
5049

@@ -233,24 +232,15 @@ defmodule Ecto.UUID do
233232
234233
## Options (version 7 only)
235234
236-
* `:precision` - The timestamp precision for version 7 UUIDs. Supported values
237-
are `:millisecond` and `:nanosecond`. Defaults to `:millisecond` if
238-
monotonic is `false` and `:nanosecond` if `:monotonic` is `true`.
239-
When using `:nanosecond`, the sub-millisecond precision is encoded in the
240-
`rand_a` field. NOTE: Due to the 12-bit space available, nanosecond
241-
precision is limited to 4096 (2^12) distinct values per millisecond.
242-
243-
* `:monotonic` - When `true`, ensures that generated version 7 UUIDs are
244-
strictly monotonically increasing, even when multiple UUIDs are generated
245-
within the same timestamp. This is useful for maintaining insertion order
246-
in databases. Defaults to `false`.
247-
NOTE: With `:millisecond` precision, generating multiple UUIDs within the
248-
same millisecond increments the timestamp by 1ms for each UUID, causing the
249-
embedded timestamp to drift ahead of real time under high throughput.
250-
Using `precision: :nanosecond` reduces this drift significantly, as
251-
timestamps only advance by 244ns per UUID when generation outpaces real
252-
time. When monotonic UUIDs are desired, it is recommended to also use
253-
`precision: :nanosecond`.
235+
* `:precision` - The timestamp precision for version 7 UUIDs. Supported
236+
values are `:millisecond` and `:monotonic`. Defaults to `:millisecond`.
237+
238+
> #### Monotonic precision {: .info}
239+
>
240+
> When using `:monotonic`, sub-millisecond precision is encoded in the
241+
> `rand_a` field. The generated version 7 UUIDs are strictly monotonically
242+
> increasing (per node), even when multiple UUIDs are generated within the same
243+
> timestamp. This is useful for maintaining insertion order in databases.
254244
255245
## Examples
256246
@@ -260,10 +250,10 @@ defmodule Ecto.UUID do
260250
> Ecto.UUID.generate(version: 7)
261251
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
262252
263-
> Ecto.UUID.generate(version: 7, precision: :nanosecond)
253+
> Ecto.UUID.generate(version: 7, precision: :millisecond)
264254
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
265255
266-
> Ecto.UUID.generate(version: 7, monotonic: true)
256+
> Ecto.UUID.generate(version: 7, precision: :monotonic)
267257
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
268258
269259
"""
@@ -291,7 +281,7 @@ defmodule Ecto.UUID do
291281
end
292282

293283
# The bits available for sub-millisecond fractions when using increased clock
294-
# precision based on nanoseconds.
284+
# precision for monotonicity (based on nanoseconds).
295285
@ns_sub_ms_bits 12
296286
# The number of values that can be represented in the bit space (2^12).
297287
@ns_possible_values Bitwise.bsl(1, @ns_sub_ms_bits)
@@ -302,22 +292,17 @@ defmodule Ecto.UUID do
302292
@ns_minimal_step div(@ns_per_ms, @ns_possible_values)
303293

304294
defp bingenerate_v7(opts) do
305-
monotonic = Keyword.get(opts, :monotonic, false)
306-
time_unit = Keyword.get(opts, :precision, if(monotonic, do: :nanosecond, else: :millisecond))
307-
308-
timestamp =
309-
case monotonic do
310-
true -> next_ascending(time_unit)
311-
false -> System.system_time(time_unit)
312-
monotonic -> raise ArgumentError, "invalid monotonic value: #{inspect(monotonic)}"
313-
end
295+
{precision, rest} = Keyword.pop(opts, :precision, :millisecond)
296+
if rest != [], do: raise(ArgumentError, "unsupported options for v7: #{inspect(rest)}")
314297

315-
case time_unit do
298+
case precision do
316299
:millisecond ->
300+
timestamp = System.system_time(:millisecond)
317301
<<rand_a::12, _::6, rand_b::62>> = :crypto.strong_rand_bytes(10)
318302
<<timestamp::48, @version_7::4, rand_a::12, @variant::2, rand_b::62>>
319303

320-
:nanosecond ->
304+
:monotonic ->
305+
timestamp = next_ascending()
321306
milliseconds = div(timestamp, @ns_per_ms)
322307

323308
clock_precision =
@@ -326,39 +311,33 @@ defmodule Ecto.UUID do
326311
<<_::2, rand_b::62>> = :crypto.strong_rand_bytes(8)
327312
<<milliseconds::48, @version_7::4, clock_precision::12, @variant::2, rand_b::62>>
328313

329-
time_unit ->
330-
raise ArgumentError, "unsupported precision: #{inspect(time_unit)}"
314+
precision ->
315+
raise ArgumentError, "unsupported precision: #{inspect(precision)}"
331316
end
332317
end
333318

334-
defp next_ascending(time_unit) when time_unit in [:millisecond, :nanosecond] do
319+
defp next_ascending do
335320
timestamp_ref =
336-
:persistent_term.get({__MODULE__, time_unit}, nil) || raise "Ecto has not been started"
337-
338-
step =
339-
case time_unit do
340-
:millisecond -> 1
341-
:nanosecond -> @ns_minimal_step
342-
end
321+
:persistent_term.get({__MODULE__, :nanosecond}, nil) || raise "Ecto has not been started"
343322

344323
previous_ts = :atomics.get(timestamp_ref, 1)
345-
min_step_ts = previous_ts + step
346-
current_ts = System.system_time(time_unit)
324+
min_step_ts = previous_ts + @ns_minimal_step
325+
current_ts = System.system_time(:nanosecond)
347326

348327
# If the current timestamp is not at least the minimal step greater than the
349328
# previous step, then we make it so.
350329
new_ts = max(current_ts, min_step_ts)
351330

352-
compare_exchange(timestamp_ref, previous_ts, new_ts, step)
331+
compare_exchange(timestamp_ref, previous_ts, new_ts)
353332
end
354333

355-
defp compare_exchange(timestamp_ref, previous_ts, new_ts, step) do
334+
defp compare_exchange(timestamp_ref, previous_ts, new_ts) do
356335
case :atomics.compare_exchange(timestamp_ref, 1, previous_ts, new_ts) do
357336
# If the new value was written, then we return it.
358337
:ok -> new_ts
359338
# Otherwise, the atomic value has changed in the meantime. We add the
360339
# minimal step value to that and try again.
361-
updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + step, step)
340+
updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + @ns_minimal_step)
362341
end
363342
end
364343

test/ecto/uuid_test.exs

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ defmodule Ecto.UUIDTest do
6161
end
6262
end
6363

64+
test "bingenerate returns 16-byte binary with correct v4 version and variant bits" do
65+
assert <<_::48, 4::4, _::12, 2::2, _::62>> = Ecto.UUID.bingenerate()
66+
end
67+
68+
test "bingenerate v7 returns 16-byte binary with correct version and variant bits" do
69+
assert <<_::48, 7::4, _::12, 2::2, _::62>> = Ecto.UUID.bingenerate(version: 7)
70+
end
71+
72+
test "bingenerate v7 with precision: :monotonic returns correct version and variant bits" do
73+
assert <<_::48, 7::4, _::12, 2::2, _::62>> =
74+
Ecto.UUID.bingenerate(version: 7, precision: :monotonic)
75+
end
76+
77+
test "generate with invalid version raises an ArgumentError" do
78+
assert_raise ArgumentError, ~r/unsupported UUID version/, fn ->
79+
Ecto.UUID.generate(version: 99)
80+
end
81+
end
82+
6483
test "generate returns valid uuid_v4" do
6584
assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> = Ecto.UUID.generate()
6685
end
@@ -70,13 +89,13 @@ defmodule Ecto.UUIDTest do
7089
Ecto.UUID.generate(version: 4)
7190
end
7291

73-
test "generate v4 with precision or monotonic raises an ArgumentError" do
74-
assert_raise ArgumentError, fn ->
92+
test "generate v4 with precision raises an ArgumentError" do
93+
assert_raise ArgumentError, ~r/unsupported options for v4/, fn ->
7594
Ecto.UUID.generate(precision: :millisecond)
7695
end
7796

78-
assert_raise ArgumentError, fn ->
79-
Ecto.UUID.generate(version: 4, monotonic: true)
97+
assert_raise ArgumentError, ~r/unsupported options for v4/, fn ->
98+
Ecto.UUID.generate(version: 4, precision: :monotonic)
8099
end
81100
end
82101

@@ -85,36 +104,35 @@ defmodule Ecto.UUIDTest do
85104
Ecto.UUID.generate(version: 7)
86105
end
87106

107+
test "generate v7 returns valid uuid_v7 with precision: :millisecond" do
108+
assert <<_::64, ?-, _::32, ?-, ?7, _::24, ?-, _::32, ?-, _::96>> =
109+
Ecto.UUID.generate(version: 7, precision: :millisecond)
110+
end
111+
88112
test "generate v7 maintains time-based sortability across milliseconds" do
89113
uuid1 = Ecto.UUID.generate(version: 7)
90114
Process.sleep(1)
91115
uuid2 = Ecto.UUID.generate(version: 7)
92116
assert uuid1 < uuid2
93117
end
94118

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
119+
test "generate v7 with precision: :monotonic maintains sortability" do
104120
uuids =
105121
for _ <- 0..20_000,
106-
do: Ecto.UUID.generate(version: 7, precision: :nanosecond, monotonic: true)
122+
do: Ecto.UUID.generate(version: 7, precision: :monotonic)
107123

108124
assert uuids == Enum.sort(uuids)
109125
end
110126

111-
test "generate v7 with invalid precision or monotonic raises an ArgumentError" do
112-
assert_raise ArgumentError, fn ->
127+
test "generate v7 with invalid precision raises an ArgumentError" do
128+
assert_raise ArgumentError, ~r/unsupported precision/, fn ->
113129
Ecto.UUID.generate(version: 7, precision: :foo)
114130
end
131+
end
115132

116-
assert_raise ArgumentError, fn ->
117-
Ecto.UUID.generate(version: 7, monotonic: :bar)
133+
test "generate v7 with invalid opts raises an ArgumentError" do
134+
assert_raise ArgumentError, ~r/unsupported options for v7/, fn ->
135+
Ecto.UUID.generate(version: 7, precision: :monotonic, foo: :bar)
118136
end
119137
end
120138
end

0 commit comments

Comments
 (0)