hyperuuid 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/hyperuuid.rb CHANGED
@@ -1,49 +1,91 @@
1
- require "time"
2
-
1
+ require_relative "hyperuuid/errors"
3
2
  require_relative "hyperuuid/uuid"
4
3
  require_relative "hyperuuid/namespaces"
5
4
  require_relative "hyperuuid/native_platform"
6
5
  require_relative "hyperuuid/runtime"
7
6
 
8
- # RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation, calling
9
- # directly into the native libhyperuuid shared library via Fiddle — no runtime bridge, no
10
- # extra gem dependency. Bundles a native build for every supported platform (see
11
- # HyperUuid::NativePlatform) and picks the right one at runtime, the same trick the Go/
12
- # Java bindings use since RubyGems has no per-platform native selection wired up here.
7
+ # RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation from one
8
+ # Rust core, reached through whichever of three backends this install can load: the core
9
+ # linked straight into a Magnus native extension (shipped precompiled in the platform gems),
10
+ # the native libhyperuuid shared library called through Fiddle (the universal gem bundles
11
+ # one per supported platform, picked by HyperUuid::NativePlatform), or the same core as a
12
+ # WebAssembly module inside the wasmtime gem. No runtime bridge in any of them, and nothing
13
+ # compiled at install time. HyperUuid::BACKEND names the one that loaded; the selection logic
14
+ # is at the bottom of this file.
15
+ #
16
+ # The doors below — with Uuid, Namespaces and the two exception classes — are the public
17
+ # surface, and are shared by every backend, argument validation included: a caller bug raises
18
+ # the same error whichever backend is live.
13
19
  module HyperUuid
14
20
  # This gem's own version — distinct from the RFC 9562 UUID *versions* (v4/v5/v6/v7) the
15
21
  # rest of this module generates.
16
- VERSION = "0.2.1"
22
+ VERSION = "0.4.0"
23
+
24
+ # The widest batch count, and the longest v5 name in bytes, the native ABI carries (a u32).
25
+ # (The millisecond count is a u64; unix_millis_from refuses anything wider.)
26
+ U32_MAX = 0xFFFF_FFFF
27
+ private_constant :U32_MAX
17
28
 
18
29
  # Creates a random UUID version 4 (RFC 9562 §5.4).
19
30
  def self.new_v4
20
- Uuid.new(Runtime.new_v4)
31
+ Uuid.new(Runtime.new_v4, true)
21
32
  end
22
33
 
23
34
  # Creates a deterministic UUID version 5 (RFC 9562 §5.5) from a namespace and a name. The
24
35
  # same (namespace, name) pair always produces the same UUID. `name` may be a text String
25
- # (encoded as UTF-8) or already-raw ASCII-8BIT bytes, which are used as-is.
36
+ # (encoded as UTF-8) or already-raw ASCII-8BIT bytes, which are used as-is; it may be empty.
37
+ #
38
+ # @raise [TypeError] if +namespace+ isn't a HyperUuid::Uuid or +name+ isn't a String.
26
39
  def self.new_v5(namespace, name)
40
+ raise TypeError, "namespace must be a HyperUuid::Uuid; got #{namespace.class}" unless namespace.is_a?(Uuid)
41
+ raise TypeError, "name must be a String; got #{name.class}" unless name.is_a?(String)
42
+
27
43
  name_bytes =
28
44
  if name.encoding == Encoding::ASCII_8BIT
29
45
  name
30
46
  else
31
47
  name.encode(Encoding::UTF_8).dup.force_encoding(Encoding::BINARY)
32
48
  end
33
- Uuid.new(Runtime.new_v5(namespace.bytes, name_bytes))
49
+ raise ArgumentError, "name must be at most #{U32_MAX} bytes" if name_bytes.bytesize > U32_MAX
50
+
51
+ Uuid.new(Runtime.new_v5(namespace.bytes, name_bytes), true)
34
52
  end
35
53
 
36
54
  # Converts +value+ to a Unix-epoch millisecond integer: +nil+ becomes the current time, a
37
55
  # +Time+ is converted exactly (via its own Rational seconds, avoiding float rounding), and
38
- # anything else (an Integer millisecond count) passes through unchanged. Shared by every
56
+ # an Integer millisecond count passes through unchanged. Shared by every
39
57
  # `new_v6`/`new_v7`/batch door below so a caller can pass either a `Time` or a raw
40
58
  # millisecond count interchangeably.
41
- private_class_method def self.unix_millis_from(value)
42
- case value
43
- when nil then Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
44
- when Time then (value.to_r * 1000).to_i
45
- else value
46
- end
59
+ #
60
+ # This is also where a caller bug is caught once, for every backend: anything else is a
61
+ # TypeError, and a count that cannot cross the ABI as a u64 at all — negative (a `Time`
62
+ # before the epoch included) or past 64 bits — is the same TimestampOutOfRangeError, with
63
+ # the same +out_of_range+ message, the core raises for a value it can see but not embed.
64
+ # Left to the backends those cases diverged: a RangeError from the extension, a wrapped
65
+ # value from Fiddle, a silently truncated one from wasm.
66
+ #
67
+ # The two common arguments go first and cheapest: no argument is the clock, which needs no
68
+ # range check, and an Integer is checked by its bit length rather than against 2**64 - 1: a
69
+ # comparison with a number that size is a bignum comparison, and cost more than the mint.
70
+ private_class_method def self.unix_millis_from(value, out_of_range)
71
+ return Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) if value.nil?
72
+
73
+ millis =
74
+ if value.is_a?(Integer) then value
75
+ elsif value.is_a?(Time) then (value.to_r * 1000).floor
76
+ else raise TypeError, "unix_millis must be a Time, an Integer or nil; got #{value.class}"
77
+ end
78
+ raise TimestampOutOfRangeError, out_of_range unless millis >= 0 && millis.bit_length <= 64
79
+
80
+ millis
81
+ end
82
+
83
+ # Checks a batch +count+ once, for every backend: an Integer the ABI's u32 can carry.
84
+ private_class_method def self.batch_count(count)
85
+ raise TypeError, "count must be an Integer; got #{count.class}" unless count.is_a?(Integer)
86
+ raise ArgumentError, "count must be between 0 and #{U32_MAX}; got #{count}" unless count.between?(0, U32_MAX)
87
+
88
+ count
47
89
  end
48
90
 
49
91
  # Creates a time-sortable UUID version 6 (RFC 9562 §5.6), a field-compatible reordering of
@@ -51,49 +93,66 @@ module HyperUuid
51
93
  # `Time` or Unix-epoch millisecond integer to embed a specific time instead. `clock_seq` and
52
94
  # `node` are randomly generated on every call — unlike version 7, there is no monotonic
53
95
  # counter, so calls within the same millisecond are not guaranteed to sort in creation order.
96
+ #
97
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
98
+ # @raise [TypeError] if +unix_millis+ isn't a Time, an Integer or nil.
54
99
  def self.new_v6(unix_millis = nil)
55
- Uuid.new(Runtime.new_v6(unix_millis_from(unix_millis)))
100
+ Uuid.new(Runtime.new_v6(unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE)), true)
56
101
  end
57
102
 
58
103
  # Creates `count` time-sortable version 6 UUIDs sharing one timestamp capture — one FFI call
59
104
  # and one random-bytes fetch instead of `count` of each. Defaults to the current time; pass
60
105
  # an explicit `Time` or Unix-epoch millisecond integer to embed a specific time instead.
106
+ #
107
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
108
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
61
109
  def self.new_v6_batch(count, unix_millis = nil)
62
- bytes = Runtime.new_v6_batch(count, unix_millis_from(unix_millis))
63
- Array.new(count) { |i| Uuid.new(bytes[i * 16, 16]) }
110
+ bytes = new_v6_batch_bytes(count, unix_millis)
111
+ Array.new(count) { |i| Uuid.new(bytes[i * 16, 16], true) }
64
112
  end
65
113
 
66
114
  # Creates a time-sortable UUID version 7 (RFC 9562 §6.2). Defaults to the current time; pass
67
115
  # an explicit `Time` or Unix-epoch millisecond integer (non-negative, fitting in 48 bits) to
68
116
  # embed a specific time instead.
117
+ #
118
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
119
+ # @raise [TypeError] if +unix_millis+ isn't a Time, an Integer or nil.
69
120
  def self.new_v7(unix_millis = nil)
70
- Uuid.new(Runtime.new_v7(unix_millis_from(unix_millis)))
121
+ Uuid.new(Runtime.new_v7(unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE)), true)
71
122
  end
72
123
 
73
124
  # Creates `count` time-sortable version 7 UUIDs sharing one timestamp capture and one
74
125
  # contiguous block of the monotonic counter — one FFI call and one random-bytes fetch
75
126
  # instead of `count` of each. Defaults to the current time; pass an explicit `Time` or
76
127
  # Unix-epoch millisecond integer to embed a specific time instead.
128
+ #
129
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
130
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
77
131
  def self.new_v7_batch(count, unix_millis = nil)
78
- bytes = Runtime.new_v7_batch(count, unix_millis_from(unix_millis))
79
- Array.new(count) { |i| Uuid.new(bytes[i * 16, 16]) }
132
+ bytes = new_v7_batch_bytes(count, unix_millis)
133
+ Array.new(count) { |i| Uuid.new(bytes[i * 16, 16], true) }
80
134
  end
81
135
 
82
136
  # Returns `count` version 7 UUIDs as one binary String of raw RFC 9562-ordered bytes,
83
137
  # 16 per UUID, instead of an Array of Uuid objects.
84
138
  #
85
- # Roughly 11x faster than #new_v7_batch for a 1000-UUID batch (about 35 us versus 400 us).
86
- # The difference is not the native call — that is identical — it is that #new_v7_batch then
87
- # allocates `count` Uuid objects and `count` String slices on top of it. This hands back the
88
- # bytes the native core already produced, untouched.
139
+ # Far faster than #new_v7_batch for a large batch — the README's "Bulk generation into
140
+ # bytes" section carries the measured figures. The difference is not the native call —
141
+ # that is identical — it is that #new_v7_batch then allocates `count` Uuid objects and
142
+ # their byte Strings on top of it. This hands back the bytes the native core already
143
+ # produced, untouched.
89
144
  #
90
145
  # Use it when bytes are the destination: a BYTEA/uniqueidentifier bind parameter, a wire
91
146
  # format, a bulk COPY. If you need Uuid objects, keep using #new_v7_batch — slicing this
92
147
  # String into them yourself just moves the same allocations into your own code.
93
148
  #
94
149
  # Slice it with `bytes[i * 16, 16]`, which is what #new_v7_batch does internally.
150
+ #
151
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
152
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 48-bit v7 field.
95
153
  def self.new_v7_batch_bytes(count, unix_millis = nil)
96
- Runtime.new_v7_batch(count, unix_millis_from(unix_millis))
154
+ count = batch_count(count)
155
+ Runtime.new_v7_batch(count, unix_millis_from(unix_millis, Runtime::V7_TIMESTAMP_OUT_OF_RANGE))
97
156
  end
98
157
 
99
158
  # Returns `count` version 6 UUIDs as one binary String of raw RFC 9562-ordered bytes,
@@ -103,8 +162,42 @@ module HyperUuid
103
162
  # clock_seq and node are independently random per item; unlike version 7 there is no
104
163
  # monotonic counter, so items minted in the same millisecond are not guaranteed to sort in
105
164
  # creation order.
165
+ #
166
+ # @raise [TypeError, ArgumentError] if +count+ isn't an Integer between 0 and 2**32 - 1.
167
+ # @raise [TimestampOutOfRangeError] if the time is negative or past the 60-bit v6 field.
106
168
  def self.new_v6_batch_bytes(count, unix_millis = nil)
107
- Runtime.new_v6_batch(count, unix_millis_from(unix_millis))
169
+ count = batch_count(count)
170
+ Runtime.new_v6_batch(count, unix_millis_from(unix_millis, Runtime::V6_TIMESTAMP_OUT_OF_RANGE))
171
+ end
172
+
173
+ # The version of the native core actually loaded, as "major.minor.patch" — read from the
174
+ # library itself (its `hyperuuid_version` export), not from this gem, so a consumer can
175
+ # prove the core behind the doors is the one this binding was built against (and name the
176
+ # mismatch against HyperUuid::VERSION when it is not). The cheapest possible probe that the
177
+ # backend resolved at all: takes nothing, mints nothing, cannot fail once the backend loads.
178
+ #
179
+ # @raise [LoadError] if no backend could be loaded for this platform.
180
+ def self.native_version
181
+ word = Runtime.packed_version
182
+ "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
183
+ end
184
+
185
+ # Whether a backend actually loaded and exports the ABI this binding was built against —
186
+ # what a consumer with a fallback of its own (SecureRandom.uuid, say) checks before
187
+ # committing to these doors. Probed once (a native_version round trip), cached, and never
188
+ # raises: a missing shared library, an unsupported platform, an older core without the
189
+ # version export, or a wasm module the wasmtime gem cannot instantiate all answer false.
190
+ # The doors themselves keep their own behavior — the first call on an unavailable backend
191
+ # raises its precise error — and so does require-time selection under HYPERUUID_WASM=1
192
+ # without wasmtime; this only answers the question quietly.
193
+ def self.available?
194
+ return @available unless @available.nil?
195
+
196
+ @available = begin
197
+ native_version.is_a?(String)
198
+ rescue LoadError, StandardError
199
+ false
200
+ end
108
201
  end
109
202
  end
110
203
 
@@ -115,16 +208,39 @@ end
115
208
  # remain the universal zero-compile fallback; precompiled platform gems are how the
116
209
  # extension ships without ever making a consumer compile anything. Set HYPERUUID_PURE=1 to
117
210
  # force Fiddle.
211
+ #
212
+ # The third backend is WebAssembly (lib/hyperuuid/wasm_runtime.rb): the same core as a
213
+ # wasm32-wasip1 module, run in-process by the `wasmtime` gem, which is deliberately not a
214
+ # runtime dependency of this gem — a consumer who wants it installs it. HYPERUUID_WASM=1
215
+ # forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
216
+ # there is no native library for this platform at all and wasmtime happens to be available,
217
+ # so no supported platform's behavior changes by its existence.
218
+ #
219
+ # Both variables are read for presence, not value — set to anything at all, "0" and the
220
+ # empty string included, they force their backend — and HYPERUUID_WASM wins when both are
221
+ # set.
118
222
  HyperUuid::BACKEND =
119
- if ENV["HYPERUUID_PURE"]
223
+ if ENV["HYPERUUID_WASM"]
224
+ begin
225
+ require "wasmtime"
226
+ rescue LoadError
227
+ raise LoadError,
228
+ "hyperuuid: HYPERUUID_WASM=1 needs the wasmtime gem — `gem install wasmtime` (or add it to your Gemfile)"
229
+ end
230
+ require_relative "hyperuuid/wasm_runtime"
231
+ :wasm
232
+ elsif ENV["HYPERUUID_PURE"]
120
233
  :fiddle
121
234
  else
122
235
  # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
123
236
  # extension per supported Ruby ABI under lib/hyperuuid/<minor>/ (see the Rakefile's
124
237
  # native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
125
- # equivalent). CI's in-job staging and a local `cargo build --release --features ruby`
126
- # instead drop a single extension flat at lib/. Trying the versioned path first and the
127
- # flat one second means neither has to know the other exists.
238
+ # equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
239
+ # same versioned path. CI's in-job staging additionally drops a single extension flat at
240
+ # lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
241
+ # builds into rust/target/ruby/release/; nothing but that rake task copies it here).
242
+ # Trying the versioned path first and the flat one second means neither has to know the
243
+ # other exists.
128
244
  #
129
245
  # A miss on both is not an error: it means this Ruby/platform combination has no
130
246
  # precompiled extension, which is precisely what the Fiddle backend below is for.
@@ -136,7 +252,22 @@ HyperUuid::BACKEND =
136
252
  require "hyperuuid_native"
137
253
  :native
138
254
  rescue LoadError
139
- :fiddle
255
+ if HyperUuid::Runtime.fiddle_library_available?
256
+ :fiddle
257
+ else
258
+ # No shared library for this platform either. wasmtime, if the consumer has it,
259
+ # is the only backend left that can run here; without it, stay on Fiddle so the
260
+ # first call raises its own precise error — a "not found" LoadError naming the
261
+ # path, or NativePlatform::UnsupportedPlatformError naming the platform — rather
262
+ # than a vaguer one from here. HyperUuid.available? is the quiet way to ask.
263
+ begin
264
+ require "wasmtime"
265
+ require_relative "hyperuuid/wasm_runtime"
266
+ :wasm
267
+ rescue LoadError
268
+ :fiddle
269
+ end
270
+ end
140
271
  end
141
272
  end
