UUID v4 UUIDs have the big drawback of being non-sortable. Fancy UUIDs encode the timestamp (just one digit short of microseconds) on the first bits and are therefore sortable.
They also encode a shortname of the generating model class, to make them globally identifiable.
Fancy UUIDs are just UUID v4 UUIDs - PostgreSQL and any other tool will be able to treat them like any other UUID v4 UUIDs.
Instead of using 122bit of the 128bit for randomness, Fancy UUIDs use:
- the first 32bit for the time in seconds
- the next 16bit for the time in almost microseconds (5 digits or μ * 10)
- the last 32bit to store the shortname of the model
Which leave only 42bit of randomness per one-hundred-thousandth of a second.
This might not be good enough for Google or Twitter, but on this scale you might need to include a node-id or similar in your UUIDs to generate them in a distributed way.
I'd say it will be good enough for most other use cases.
As the exact timestamp in encoded into the Fancy UUID, this could reveal information about your user's behavior.
I didn't properly benchmark them, they will take a little bit longer to generate. If you operate on a scale where this is relevant, they might not be a good choice. Though as the current example implementation is in an early draft state, there is also plenty of room for optimization.
- sortable
- encode created_at timestamp
- encode model name
- compatible with UUID v4
Using Ruby on Rails code for the example:
class CreateFancyModel < ActiveRecord::Migration[7.0]
def change
create_table "fancies", force: :cascade do |t|
t.uuid "uuid", null: false
# actually created_at will already be encoded in the fancy_uuid
t.timestamps
t.index ["uuid"], name: "index_fancies_on_uuid", unique: true
end
end
end
class Fancy < ApplicationRecord
# e.g. 666e6379 in hex to identify the model class
# must be unique throughout the app and have exactly 4 characters
#
def fancy_shortname
"fncy"
end
after_initialize :create_fancy_uuid
validates :uuid, presence: true
# TODO: make this available on an Admin endpoint
# which expects any fancy_uuid and
# returns the information of this method
# and links the actual object
#
def unpack_uuid
time_in_seconds = uuid[0..12].split("-").join.to_i(16) / 100_000.0
iso_time = Time.zone.at(time_in_seconds).rfc3339
fancy_shortname = [uuid[28..35]].pack("H*") # TODO: lookup Model Name
random_part = uuid[14..27]
{
fancy_uuid: uuid,
instance_of: fancy_shortname,
generated_at: iso_time,
uid: random_part,
}
end
protected
# executed in after_validation callback
#
def create_fancy_uuid
return if uuid.present?
retries ||= 0
# the .dup is only needed for the spec with the mock ^^
uuidv4 = SecureRandom.uuid.dup
# e.g "666e6379" shortname in hex to identify the model class
slug = fancy_shortname.each_byte.map { |b| b.to_s(16) }.join
# e.g. "978189ddd90c" 'Microseconds' in 10 µm buckets (5 digits) in hex
hex_time = DateTime.now.strftime('%s%5N').to_i.to_s(16)
uuidv4[0..7] = hex_time[0..7] # seconds
uuidv4[9..12] = hex_time[8..11] # seconds / 100_000 (5 digits)
uuidv4[28..35] = slug[0..7] # model class shortname
self.uuid = uuidv4
rescue StandardError
raise if (retries += 1) > 2
retry
end
end
RSpec.describe Fancy do
subject(:fancy) { Fancy.new }
describe "#create_fancy_uuid" do
let(:date_1) { 1.year.ago }
let(:date_2) { 1.day.ago }
let(:date_3) { Time.current }
let(:date_4) { 5.seconds.from_now }
let(:date_5) { 1.hour.from_now }
it "creates a fancy uuid" do
freeze_time do
allow(SecureRandom).to receive(:uuid).and_return("2f03803d-9095-49e1-abcd-a295c524b02f")
hex_time = DateTime.now.strftime("%s%5N").to_i.to_s(16)
slug = fancy.fancy_shortname.each_byte.map { |b| b.to_s(16) }.join
expect(fancy.uuid).to be_present
expect(fancy.uuid).to eq("#{hex_time[0..7]}-#{hex_time[8..12]}-49e1-abcd-a295#{slug[0..7]}")
end
end
it "generates sortable uuids" do
travel_to(date_3) { @date_3_fancy = Fancy.create! }
travel_to(date_2) { @date_2_fancy = Fancy.create! }
travel_to(date_5) { @date_5_fancy = Fancy.create! }
travel_to(date_4) { @date_4_fancy = Fancy.create! }
travel_to(date_1) { @date_1_fancy = Fancy.create! }
expect(fancy.order(:uuid).pluck(:uuid)).to eq(
[
@date_1_fancy.uuid,
@date_2_fancy.uuid,
@date_3_fancy.uuid,
@date_4_fancy.uuid,
@date_5_fancy.uuid,
]
)
expect(fancy.order(:uuid).pluck(:created_at)).to eq(fancy.order(:created_at).pluck(:created_at))
end
end
describe "#unpack_uuid" do
it "decodes the information in the uuid" do
freeze_time do
allow(SecureRandom).to receive(:uuid).and_return("2f03803d-9095-49e1-abcd-a295c524b02f")
expect(fancy.unpack_uuid).to eq(
{
fancy_uuid: fancy.uuid,
instance_of: "fncy",
generated_at: Time.current.rfc3339,
uid: "49e1-abcd-a295",
}
)
end
end
end
end