Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
hoov_vin (1.0.0)
hoov_vin (2.0.0)
redis (~> 5)

GEM
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ count = 100
vin.generate_ids(data_type, count) # => [63801199693922306, 63801199693922307, … 98 other IDs … ]
```

```ruby
# Generate IDs with custom timestamps (e.g. for back-porting legacy systems, passing your db's created_at timestamps)
timestamps = [1646160000000, 1646160001000, 1646160002000] # Unix milliseconds
vin.generate_ids_from_timestamps(data_type, timestamps: timestamps)
# => [id_with_timestamp1, id_with_timestamp2, id_with_timestamp3]
```

```ruby
id_number = vin.generate_id(data_type) # => 63801532235120742
id = VIN::Id.new(id: id_number) # => #<VIN::Id:0x0000000108452ff0…>
Expand All @@ -188,6 +195,28 @@ id.timestamp.to_time # 2023-09-27 22:16:15.33 -0300 (Ruby Time object)
id.timestamp.epoch #=> 1688258040000, time since UNIX epoch in milliseconds
```

## Important Limitations

### Maximum IDs per Timestamp

**⚠️ CRITICAL:** When using `generate_ids_from_timestamps`, you cannot generate more than `max_sequence` IDs for the same timestamp. The `max_sequence` value is determined by your `sequence_bits` configuration, e.g.:

- With `sequence_bits: 11` → max 2,048 IDs per timestamp
- With `sequence_bits: 12` → max 4,096 IDs per timestamp
- With `sequence_bits: 10` → max 1,024 IDs per timestamp

**Why this matters:** If you request more IDs than `max_sequence` for the same timestamp, the sequence counter would wrap around and start from 0 again, creating IDs with identical timestamp and sequence values. This would result in duplicate IDs.

**Protection:** The `generate_ids_from_timestamps` method automatically validates this constraint and **raises an ArgumentError** if you try to exceed the limit, preventing duplicate IDs from being generated.

If you are back-porting a legacy system that has more than `max_sequence` records with the same timestamp, you will need to either:

1. **Recommended: Group the timestamps in batches**, ensuring that each batch has the size of `max_sequence` and when it exceeds, you increment the timestamp by 1 millisecond. Repeat this process with all timestamps again once finished, until you don't have to increment any timestamp in the entire sequence anymore.
- This means your VINs will not have exactly the same timestamp as the legacy IDs, but they will be very close, and you will avoid collisions. This is a good trade-off, especially when back-porting legacy systems.
2. **Re-consider the number of sequence bits** to ensure that it fits your business needs.

Note that this problem doesn't happen when generating IDs during standard usage via `generate_id` or `generate_ids` without passing timestamps as arguments, as those methods internally manage the timestamp and sequence to ensure uniqueness.

# Configuration

The VIN generator can be configured with the following parameters:
Expand Down
12 changes: 8 additions & 4 deletions lib/vin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,27 @@ def initialize(config: nil)
@config = config || VIN::Config.new
end

def generate_id(data_type, timestamp: nil)
generator.generate_ids(data_type, 1, timestamp: timestamp).first
def generate_id(data_type)
generator.generate_id(data_type)
end

def generate_ids(data_type, count, timestamp: nil)
def generate_ids(data_type, count)
ids = []
# The Lua script can't always return as many IDs as you may want. So we loop
# until we have the exact amount.
while ids.length < count
initial_id_count = ids.length
ids += generator.generate_ids(data_type, count - ids.length, timestamp: timestamp)
ids += generator.generate_ids(data_type, count - ids.length)
# Ensure the ids array keeps growing as infinite loop insurance
return ids unless ids.length > initial_id_count
end
ids
end

def generate_ids_from_timestamps(data_type, timestamps:)
generator.generate_ids_from_timestamps(data_type, timestamps: timestamps)
end

private

def generator
Expand Down
101 changes: 92 additions & 9 deletions lib/vin/generator.rb
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
require "vin/config"

class VIN
# rubocop:disable Metrics/ClassLength
class Generator
attr_reader :data_type, :count, :config, :custom_timestamp

def initialize(config:)
@config = config
end

def generate_ids(data_type, count = 1, timestamp: nil)
raise(ArgumentError, "data_type must be an integer") unless data_type.is_a?(Integer)
def generate_id(data_type)
validate_data_type!(data_type)

generate_single_batch(data_type, 1, nil).first
end

def generate_ids(data_type, count)
validate_data_type!(data_type)
validate_count!(count)

generate_single_batch(data_type, count, nil)
end

def generate_ids_from_timestamps(data_type, timestamps:)
validate_data_type!(data_type)
raise(ArgumentError, "timestamps must be an array") unless timestamps.is_a?(Array)
raise(ArgumentError, "timestamps array cannot be empty") if timestamps.empty?

timestamps.each { |ts| validate_timestamp!(ts) }

unless config.data_type_allowed_range.include?(data_type)
raise(ArgumentError, "data_type is outside the allowed range of #{config.data_type_allowed_range}")
# Check for potential duplicate ID issue
timestamp_counts = timestamps.tally
max_count = timestamp_counts.values.max
if max_count > config.max_sequence
raise ArgumentError,
"Cannot generate #{max_count} IDs for the same timestamp. " \
"Maximum allowed is #{config.max_sequence} (2^#{config.sequence_bits} - 1). " \
"Requesting more would produce duplicate IDs."
end

generate_ids_for_multiple_timestamps(data_type, timestamps)
end

private

def validate_data_type!(data_type)
raise(ArgumentError, "data_type must be an integer") unless data_type.is_a?(Integer)

return if config.data_type_allowed_range.include?(data_type)
raise(ArgumentError, "data_type is outside the allowed range of #{config.data_type_allowed_range}")
end

def validate_count!(count)
raise(ArgumentError, "count must be an integer") unless count.is_a?(Integer)
raise(ArgumentError, "count must be a positive number") if count < 1
end

if timestamp
validate_timestamp!(timestamp)
end

def generate_single_batch(data_type, count, timestamp)
@data_type = data_type
@count = count
@custom_timestamp = timestamp
Expand All @@ -39,7 +74,54 @@ def generate_ids(data_type, count = 1, timestamp: nil)
result
end

private
def generate_ids_for_multiple_timestamps(data_type, timestamps)
# Group timestamps to optimize Redis calls
# Each unique timestamp needs only one Redis call, then we can reuse sequences
timestamp_groups = {}
timestamps.each_with_index do |ts, index|
timestamp_groups[ts] ||= []
timestamp_groups[ts] << index
end

# Result array to maintain order
results = Array.new(timestamps.length)

# Process each unique timestamp
timestamp_groups.each do |ts, indices|
needed_count = indices.length
ids = []

# The Lua script can't always return as many IDs as you want. So we loop
# until we have the exact amount.
while ids.length < needed_count
@data_type = data_type
@count = needed_count - ids.length
@custom_timestamp = ts

batch_ids = response.sequence.map do |sequence|
(
shifted_timestamp |
shifted_logical_shard_id |
shifted_data_type |
(sequence << config.sequence_shift)
)
end

ids += batch_ids
@response = nil

# Safety check to prevent infinite loops
break unless batch_ids.any?
end

# Place IDs in correct positions to maintain order
indices.each_with_index do |original_index, i|
results[original_index] = ids[i] if i < ids.length
end
end

results
end

def shifted_timestamp
timestamp = if custom_timestamp
Expand Down Expand Up @@ -70,4 +152,5 @@ def response
@response ||= Request.new(config, data_type, count, custom_timestamp: custom_timestamp).response
end
end
# rubocop:enable Metrics/ClassLength
end
2 changes: 1 addition & 1 deletion lib/vin/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
class VIN
VERSION = "1.0.0".freeze
VERSION = "2.0.0".freeze
end
Loading