142
273
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperuuid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: benchmark-ips
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '2.15'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '2.15'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: rake
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -66,9 +52,12 @@ dependencies:
66
52
  - !ruby/object:Gem::Version
67
53
  version: '0.9'
68
54
  description: |
69
- High-performance, allocation-free RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7
70
- (time-sortable) generation, calling directly into the native libhyperuuid shared library
71
- via Fiddle (Ruby's standard-library FFI) — no runtime bridge, no extra gem dependency.
55
+ RFC 9562 UUID v4 (random), v5 (deterministic), v6 and v7 (time-sortable) generation, with
56
+ batch and raw-bytes forms, from one native Rust core. Three backends behind one surface,
57
+ selected automatically and all shipped prebuilt: a Magnus extension where a precompiled
58
+ platform gem matches, stdlib Fiddle everywhere else, and the same core as a WebAssembly
59
+ module for anyone who installs the wasmtime gem. No runtime bridge, no dependencies
60
+ beyond Fiddle.
72
61
  executables: []
73
62
  extensions: []
74
63
  extra_rdoc_files: []
@@ -76,22 +65,30 @@ files:
76
65
  - LICENSE
77
66
  - README.md
78
67
  - lib/hyperuuid.rb
68
+ - lib/hyperuuid/errors.rb
79
69
  - lib/hyperuuid/namespaces.rb
80
- - lib/hyperuuid/native/README.md
81
70
  - lib/hyperuuid/native/linux-arm64/libhyperuuid.so
71
+ - lib/hyperuuid/native/linux-musl-arm64/libhyperuuid.so
72
+ - lib/hyperuuid/native/linux-musl-x64/libhyperuuid.so
82
73
  - lib/hyperuuid/native/linux-x64/libhyperuuid.so
83
74
  - lib/hyperuuid/native/osx-arm64/libhyperuuid.dylib
84
75
  - lib/hyperuuid/native/osx-x64/libhyperuuid.dylib
76
+ - lib/hyperuuid/native/wasm32-wasip1/hyperuuid.wasm
85
77
  - lib/hyperuuid/native/win-arm64/hyperuuid.dll
86
78
  - lib/hyperuuid/native/win-x64/hyperuuid.dll
87
79
  - lib/hyperuuid/native_platform.rb
88
80
  - lib/hyperuuid/runtime.rb
89
81
  - lib/hyperuuid/uuid.rb
82
+ - lib/hyperuuid/wasm_runtime.rb
90
83
  homepage: https://github.com/SkunkWerkx/HyperUuid
91
84
  licenses:
92
85
  - MIT
93
86
  metadata:
94
87
  source_code_uri: https://github.com/SkunkWerkx/HyperUuid
88
+ changelog_uri: https://github.com/SkunkWerkx/HyperUuid/blob/master/CHANGELOG.md
89
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperUuid/issues
90
+ documentation_uri: https://github.com/SkunkWerkx/HyperUuid/tree/master/ruby#readme
91
+ rubygems_mfa_required: 'true'
95
92
  rdoc_options: []
96
93
  require_paths:
97
94
  - lib
@@ -99,15 +96,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
96
  requirements:
100
97
  - - ">="
101
98
  - !ruby/object:Gem::Version
102
- version: '3.2'
99
+ version: '3.3'
103
100
  required_rubygems_version: !ruby/object:Gem::Requirement
104
101
  requirements:
105
102
  - - ">="
106
103
  - !ruby/object:Gem::Version
107
104
  version: '0'
108
105
  requirements: []
109
- rubygems_version: 4.0.16
106
+ rubygems_version: 4.0.20
110
107
  specification_version: 4
111
- summary: RFC 9562 UUID v4/v5/v6/v7 generation — direct native FFI into a Rust core,
112
- no runtime bridge.
108
+ summary: RFC 9562 UUID v4/v5/v6/v7 generation over a native Rust core, shipped prebuilt
113
109
  test_files: []
@@ -1,6 +0,0 @@
1
- # native/
2
-
3
- Populated per-RID with the platform's native `libhyperuuid` build (`native/{rid}/{lib}`) by CI
4
- and by `cargo build --release` for local dev — see `../../../.gitignore`. This file exists so
5
- the directory has at least one tracked file on a fresh checkout, matching the Go/Swift
6
- bindings' `native/`/`NativeLibs/` placeholder convention.