Skip to content

Instantly share code, notes, and snippets.

@mediafinger
Created October 15, 2022 12:55
Show Gist options
  • Save mediafinger/a52b0fae444f189a29e441691eee3ce7 to your computer and use it in GitHub Desktop.
Save mediafinger/a52b0fae444f189a29e441691eee3ce7 to your computer and use it in GitHub Desktop.
Fancy UUIDs - sortable UUID v4 compatible UUIDs with encoded timestamp and model name

Fancy UUIDs

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.

Compatibility

Fancy UUIDs are just UUID v4 UUIDs - PostgreSQL and any other tool will be able to treat them like any other UUID v4 UUIDs.

Security

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.

Privacy

As the exact timestamp in encoded into the Fancy UUID, this could reveal information about your user's behavior.

Performance

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.

Benefits of Fancy UUIDs

  • sortable
  • encode created_at timestamp
  • encode model name
  • compatible with UUID v4

Implementation

Using Ruby on Rails code for the example:

Database Migration

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

ActiveRecord Model

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 Test

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment