Skip to content

Commit c5085e6

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 * The values produced by Uuidx.make_uuid_urnd hadn't necessarily been valid UUIDs, since the variant and version fields were being filled in randomly - this is now fixed so that it returns v4 UUIDs. * There are a couple of functions for generating v4 and v7 from known inputs, for the purpose of unit testing. (The v4 function is mainly there so I could check the setting of variant and version fields by comparing the output with that which Python's UUID module produces.) Signed-off-by: Robin Newton <[email protected]>
1 parent fda9275 commit c5085e6

File tree

4 files changed

+170
-7
lines changed

4 files changed

+170
-7
lines changed

ocaml/libs/uuid/dune

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
(public_name uuid)
44
(modules uuidx)
55
(libraries
6-
unix (re_export uuidm)
6+
ptime
7+
ptime.clock.os
8+
unix
9+
(re_export uuidm)
710
)
811
(wrapped false)
912
)
@@ -12,5 +15,5 @@
1215
(name uuid_test)
1316
(package uuid)
1417
(modules uuid_test)
15-
(libraries alcotest fmt uuid)
18+
(libraries alcotest fmt ptime uuid)
1619
)

ocaml/libs/uuid/uuid_test.ml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,51 @@ 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_v4_cases =
29+
[
30+
((0L, 0L), "00000000-0000-4000-8000-000000000000")
31+
; ((-1L, -1L), "ffffffff-ffff-4fff-bfff-ffffffffffff")
32+
; ((0x89ab_cdefL, 0x0123_4567L), "00000000-89ab-4def-8000-000001234567")
33+
]
34+
35+
let uuid_v7_times =
36+
let of_ms ms = Ptime.Span.of_float_s (ms /. 1000.0) |> Option.get in
37+
let power_of_2_ms n =
38+
Float.pow 2.0 (Float.of_int n) |> of_ms |> Ptime.Span.truncate ~frac_s:3
39+
in
40+
let zero = of_ms 0.0 in
41+
let ms = of_ms 1.0 in
42+
let ps = Ptime.Span.of_d_ps (0, 1L) |> Option.get in
43+
(* Using RFC9562 "method 3" for representiong sub-millisecond fractions,
44+
that smallest amount of time a v7 UUID can represent is 1 / 4096 ms,
45+
which is (just less than) 244141 picoseconds *)
46+
let tick = Ptime.Span.of_d_ps (0, 244141L) |> Option.get in
47+
let ( - ) = Ptime.Span.sub in
48+
let to_d_ps = Ptime.Span.to_d_ps in
49+
[
50+
(zero |> to_d_ps, "00000000-0000-7000-8000-000000000000")
51+
; (tick |> to_d_ps, "00000000-0000-7001-8000-000000000000")
52+
; (ms |> to_d_ps, "00000000-0001-7000-8000-000000000000")
53+
; (ms - ps |> to_d_ps, "00000000-0000-7fff-8000-000000000000")
54+
(* Test a wide range of dates - but we can't get much bigger than
55+
epoch + 2^47 milliseconds, and that puts us in the year 6429 and Ptime
56+
only allows dates up to the year 9999 *)
57+
; (power_of_2_ms 05 |> to_d_ps, "00000000-0020-7000-8000-000000000000")
58+
; (power_of_2_ms 15 |> to_d_ps, "00000000-8000-7000-8000-000000000000")
59+
; (power_of_2_ms 25 |> to_d_ps, "00000200-0000-7000-8000-000000000000")
60+
; (power_of_2_ms 35 |> to_d_ps, "00080000-0000-7000-8000-000000000000")
61+
; (power_of_2_ms 45 |> to_d_ps, "20000000-0000-7000-8000-000000000000")
62+
; (power_of_2_ms 47 |> to_d_ps, "80000000-0000-7000-8000-000000000000")
63+
; (power_of_2_ms 47 - ps |> to_d_ps, "7fffffff-ffff-7fff-8000-000000000000")
64+
]
65+
66+
let uuid_v7_bytes =
67+
[
68+
(1L, "00000000-0000-7000-8000-000000000001")
69+
; (-1L, "00000000-0000-7000-bfff-ffffffffffff")
70+
; (0x1234_5678_9abc_def0L, "00000000-0000-7000-9234-56789abcdef0")
71+
]
72+
2873
type resource
2974

