Skip to content

Commit eb0a160

Browse files
UUIDv7 monotonicity and increased clock precision (#4683)
1 parent a17227e commit eb0a160

File tree

3 files changed

+177
-12
lines changed

3 files changed

+177
-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: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ defmodule Ecto.UUID do
1515
1616
use Ecto.Schema
1717
@primary_key {:id, :binary_id, autogenerate: [version: 7]}
18+
19+
To use UUID v7 (time-ordered) monotonic:
20+
21+
use Ecto.Schema
22+
@primary_key {:id, :binary_id, autogenerate: [version: 7, monotonic: true]}
23+
24+
According to [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562#name-monotonicity-and-counters):
25+
"Monotonicity (each subsequent value being greater than the last) is the
26+
backbone of time-based sortable UUIDs."
1827
"""
1928

2029
use Ecto.Type
@@ -30,9 +39,18 @@ defmodule Ecto.UUID do
3039
@type raw :: <<_::128>>
3140

3241
@typedoc """
33-
currently supported option is version, it accepts 4 or 7.
42+
Supported options: `:version`, `:precision` (v7-only), and `:monotonic` (v7-only).
3443
"""
35-
@type options :: [version: 4 | 7]
44+
@type option ::
45+
{:version, 4 | 7}
46+
| {:precision, :millisecond | :nanosecond}
47+
| {:monotonic, boolean()}
48+
49+
@type options :: [option]
50+
51+
@version_4 4
52+
@version_7 7
53+
@variant 2
3654

3755
@doc false
3856
def type, do: :uuid
@@ -206,34 +224,142 @@ defmodule Ecto.UUID do
206224

207225
@default_version 4
208226
@doc """
209-
Generates a uuid with the given options.
227+
Generates a UUID string.
228+
229+
## Options
230+
231+
* `:version` - The UUID version to generate. Supported values are `4` (random)
232+
and `7` (time-ordered). Defaults to `4`.
233+
234+
## Options (version 7 only)
235+
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`.
254+
255+
## Examples
256+
257+
> Ecto.UUID.generate()
258+
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
259+
260+
> Ecto.UUID.generate(version: 7)
261+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
262+
263+
> Ecto.UUID.generate(version: 7, precision: :nanosecond)
264+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
265+
266+
> Ecto.UUID.generate(version: 7, monotonic: true)
267+
"018ec4c1-ae46-7f5a-8f5a-6f5a8f5a6f5a"
268+
210269
"""
211270
@spec generate() :: t
212271
@spec generate(options) :: t
213272
def generate(opts \\ []), do: encode(bingenerate(opts))
214273

215274
@doc """
216275
Generates a uuid with the given options in binary format.
276+
See `generate/1` for details and available options.
217277
"""
218278
@spec bingenerate(options) :: raw
219279
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)}"
280+
case Keyword.pop(opts, :version, @default_version) do
281+
{4, []} -> bingenerate_v4()
282+
{7, opts} -> bingenerate_v7(opts)
283+
{4, opts} -> raise ArgumentError, "unsupported options for v4: #{inspect(opts)}"
284+
{version, _} -> raise ArgumentError, "unsupported UUID version: #{inspect(version)}"
224285
end
225286
end
226287

227288
defp bingenerate_v4 do
228289
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
229-
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
290+
<<u0::48, @version_4::4, u1::12, @variant::2, u2::62>>
291+
end
292+
293+
# The bits available for sub-millisecond fractions when using increased clock
294+
# precision based on nanoseconds.
295+
@ns_sub_ms_bits 12
296+
# The number of values that can be represented in the bit space (2^12).
297+
@ns_possible_values Bitwise.bsl(1, @ns_sub_ms_bits)
298+
# The number of nanoseconds in a millisecond.
299+
@ns_per_ms 1_000_000
300+
# The minimum step when using increased clock precision with fractional
301+
# milliseconds based on nanoseconds.
302+
@ns_minimal_step div(@ns_per_ms, @ns_possible_values)
303+
304+
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
314+
315+
case time_unit do
316+
:millisecond ->
317+
<<rand_a::12, _::6, rand_b::62>> = :crypto.strong_rand_bytes(10)
318+
<<timestamp::48, @version_7::4, rand_a::12, @variant::2, rand_b::62>>
319+
320+
:nanosecond ->
321+
milliseconds = div(timestamp, @ns_per_ms)
322+
323+
clock_precision =
324+
(rem(timestamp, @ns_per_ms) * @ns_possible_values) |> div(@ns_per_ms)
325+
326+
<<_::2, rand_b::62>> = :crypto.strong_rand_bytes(8)
327+
<<milliseconds::48, @version_7::4, clock_precision::12, @variant::2, rand_b::62>>
328+
329+
time_unit ->
330+
raise ArgumentError, "unsupported precision: #{inspect(time_unit)}"
331+
end
230332
end
231333

232-
defp bingenerate_v7 do
233-
milliseconds = System.system_time(:millisecond)
234-
<<u0::12, u1::62, _::6>> = :crypto.strong_rand_bytes(10)
334+
defp next_ascending(time_unit) when time_unit in [:millisecond, :nanosecond] do
335+
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
235343

236-
<<milliseconds::48, 7::4, u0::12, 2::2, u1::62>>
344+
previous_ts = :atomics.get(timestamp_ref, 1)
345+
min_step_ts = previous_ts + step
346+
current_ts = System.system_time(time_unit)
347+
348+
# If the current timestamp is not at least the minimal step greater than the
349+
# previous step, then we make it so.
350+
new_ts = max(current_ts, min_step_ts)
351+
352+
compare_exchange(timestamp_ref, previous_ts, new_ts, step)
353+
end
354+
355+
defp compare_exchange(timestamp_ref, previous_ts, new_ts, step) do
356+
case :atomics.compare_exchange(timestamp_ref, 1, previous_ts, new_ts) do
357+
# If the new value was written, then we return it.
358+
:ok -> new_ts
359+
# Otherwise, the atomic value has changed in the meantime. We add the
360+
# minimal step value to that and try again.
361+
updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + step, step)
362+
end
237363
end
238364

239365
# 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)