Skip to content

Commit 24a4f75

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 code that constructs a v7 UUIDs from a time and some random bytes has been submitted in a PR to the uuidm module, with the intention that if it gets merged we can delete our version of it. * 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 as constructed by the uuidm module. * There is a function for generating v7 from known inputs, for the purpose of unit testing. Signed-off-by: Robin Newton <[email protected]>
1 parent ae952af commit 24a4f75

File tree

4 files changed

+134
-6
lines changed

4 files changed

+134
-6
lines changed

ocaml/libs/uuid/dune

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
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
10+
unix
11+
(re_export uuidm)
712
)
813
(wrapped false)
914
)

ocaml/libs/uuid/uuid_test.ml

Lines changed: 75 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
2969

3070
let uuid_testable : (module Alcotest.TESTABLE with type t = resource Uuidx.t) =
@@ -51,6 +91,39 @@ 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 zeros = Bytes.make 8 '\x00' in
104+
let test () =
105+
let result = Uuidx.make_v7_uuid_from_parts t zeros in
106+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
107+
in
108+
(expected_as_string, [("Make UUIDv7 from time", `Quick, test)])
109+
110+
let uuid_v7_bytes_tests (rand_b, expected_as_string) =
111+
let expected =
112+
match Uuidx.of_string expected_as_string with
113+
| Some uuid ->
114+
uuid
115+
| None ->
116+
Alcotest.fail
117+
(Printf.sprintf "Couldn't convert to UUID: %s" expected_as_string)
118+
in
119+
let test () =
120+
let bs = Bytes.create 8 in
121+
Bytes.set_int64_be bs 0 rand_b ;
122+
let result = Uuidx.make_v7_uuid_from_parts 0L bs in
123+
Alcotest.(check @@ uuid_testable) "make UUIDv7" expected result
124+
in
125+
(expected_as_string, [("Make UUIDv7 from bytes", `Quick, test)])
126+
54127
let string_roundtrip_tests testing_string =
55128
let testing_uuid =
56129
match Uuidx.of_string testing_string with
@@ -111,6 +184,8 @@ let regression_tests =
111184
; List.map array_roundtrip_tests uuid_arrays
112185
; List.map invalid_string_tests non_uuid_strings
113186
; List.map invalid_array_tests non_uuid_arrays
187+
; List.map uuid_v7_time_tests uuid_v7_times
188+
; List.map uuid_v7_bytes_tests uuid_v7_bytes
114189
]
115190

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

ocaml/libs/uuid/uuidx.ml

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,49 @@ 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 make_uuid_urnd () =
61+
let buf = read_bytes dev_urandom 16 in
62+
Uuidm.v4 buf
63+
64+
(* Implementation of UUIDv7 from https://github.com/dbuenzli/uuidm/pull/14. If
65+
this (or something like it) is merged then this can be deleted. *)
66+
let v7 =
67+
let open Int64 in
68+
let ns_in_ms = 1_000_000L in
69+
let sub_ms_frac_multiplier = unsigned_div minus_one ns_in_ms in
70+
fun ts b ->
71+
let u = Bytes.create 16 in
72+
Bytes.blit b 0 u 8 8 ;
73+
(* RFC9562 requires we use 48 bits for a timestamp in milliseconds, and
74+
allows for 12 bits to store a sub-millisecond fraction. We get the
75+
latter by multiplying to put the fraction in a 64-bit range, then
76+
shifting into 12 bits. *)
77+
let ms = unsigned_div ts ns_in_ms in
78+
let ns = unsigned_rem ts ns_in_ms in
79+
let sub_ms_frac = shift_right_logical (mul ns sub_ms_frac_multiplier) 52 in
80+
Bytes.set_int64_be u 0 (shift_left ms 16) ;
81+
Bytes.set_int16_be u 6 (to_int sub_ms_frac) ;
82+
let b6 = 0b0111_0000 lor (Char.code (Bytes.get u 6) land 0b0000_1111) in
83+
let b8 = 0b1000_0000 lor (Char.code (Bytes.get u 8) land 0b0011_1111) in
84+
Bytes.set u 6 (Char.unsafe_chr b6) ;
85+
Bytes.set u 8 (Char.unsafe_chr b8) ;
86+
Bytes.unsafe_to_string u
87+
88+
let make_v7_uuid_from_parts ts b = v7 ts b |> of_bytes |> Option.get
89+
90+
let make_v7_uuid =
91+
let start = Mtime_clock.counter () in
92+
let t0 =
93+
let d, ps = Ptime_clock.now () |> Ptime.to_span |> Ptime.Span.to_d_ps in
94+
Int64.(add (mul (of_int d) 86_400_000_000_000L) (div ps 1000L))
95+
in
96+
fun () ->
97+
let since_t0 = Mtime_clock.count start |> Mtime.Span.to_uint64_ns in
98+
make_v7_uuid_from_parts (Int64.add t0 since_t0) (read_bytes dev_urandom 8)
6199

62100
(* Use the CSPRNG-backed urandom *)
63101
let make = make_uuid_urnd
@@ -66,7 +104,7 @@ type cookie = string
66104

67105
let make_cookie () =
68106
read_bytes dev_urandom 64
69-
|> String.to_seq
107+
|> Bytes.to_seq
70108
|> Seq.map (fun c -> Printf.sprintf "%1x" (int_of_char c))
71109
|> List.of_seq
72110
|> String.concat ""

ocaml/libs/uuid/uuidx.mli

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,24 @@
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_v7_uuid_from_parts : int64 -> bytes -> 'a t
38+
(** For testing only: create a v7 UUID, as defined in RFC 9562 5.7 *)
39+
40+
val make_v7_uuid : unit -> 'a t
41+
(** Create a fresh v7 UUID, as defined in RFC 9562 5.7. This incorporates a
42+
POSIX timestamp, such that the alphabetic of any two such UUIDs will match
43+
the timestamp order - provided that they are at least 245 nanoseconds
44+
apart. Note that in order to ensure that the timestamps used are
45+
monotonic, operating time adjustments are ignored and hence timestamps
46+
only approximate system time. *)
47+
3848
val pp : Format.formatter -> 'a t -> unit
3949

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

0 commit comments

Comments
 (0)