3075
let uuid_testable : (module Alcotest.TESTABLE with type t = resource Uuidx.t) =
@@ -51,6 +96,51 @@ let roundtrip_tests testing_uuid =
5196
; ("Roundtrip array conversion", `Quick, test_array)
5297
]
5398

99+
let uuid_v4_tests ((upper, lower), expected_as_string) =
100+
let expected =
101+
match Uuidx.of_string expected_as_string with
102+
| Some uuid ->
103+
uuid
104+
| None ->
105+
Alcotest.fail
106+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
107+
in
108+
let test () =
109+
let result = Uuidx.make_v4_uuid upper lower in
110+
Alcotest.(check @@ uuid_testable) "make UUIDv4" expected result
111+
in
112+
(expected_as_string, [("Make UUIDv4 from bytes", `Quick, test)])
113+
114+
let uuid_v7_time_tests (t, expected_as_string) =
115+
let expected =
116+
match Uuidx.of_string expected_as_string with
117+
| Some uuid ->
118+
uuid
119+
| None ->
120+
Alcotest.fail
121+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
122+
in
123+
let test () =
124+
let result = Uuidx.make_v7_uuid_from_time_and_bytes t 0L in
125+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
126+
in
127+
(expected_as_string, [("Make UUIDv7 from time", `Quick, test)])
128+
129+
let uuid_v7_bytes_tests (bs, expected_as_string) =
130+
let expected =
131+
match Uuidx.of_string expected_as_string with
132+
| Some uuid ->
133+
uuid
134+
| None ->
135+
Alcotest.fail
136+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
137+
in
138+
let test () =
139+
let result = Uuidx.make_v7_uuid_from_time_and_bytes (0, 0L) bs in
140+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
141+
in
142+
(expected_as_string, [("Make UUIDv7 from bytes", `Quick, test)])
143+
54144
let string_roundtrip_tests testing_string =
55145
let testing_uuid =
56146
match Uuidx.of_string testing_string with
@@ -111,6 +201,9 @@ let regression_tests =
111201
; List.map array_roundtrip_tests uuid_arrays
112202
; List.map invalid_string_tests non_uuid_strings
113203
; List.map invalid_array_tests non_uuid_arrays
204+
; List.map uuid_v4_tests uuid_v4_cases
205+
; List.map uuid_v7_time_tests uuid_v7_times
206+
; List.map uuid_v7_bytes_tests uuid_v7_bytes
114207
]
115208

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

ocaml/libs/uuid/uuidx.ml

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,66 @@ let read_bytes dev n =
5353
if read <> n then
5454
raise End_of_file
5555
else
56-
Bytes.to_string buf
56+
buf
5757
)
5858
(fun () -> Unix.close fd)
5959

