Skip to content

Commit 2817fc2

Browse files
author
Robin Newton
committed
IH-577 Implement v7 UUID generation
* New function Uuidx.make_v7_uuid, with the idea being that ordering v7 UUIDs alphabetically will also order them by creation time. This requires uuidm v0.9.9, as that contains the code for constructing a v7 UUID from a time and some random bytes. * There is a function for generating v7 from known inputs, for the purpose of unit testing. Arguably this is pointless to have unit tests for third-party code, but the tests were written to test code that was submitted to uuidm only later, and I'm always loathe to delete tests. Signed-off-by: Robin Newton <[email protected]>
1 parent 01b6205 commit 2817fc2

File tree

4 files changed

+119
-10
lines changed

4 files changed

+119
-10
lines changed

ocaml/libs/uuid/dune

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
(public_name uuid)
44
(modules uuidx)
55
(libraries
6-
unix (re_export uuidm)
6+
mtime
7+
mtime.clock.os
8+
ptime
9+
ptime.clock.os
710
threads.posix
11+
unix
12+
(re_export uuidm)
813
)
914
(wrapped false)
1015
)

ocaml/libs/uuid/uuid_test.ml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ let uuid_arrays =
2525
let non_uuid_arrays =
2626
[[|0|]; [|0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14|]]
2727

28+
let uuid_v7_times =
29+
let of_ms ms = Int64.mul 1_000_000L (Int64.of_float ms) in
30+
let power_of_2_ms n = Float.pow 2.0 (Float.of_int n) |> of_ms in
31+
let zero = 0L in
32+
let ms = 1_000_000L in
33+
let ns = 1L in
34+
(* Using RFC9562 "method 3" for representiong sub-millisecond fractions,
35+
that smallest amount of time a v7 UUID can represent is 1 / 4096 ms,
36+
which is (just more than) 244 nanoseconds *)
37+
let tick = 245L in
38+
let ( + ) = Int64.add in
39+
let ( - ) = Int64.sub in
40+
[
41+
(zero, "00000000-0000-7000-8000-000000000000")
42+
; (tick, "00000000-0000-7001-8000-000000000000")
43+
; (ms, "00000000-0001-7000-8000-000000000000")
44+
; (ms - ns, "00000000-0000-7fff-8000-000000000000")
45+
(* Test a wide range of dates - however, we can't express dates of
46+
beyond epoch + (2^64 - 1) nanoseconds, which is about approximately
47+
epoch + 2^44 milliseconds - some point in the 26th century *)
48+
; (power_of_2_ms 05, "00000000-0020-7000-8000-000000000000")
49+
; (power_of_2_ms 10, "00000000-0400-7000-8000-000000000000")
50+
; (power_of_2_ms 15, "00000000-8000-7000-8000-000000000000")
51+
; (power_of_2_ms 20, "00000010-0000-7000-8000-000000000000")
52+
; (power_of_2_ms 25, "00000200-0000-7000-8000-000000000000")
53+
; (power_of_2_ms 30, "00004000-0000-7000-8000-000000000000")
54+
; (power_of_2_ms 35, "00080000-0000-7000-8000-000000000000")
55+
; (power_of_2_ms 40, "01000000-0000-7000-8000-000000000000")
56+
; (power_of_2_ms 44, "10000000-0000-7000-8000-000000000000")
57+
; (power_of_2_ms 44 - ns, "0fffffff-ffff-7fff-8000-000000000000")
58+
; (power_of_2_ms 44 + tick, "10000000-0000-7001-8000-000000000000")
59+
]
60+
61+
let uuid_v7_bytes =
62+
[
63+
(1L, "00000000-0000-7000-8000-000000000001")
64+
; (-1L, "00000000-0000-7000-bfff-ffffffffffff")
65+
; (0x1234_5678_9abc_def0L, "00000000-0000-7000-9234-56789abcdef0")
66+
]
67+
2868
type resource = [`Generic]
2969

3070
let uuid_testable : (module Alcotest.TESTABLE with type t = resource Uuidx.t) =
@@ -51,6 +91,36 @@ let roundtrip_tests testing_uuid =
5191
; ("Roundtrip array conversion", `Quick, test_array)
5292
]
5393

