Skip to content

Commit a864137

Browse files
uuidv7 monotonicity
1 parent e6da70d commit a864137

File tree

1 file changed

+95
-9
lines changed

1 file changed

+95
-9
lines changed

lib/ecto/uuid.ex

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,25 @@ 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+
| {:monotonic_method, :millisecond | :increased_clock_precision}
38+
39+
@type options :: [option]
40+
41+
# The bits available for sub-millisecond fractions when using increased clock
42+
# precision.
43+
@sub_ms_bits 12
44+
45+
# The number of values that can be represented in the bit space (2^12).
46+
@possible_values Bitwise.bsl(1, @sub_ms_bits)
47+
48+
# The number of nanoseconds in a millisecond.
49+
@ns_per_ms 1_000_000
50+
51+
# The minimum step when using increased clock precision with fractional
52+
# milliseconds.
53+
@minimal_step_ns div(@ns_per_ms, @possible_values)
3654

3755
@doc false
3856
def type, do: :uuid
@@ -217,10 +235,10 @@ defmodule Ecto.UUID do
217235
"""
218236
@spec bingenerate(options) :: raw
219237
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)}"
238+
case Keyword.pop(opts, :version, @default_version) do
239+
{4, _opts} -> bingenerate_v4()
240+
{7, opts} -> bingenerate_v7(opts)
241+
{version, _} -> raise ArgumentError, "unsupported UUID version: #{inspect(version)}"
224242
end
225243
end
226244

@@ -229,11 +247,79 @@ defmodule Ecto.UUID do
229247
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
230248
end
231249

232-
defp bingenerate_v7 do
233-
milliseconds = System.system_time(:millisecond)
234-
<<u0::12, u1::62, _::6>> = :crypto.strong_rand_bytes(10)
250+
defp bingenerate_v7(opts) do
251+
# From RFC 9562:
252+
# Monotonicity (each subsequent value being greater than the last) is the
253+
# backbone of time-based sortable UUIDs. Normally, time-based UUIDs will be
254+
# monotonic due to an embedded timestamp; however, implementations can
255+
# guarantee additional monotonicity via the concepts covered in section 6.2.
256+
case Keyword.get(opts, :monotonic_method) do
257+
# Millisecond granularity only. No monotonic guarantee.
258+
nil ->
259+
milliseconds = System.system_time(:millisecond)
260+
<<rand_a::12, _::6, rand_b::62>> = :crypto.strong_rand_bytes(10)
261+
<<milliseconds::48, 7::4, rand_a::12, 2::2, rand_b::62>>
262+
263+
# For single-node UUID implementations that do not need to create
264+
# batches of UUIDs, the embedded timestamp within UUIDv7 can provide
265+
# sufficient monotonicity guarantees by simply ensuring that timestamp
266+
# increments before creating a new UUID. (RFC9562§6.2)
267+
:millisecond ->
268+
milliseconds = next_ascending(:millisecond)
269+
<<rand_a::12, _::6, rand_b::62>> = :crypto.strong_rand_bytes(10)
270+
<<milliseconds::48, 7::4, rand_a::12, 2::2, rand_b::62>>
271+
272+
# Replace Leftmost Random Bits with Increased Clock Precision (RFC9562§6.2, Method 3):
273+
:increased_clock_precision ->
274+
nanoseconds = next_ascending(:nanosecond)
275+
milliseconds = div(nanoseconds, @ns_per_ms)
276+
clock_precision = (rem(nanoseconds, @ns_per_ms) * @possible_values) |> div(@ns_per_ms)
277+
<<_rand_a::2, rand_b::62>> = :crypto.strong_rand_bytes(8)
278+
<<milliseconds::48, 7::4, clock_precision::12, 2::2, rand_b::62>>
279+
280+
method ->
281+
raise ArgumentError, "invalid monotonic method: #{inspect(method)}"
282+
end
283+
end
235284

236-
<<milliseconds::48, 7::4, u0::12, 2::2, u1::62>>
285+
defp next_ascending(time_unit) when time_unit in [:millisecond, :nanosecond] do
286+
timestamp_ref =
287+
with nil <- :persistent_term.get({__MODULE__, time_unit}, nil) do
288+
timestamp_ref = :atomics.new(1, signed: false)
289+
:ok = :persistent_term.put({__MODULE__, time_unit}, timestamp_ref)
290+
timestamp_ref
291+
end
292+
293+
step =
294+
case time_unit do
295+
:millisecond -> 1
296+
:nanosecond -> @minimal_step_ns
297+
end
298+
299+
previous_ts = :atomics.get(timestamp_ref, 1)
300+
min_step_ts = previous_ts + step
301+
current_ts = System.system_time(time_unit)
302+
303+
# If the current timestamp is not at least the minimal step greater than the
304+
# previous step, then we make it so.
305+
new_ts =
306+
if current_ts > min_step_ts do
307+
current_ts
308+
else
309+
min_step_ts
310+
end
311+
312+
compare_exchange(timestamp_ref, previous_ts, new_ts, step)
313+
end
314+
315+
defp compare_exchange(timestamp_ref, previous_ts, new_ts, step) do
316+
case :atomics.compare_exchange(timestamp_ref, 1, previous_ts, new_ts) do
317+
# If the new value was written, then we return it.
318+
:ok -> new_ts
319+
# If the atomic value has changed in the meantime, we add the minimal step
320+
# nanoseconds value to that and try again.
321+
updated_ts -> compare_exchange(timestamp_ref, updated_ts, updated_ts + step, step)
322+
end
237323
end
238324

239325
# Callback invoked by autogenerate fields.

0 commit comments

Comments
 (0)