60-
let make_uuid_urnd () = of_bytes (read_bytes dev_urandom 16) |> Option.get
60+
let set_bits buf i value mask =
61+
let old_octet = Bytes.get_uint8 buf i in
62+
let new_octet = lnot mask land old_octet lor (mask land value) in
63+
Bytes.set_uint8 buf i new_octet
64+
65+
let set_var_and_ver ver buf =
66+
(* Sets the 2-bit variant field and the 4-bit version field (see RFC 9562
67+
sections 4.1 and 4.2 respectively) into a 16 byte buffer representing a
68+
UUID. *)
69+
set_bits buf 8 (2 lsl 6) (0x3 lsl 6) ;
70+
set_bits buf 6 (ver lsl 4) (0xf lsl 4) ;
71+
()
72+
73+
let of_buf buf = of_bytes (buf |> Bytes.to_string) |> Option.get
74+
75+
let make_uuid_urnd () =
76+
let buf = read_bytes dev_urandom 16 in
77+
set_var_and_ver 4 buf ; of_buf buf
78+
79+
let make_v4_uuid upper lower =
80+
let buf = Bytes.create 16 in
81+
Bytes.set_int64_be buf 0 upper ;
82+
Bytes.set_int64_be buf 8 lower ;
83+
set_var_and_ver 4 buf ;
84+
of_buf buf
85+
86+
let d_ps_to_ms_ps (days, picoseconds) =
87+
let ms_in_day = 86_400_000L in
88+
let ps_in_ms = 1_000_000_000L in
89+
( Int64.(add (mul (of_int days) ms_in_day) (div picoseconds ps_in_ms))
90+
, Int64.rem picoseconds ps_in_ms
91+
)
92+
93+
let make_v7_uuid_from_time_and_bytes days_picos rand_b =
94+
let buf = Bytes.create 16 in
95+
let ms, ps = days_picos |> d_ps_to_ms_ps in
96+
(* We are using 12 bits to contain a sub-millisecond fraction, so we want
97+
to converted the remaindered number of picoseconds into the range 0 to
98+
4096. Given there are 10^9 picoseconds in a millisecond, multiplying by
99+
(2^63 / 10^9) converts into the range 0 - 2^63-1, which we can shift to
100+
give the 12-bit value we want. *)
101+
let sub_ms_frac = Int64.(shift_right (mul ps 9_223_372_037L) 51 |> to_int) in
102+
Bytes.set_int64_be buf 0 (Int64.shift_left ms 16) ;
103+
Bytes.set_int16_be buf 6 sub_ms_frac ;
104+
Bytes.set_int64_be buf 8 rand_b ;
105+
set_var_and_ver 7 buf ;
106+
of_buf buf
107+
108+
let make_rand64 () =
109+
let buf = read_bytes dev_urandom 8 in
110+
Bytes.get_int64_ne buf 0
111+
112+
let make_v7_uuid () =
113+
make_v7_uuid_from_time_and_bytes
114+
(Ptime_clock.now () |> Ptime.to_span |> Ptime.Span.to_d_ps)
115+
(make_rand64 ())
61116

62117
(* Use the CSPRNG-backed urandom *)
63118
let make = make_uuid_urnd
@@ -66,7 +121,7 @@ type cookie = string
66121

67122
let make_cookie () =
68123
read_bytes dev_urandom 64
69-
|> String.to_seq
124+
|> Bytes.to_seq
70125
|> Seq.map (fun c -> Printf.sprintf "%1x" (int_of_char c))
71126
|> List.of_seq
72127
|> String.concat ""

ocaml/libs/uuid/uuidx.mli

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,26 @@
2727
type 'a t
2828

2929
val null : 'a t
30-
(** A null UUID, as if such a thing actually existed. It turns out to be
31-
useful though. *)
30+
(** A null UUID, as defined in RFC 9562 5.9. *)
3231

3332
val make : unit -> 'a t
3433
(** Create a fresh UUID *)
3534

3635
val make_uuid_urnd : unit -> 'a t
3736

37+
val make_v4_uuid : int64 -> int64 -> 'a t
38+
(** For testing only: Create a v4 UUID, as defined in RFC 9562 5.4 *)
39+
40+
val make_v7_uuid_from_time_and_bytes : int * int64 -> int64 -> 'a t
41+
(** For testing only: create a v7 UUID, as defined in RFC 9562 5.7 *)
42+
43+
val make_v7_uuid : unit -> 'a t
44+
(** Create a fresh v7 UUID, as defined in RFC 9562 5.7. This incorporates a
45+
POSIX timestamp, such that the alphabetic of any two such UUIDs will match
46+
the timestamp order - provided that they are at least 245 nanoseconds
47+
apart. Note, however, that due to operating system time adjustments, these
48+
timestamps may not be monotonic. *)
49+
3850
val pp : Format.formatter -> 'a t -> unit
3951

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

0 commit comments

Comments
 (0)