94+
let uuid_v7_time_tests (t, expected_as_string) =
95+
let expected =
96+
match Uuidx.of_string expected_as_string with
97+
| Some uuid ->
98+
uuid
99+
| None ->
100+
Alcotest.fail
101+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
102+
in
103+
let test () =
104+
let result = Uuidx.make_v7_uuid_from_parts t 0L in
105+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
106+
in
107+
(expected_as_string, [("Make UUIDv7 from time", `Quick, test)])
108+
109+
let uuid_v7_bytes_tests (rand_b, expected_as_string) =
110+
let expected =
111+
match Uuidx.of_string expected_as_string with
112+
| Some uuid ->
113+
uuid
114+
| None ->
115+
Alcotest.fail
116+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
117+
in
118+
let test () =
119+
let result = Uuidx.make_v7_uuid_from_parts 0L rand_b in
120+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
121+
in
122+
(expected_as_string, [("Make UUIDv7 from bytes", `Quick, test)])
123+
54124
let string_roundtrip_tests testing_string =
55125
let testing_uuid =
56126
match Uuidx.of_string testing_string with
@@ -111,6 +181,8 @@ let regression_tests =
111181
; List.map array_roundtrip_tests uuid_arrays
112182
; List.map invalid_string_tests non_uuid_strings
113183
; List.map invalid_array_tests non_uuid_arrays
184+
; List.map uuid_v7_time_tests uuid_v7_times
185+
; List.map uuid_v7_bytes_tests uuid_v7_bytes
114186
]
115187

116188
let () = Alcotest.run "Uuid" regression_tests

ocaml/libs/uuid/uuidx.ml

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,43 @@ let read_bytes dev n =
131131

132132
let make_uuid_urnd () = of_bytes (read_bytes dev_urandom_fd 16) |> Option.get
133133

134-
(** Use non-CSPRNG by default, for CSPRNG see {!val:make_uuid_urnd} *)
135-
let make_uuid_fast =
136-
let uuid_state = Random.State.make_self_init () in
134+
(* State for random number generation. Random.State.t isn't thread safe, so
135+
only use this via with_non_csprng_state, which takes care of this.
136+
*)
137+
let rstate = Random.State.make_self_init ()
138+
let rstate_m = Mutex.create ()
139+
140+
let with_non_csprng_state =
137141
(* On OCaml 5 we could use Random.State.split instead,
138142
and on OCaml 4 the mutex may not be strictly needed
139143
*)
140-
let m = Mutex.create () in
141-
let finally () = Mutex.unlock m in
142-
let gen = Uuidm.v4_gen uuid_state in
143-
fun () -> Mutex.lock m ; Fun.protect ~finally gen
144+
let finally () = Mutex.unlock rstate_m in
145+
fun f -> Mutex.lock rstate_m ; Fun.protect ~finally (f rstate)
146+
147+
(** Use non-CSPRNG by default, for CSPRNG see {!val:make_uuid_urnd} *)
148+
let make_uuid_fast () = with_non_csprng_state Uuidm.v4_gen
144149

145150
let make_default = ref make_uuid_urnd
146151

147152
let make () = !make_default ()
148153

154+
let make_v7_uuid_from_parts time_ns rand_b = Uuidm.v7_ns ~time_ns ~rand_b
155+
156+
let rand64 () =
157+
with_non_csprng_state (fun rstate () -> Random.State.bits64 rstate)
158+
159+
let now_ns =
160+
let start = Mtime_clock.counter () in
161+
let t0 =
162+
let d, ps = Ptime_clock.now () |> Ptime.to_span |> Ptime.Span.to_d_ps in
163+
Int64.(add (mul (of_int d) 86_400_000_000_000L) (div ps 1000L))
164+
in
165+
fun () ->
166+
let since_t0 = Mtime_clock.count start |> Mtime.Span.to_uint64_ns in
167+
Int64.add t0 since_t0
168+
169+
let make_v7_uuid () = make_v7_uuid_from_parts (now_ns ()) (rand64 ())
170+
149171
type cookie = string
150172

151173
let make_cookie () =

ocaml/libs/uuid/uuidx.mli

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ type all = [without_secret | secret]
115115
type 'a t = Uuidm.t constraint 'a = [< all]
116116

117117
val null : [< not_secret] t
118-
(** A null UUID, as if such a thing actually existed. It turns out to be
119-
useful though. *)
118+
(** A null UUID, as defined in RFC 9562 5.9. *)
120119

121120
val make : unit -> [< not_secret] t
122121
(** Create a fresh UUID *)
@@ -130,6 +129,17 @@ val make_uuid_fast : unit -> [< not_secret] t
130129
Don't use this to generate secrets, see {!val:make_uuid_urnd} for that instead.
131130
*)
132131

132+
val make_v7_uuid_from_parts : int64 -> int64 -> [< not_secret] t
133+
(** For testing only: create a v7 UUID, as defined in RFC 9562 5.7 *)
134+
135+
val make_v7_uuid : unit -> [< not_secret] t
136+
(** Create a fresh v7 UUID, as defined in RFC 9562 5.7. This incorporates a
137+
POSIX timestamp, such that the alphabetic of any two such UUIDs will match
138+
the timestamp order - provided that they are at least 245 nanoseconds
139+
apart. Note that in order to ensure that the timestamps used are
140+
monotonic, operating time adjustments are ignored and hence timestamps
141+
only approximate system time. *)
142+
133143
val pp : Format.formatter -> [< not_secret] t -> unit
134144

135145
val equal : 'a t -> 'a t -> bool

0 commit comments

Comments
 (0)