diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..63121e0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(bundle exec:*)", + "Bash(bundle install:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 71fd791..04f90b1 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -2,8 +2,6 @@ name: Tests & Linter on: push jobs: test: - env: - CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} strategy: fail-fast: false matrix: diff --git a/.rubocop.yml b/.rubocop.yml index b14f3d4..588304a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,17 @@ -require: +# Last reviewed on July 14th 2025, using RuboCop v1.77.0 + +plugins: - rubocop-rake - rubocop-rspec AllCops: NewCops: enable - TargetRubyVersion: 3.1 + TargetRubyVersion: 3.2 +# As of Ruby 3.0+, # frozen_string_literal: true is no longer necessary in most cases, and in Rails (7.x and upcoming 8), it’s not considered a best practice anymore to manually add it to every file. Style/FrozenStringLiteralComment: Enabled: true + EnforcedStyle: never Metrics/MethodLength: Max: 30 @@ -56,15 +60,19 @@ Layout/EndAlignment: EnforcedStyleAlignWith: start_of_line # The default style is `special_inside_parentheses` which looks like: -# in_a_method_call({ -# some_key: "value", -# }) +# +# this_is_a_method_call({ +# some_key: "value", +# }) +# # This would cause an unnecessary huge diff if `in_a_method_call` is renamed, # because all following lines need to be re-indented. That's why we prefer # the layout like this: -# in_a_method_call({ -# some_key: "value", -# }) +# +# this_is_a_method_call({ +# some_key: "value", +# }) +# Layout/FirstHashElementIndentation: EnforcedStyle: consistent @@ -187,18 +195,58 @@ Style/Documentation: Style/MethodCallWithArgsParentheses: Enabled: true EnforcedStyle: require_parentheses - Exclude: - - "spec/**/*" + AllowParenthesesInMultilineCall: true + AllowedMethods: + - 'attr_accessor' + - 'attr_reader' + - 'command' + - 'desc' + - 'gem' + - 'lane' + - 'platform' + - 'program' + - 'raise' + - 'require_relative' + - 'require' + # rspec tests code below + - 'after' + - 'be' + - 'before' + - 'context' + - 'describe' + - 'it' + - 'not_to' + - 'to' RSpec/NamedSubject: + Enabled: true + EnforcedStyle: named_only + +RSpec/ExampleLength: Enabled: false +# This cop is not helpful - developers should be able to decide how many memoized helpers are adequate for their tests. RSpec/MultipleMemoizedHelpers: Enabled: false +# Multiple expectations in a single example are not necessarily bad, and can be useful to avoid unnecessary duplication and keep tests concise. RSpec/MultipleExpectations: Enabled: false +# It catches the false positive of `set_up_`, which is the right spelling for the method's name, so we disable it. +Naming/AccessorMethodName: + Enabled: false + +# Checks the indentation of the method name part in method calls that span more than one line, and prefer the style that uses consistent indentation instead of the default (aligned with the first line). +Layout/MultilineMethodCallIndentation: + Enabled: true + EnforcedStyle: indented + +# Disallow multiple spaces that aim to align code. +Layout/ExtraSpacing: + Enabled: true + AllowForAlignment: false + RSpec/NestedGroups: Enabled: true Max: 4 @@ -206,4 +254,4 @@ RSpec/NestedGroups: Style/ClassVars: Enabled: true Exclude: - - "lib/vin/request.rb" + - "lib/vin/request.rb" diff --git a/Gemfile b/Gemfile index 949d677..4acc92c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,3 @@ -# frozen_string_literal: true - source "https://rubygems.org" gemspec diff --git a/Gemfile.lock b/Gemfile.lock index abc2585..7833734 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,77 +7,73 @@ PATH GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - base64 (0.1.1) + ast (2.4.3) coderay (1.1.3) - connection_pool (2.4.1) - diff-lcs (1.5.0) - docile (1.4.0) - dotenv (2.8.1) - json (2.6.3) - language_server-protocol (3.17.0.3) - method_source (1.0.0) - parallel (1.23.0) - parser (3.2.2.3) + connection_pool (2.5.3) + diff-lcs (1.6.2) + docile (1.4.1) + dotenv (3.1.8) + json (2.13.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + method_source (1.1.0) + parallel (1.27.0) + parser (3.3.9.0) ast (~> 2.4.1) racc pastel (0.8.0) tty-color (~> 0.5) - pry (0.14.2) + prism (1.4.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - racc (1.7.1) + racc (1.8.1) rainbow (3.1.1) - rake (13.0.6) - redis (5.0.7) - redis-client (>= 0.9.0) - redis-client (0.17.0) + rake (13.3.0) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.25.2) connection_pool - regexp_parser (2.8.1) - rexml (3.2.6) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + regexp_parser (2.11.2) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.6) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.1) - rubocop (1.56.3) - base64 (~> 0.1.1) + rspec-support (~> 3.13.0) + rspec-support (3.13.5) + rubocop (1.79.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) - rubocop-capybara (2.19.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.24.0) - rubocop (~> 1.33) - rubocop-rake (0.6.0) - rubocop (~> 1.0) - rubocop-rspec (2.24.1) - rubocop (~> 1.33) - rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.6.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) tty-color (0.6.0) tty-cursor (0.7.1) @@ -88,8 +84,10 @@ GEM tty-cursor (~> 0.7) tty-screen (~> 0.8) wisper (~> 2.0) - tty-screen (0.8.1) - unicode-display_width (2.4.2) + tty-screen (0.8.2) + unicode-display_width (3.1.5) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) wisper (2.0.1) PLATFORMS diff --git a/Rakefile b/Rakefile index b82d6a9..9c31566 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "bundler/gem_tasks" require "rspec/core/rake_task" require "tty-prompt" diff --git a/hoov_vin.gemspec b/hoov_vin.gemspec index 8f99e74..5db162c 100644 --- a/hoov_vin.gemspec +++ b/hoov_vin.gemspec @@ -1,5 +1,3 @@ -# frozen_string_literal: true - lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) @@ -16,7 +14,7 @@ Gem::Specification.new do |s| s.email = "roger@hoov.com.br" s.homepage = "https://github.com/hoovbr/vin" s.license = "MIT" - s.required_ruby_version = ">= 3.1" + s.required_ruby_version = ">= 3.2" s.metadata["homepage_uri"] = s.homepage s.metadata["source_code_uri"] = "https://github.com/hoovbr/vin" s.metadata["changelog_uri"] = "https://github.com/hoovbr/vin/blob/main/CHANGELOG.md" diff --git a/lib/vin.rb b/lib/vin.rb index c671d5d..450ac6f 100644 --- a/lib/vin.rb +++ b/lib/vin.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "redis" require "vin/mixins/redis" @@ -10,17 +8,17 @@ def initialize(config: nil) @config = config || VIN::Config.new end - def generate_id(data_type) - generator.generate_ids(data_type, 1).first + def generate_id(data_type, timestamp: nil) + generator.generate_ids(data_type, 1, timestamp: timestamp).first end - def generate_ids(data_type, count) + def generate_ids(data_type, count, timestamp: nil) 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) + ids += generator.generate_ids(data_type, count - ids.length, timestamp: timestamp) # Ensure the ids array keeps growing as infinite loop insurance return ids unless ids.length > initial_id_count end diff --git a/lib/vin/config.rb b/lib/vin/config.rb index f1c1ecf..dc8965f 100644 --- a/lib/vin/config.rb +++ b/lib/vin/config.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class VIN class Config # Expressed in milliseconds. @@ -85,9 +83,11 @@ def fetch_allowed_range! ENV.fetch("VIN_LOGICAL_SHARD_ID_RANGE_MIN", 0).to_i, ENV.fetch("VIN_LOGICAL_SHARD_ID_RANGE_MAX", 0).to_i, ) + # rubocop:disable Style/BitwisePredicate unless (logical_shard_id_allowed_range.to_a & range.to_a) == range.to_a raise(ArgumentError, "VIN_LOGICAL_SHARD_ID_RANGE_MIN and VIN_LOGICAL_SHARD_ID_RANGE_MAX env vars compose a range outside the allowed range of #{logical_shard_id_allowed_range} defined by the number of bits in VIN_LOGICAL_SHARD_ID_BITS env var.") end + # rubocop:enable Style/BitwisePredicate range end end diff --git a/lib/vin/generator.rb b/lib/vin/generator.rb index c84172b..890d522 100644 --- a/lib/vin/generator.rb +++ b/lib/vin/generator.rb @@ -1,16 +1,14 @@ -# frozen_string_literal: true - require "vin/config" class VIN class Generator - attr_reader :data_type, :count, :config + attr_reader :data_type, :count, :config, :custom_timestamp def initialize(config:) @config = config end - def generate_ids(data_type, count = 1) + def generate_ids(data_type, count = 1, timestamp: nil) raise(ArgumentError, "data_type must be an integer") unless data_type.is_a?(Integer) unless config.data_type_allowed_range.include?(data_type) @@ -20,8 +18,13 @@ def generate_ids(data_type, count = 1) raise(ArgumentError, "count must be an integer") unless count.is_a?(Integer) raise(ArgumentError, "count must be a positive number") if count < 1 + if timestamp + validate_timestamp!(timestamp) + end + @data_type = data_type @count = count + @custom_timestamp = timestamp result = response.sequence.map do |sequence| ( @@ -39,10 +42,22 @@ def generate_ids(data_type, count = 1) private def shifted_timestamp - timestamp = Timestamp.from_redis(response.seconds, response.microseconds_part) + timestamp = if custom_timestamp + # Custom timestamp is in Unix milliseconds (absolute time) + # Convert it to be relative to custom epoch + milliseconds_from_custom_epoch = custom_timestamp - config.custom_epoch + Timestamp.new(milliseconds_from_custom_epoch, epoch: config.custom_epoch) + else + Timestamp.from_redis(response.seconds, response.microseconds_part) + end timestamp.with_epoch(config.custom_epoch).milliseconds << config.timestamp_shift end + def validate_timestamp!(timestamp) + raise(ArgumentError, "timestamp must be an integer (milliseconds)") unless timestamp.is_a?(Integer) + raise(ArgumentError, "timestamp cannot be before the custom epoch (#{config.custom_epoch}ms since Unix epoch)") if timestamp < config.custom_epoch + end + def shifted_data_type data_type << config.data_type_shift end @@ -52,7 +67,7 @@ def shifted_logical_shard_id end def response - @response ||= Request.new(config, data_type, count).response + @response ||= Request.new(config, data_type, count, custom_timestamp: custom_timestamp).response end end end diff --git a/lib/vin/id.rb b/lib/vin/id.rb index 6f4abc7..85dfeaf 100644 --- a/lib/vin/id.rb +++ b/lib/vin/id.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class VIN class Id attr_reader :id, :config diff --git a/lib/vin/lua_script.rb b/lib/vin/lua_script.rb index 8cb50e4..82725d5 100644 --- a/lib/vin/lua_script.rb +++ b/lib/vin/lua_script.rb @@ -1,11 +1,9 @@ -# frozen_string_literal: true - require "erb" require "vin/config" class VIN module LuaScript - LUA_SCRIPT_PATH = "lua/id-generation.lua.erb" + LUA_SCRIPT_PATH = "lua/id-generation.lua.erb".freeze def self.generate_file(config: nil) config ||= VIN::Config.new diff --git a/lib/vin/mixins/redis.rb b/lib/vin/mixins/redis.rb index 4075be5..80c9ef1 100644 --- a/lib/vin/mixins/redis.rb +++ b/lib/vin/mixins/redis.rb @@ -1,9 +1,7 @@ -# frozen_string_literal: true - class VIN module Mixins module Redis - DEFAULT_REDIS_URL = "redis://127.0.0.1:6379" + DEFAULT_REDIS_URL = "redis://127.0.0.1:6379".freeze def redis # TODO: Redis config for multiple servers diff --git a/lib/vin/request.rb b/lib/vin/request.rb index 4014f14..23be8fe 100644 --- a/lib/vin/request.rb +++ b/lib/vin/request.rb @@ -1,14 +1,12 @@ -# frozen_string_literal: true - class VIN class Request include VIN::Mixins::Redis MAX_TRIES = 5 - attr_reader :data_type, :count, :config + attr_reader :data_type, :count, :config, :custom_timestamp - def initialize(config, data_type, count = 1) + def initialize(config, data_type, count = 1, custom_timestamp: nil) raise(ArgumentError, "data_type must be a number") unless data_type.is_a?(Numeric) unless config.data_type_allowed_range.include?(data_type) raise(ArgumentError, "data_type is outside the allowed range of #{config.data_type_allowed_range}") @@ -20,6 +18,7 @@ def initialize(config, data_type, count = 1) @data_type = data_type @count = count @config = config + @custom_timestamp = custom_timestamp end def response @@ -33,7 +32,7 @@ def lua_script_sha end def lua_keys - @lua_keys ||= [data_type, count] + @lua_keys ||= [data_type, count, custom_timestamp].compact end # NOTE: If too many requests come in inside of a millisecond the Lua script diff --git a/lib/vin/response.rb b/lib/vin/response.rb index be19a63..f944a56 100644 --- a/lib/vin/response.rb +++ b/lib/vin/response.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class VIN class Response START_SEQUENCE_INDEX = 0 diff --git a/lib/vin/timestamp.rb b/lib/vin/timestamp.rb index e406f3c..fdbdf7f 100644 --- a/lib/vin/timestamp.rb +++ b/lib/vin/timestamp.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class VIN class Timestamp ONE_SECOND_IN_MILLIS = 1_000 diff --git a/lib/vin/version.rb b/lib/vin/version.rb index 2649f4c..f66951e 100644 --- a/lib/vin/version.rb +++ b/lib/vin/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class VIN - VERSION = "1.0.0" + VERSION = "1.0.0".freeze end diff --git a/lua/id-generation.lua.erb b/lua/id-generation.lua.erb index 5a235f5..8fc4332 100644 --- a/lua/id-generation.lua.erb +++ b/lua/id-generation.lua.erb @@ -3,6 +3,7 @@ local max_sequence = <%= config.max_sequence %> local data_type = tonumber(KEYS[1]) local num_ids = tonumber(KEYS[2]) +local custom_timestamp = tonumber(KEYS[3]) -- Optional custom timestamp in Unix milliseconds (absolute time) -- Allow one server to acts as multiple shards local logical_shard_id_min = <%= config.logical_shard_id_range.min %> @@ -72,12 +73,21 @@ outcome of the writes. See the "Scripts as pure functions" section at http://redis.io/commands/eval for more information. --]] -local time = redis.call('TIME') +local seconds, microseconds +if custom_timestamp then + -- Custom timestamp is already in Unix milliseconds (absolute time) + seconds = math.floor(custom_timestamp / 1000) + microseconds = (custom_timestamp % 1000) * 1000 +else + local time = redis.call('TIME') + seconds = tonumber(time[1]) + microseconds = tonumber(time[2]) +end return { start_sequence, end_sequence, -- Doesn't need conversion, the result of INCR or the variable set is always a number. logical_shard_id, - tonumber(time[1]), - tonumber(time[2]) + seconds, + microseconds } diff --git a/spec/features/custom_timestamp_spec.rb b/spec/features/custom_timestamp_spec.rb new file mode 100644 index 0000000..8b847f6 --- /dev/null +++ b/spec/features/custom_timestamp_spec.rb @@ -0,0 +1,212 @@ +describe "VIN custom timestamp feature" do + include DummyData + + subject { VIN.new(config: config) } + + let(:config) { VIN::Config.new } + let(:data_type) { random_data_type } + let(:now_ms) { (Time.now.to_f * 1000).to_i } + let(:custom_timestamp) { config.custom_epoch + 86_400_000 } # 1 day after custom epoch + let(:generator) { subject.send(:generator) } + + before do + VIN::LuaScript.reset_cache + + # Track state for mock responses + sequence_counter = 0 + shard_counter = 0 + + # Mock the generator's response method to return dummy responses + allow(generator).to receive(:response) do + # Get count from the generator (for batch generation) + count = generator.instance_variable_get(:@count) || 1 + + # Use custom timestamp if provided, otherwise use current time + if generator.instance_variable_get(:@custom_timestamp) + timestamp_ms = generator.instance_variable_get(:@custom_timestamp) + time = Time.at(timestamp_ms / 1000.0) + else + time = Time.now + end + + # Use the dummy_redis_response helper to generate consistent responses + redis_response = dummy_redis_response( + count: count, + sequence_start: sequence_counter + 1, + logical_shard_id: shard_counter % 8, + now: time, + ) + + # Update counters for next call + sequence_counter = redis_response[1] # end_sequence + shard_counter += 1 + + VIN::Response.new(redis_response) + end + end + + describe "#generate_id with custom timestamp" do + context "when timestamp is valid" do + it "generates an ID with the provided timestamp" do + id = subject.generate_id(data_type, timestamp: custom_timestamp) + expect(id).to be_a(Integer) + + # Extract timestamp from the ID + vin_id = VIN::Id.new(id: id, config: config) + expected_timestamp_ms = custom_timestamp - config.custom_epoch + + # The timestamp in the ID should match the custom timestamp (relative to custom epoch) + expect(vin_id.timestamp.milliseconds).to eq(expected_timestamp_ms) + end + + it "generates different IDs when called multiple times with the same timestamp" do + id1 = subject.generate_id(data_type, timestamp: custom_timestamp) + id2 = subject.generate_id(data_type, timestamp: custom_timestamp) + + expect(id1).not_to eq(id2) + + # Both should have the same timestamp + vin_id1 = VIN::Id.new(id: id1, config: config) + vin_id2 = VIN::Id.new(id: id2, config: config) + + expect(vin_id1.timestamp.milliseconds).to eq(vin_id2.timestamp.milliseconds) + + # IDs should be different due to either different sequences or different logical shard IDs + # (The Lua script round-robins through logical shard IDs, each with its own sequence counter) + if vin_id1.logical_shard_id == vin_id2.logical_shard_id + expect(vin_id1.sequence).not_to eq(vin_id2.sequence) + else + # Different logical shards can have the same sequence, but that's fine + expect(vin_id1.logical_shard_id).not_to eq(vin_id2.logical_shard_id) + end + end + end + + context "when timestamp is before custom epoch" do + let(:custom_timestamp) { config.custom_epoch - 1000 } # 1 second before custom epoch + + it "raises an ArgumentError" do + expect do + subject.generate_id(data_type, timestamp: custom_timestamp) + end.to raise_error(ArgumentError, /timestamp cannot be before the custom epoch/) + end + end + + context "when timestamp is not an integer" do + let(:custom_timestamp) { "not_a_timestamp" } + + it "raises an ArgumentError" do + expect do + subject.generate_id(data_type, timestamp: custom_timestamp) + end.to raise_error(ArgumentError, /timestamp must be an integer/) + end + end + + context "when timestamp is nil" do + it "generates an ID using Redis timestamp" do + id = subject.generate_id(data_type, timestamp: nil) + expect(id).to be_a(Integer) + + # The ID should have a recent timestamp (within the last few seconds) + vin_id = VIN::Id.new(id: id, config: config) + unix_timestamp = vin_id.timestamp.with_unix_epoch.milliseconds + + expect(unix_timestamp).to be_within(5000).of(now_ms) + end + end + end + + describe "#generate_ids with custom timestamp" do + let(:count) { 5 } + + context "when timestamp is valid" do + it "generates multiple IDs with the provided timestamp" do + ids = subject.generate_ids(data_type, count, timestamp: custom_timestamp) + + expect(ids.length).to eq(count) + expect(ids.uniq.length).to eq(count) # All IDs should be unique + + # All IDs should have the same timestamp + timestamps = ids.map do |id| + VIN::Id.new(id: id, config: config).timestamp.milliseconds + end + + expect(timestamps.uniq.length).to eq(1) + expect(timestamps.first).to eq(custom_timestamp - config.custom_epoch) + end + + it "generates sequential IDs with increasing sequences" do + ids = subject.generate_ids(data_type, count, timestamp: custom_timestamp) + + sequences = ids.map do |id| + VIN::Id.new(id: id, config: config).sequence + end + + # Sequences should be consecutive + sequences.each_cons(2) do |a, b| + expect(b - a).to eq(1) + end + end + end + + context "when requesting many IDs with the same timestamp" do + let(:count) { 100 } + + it "generates all unique IDs" do + ids = subject.generate_ids(data_type, count, timestamp: custom_timestamp) + + expect(ids.length).to eq(count) + expect(ids.uniq.length).to eq(count) + + # All should have the same timestamp + timestamps = ids.map do |id| + VIN::Id.new(id: id, config: config).timestamp.milliseconds + end + + expect(timestamps.uniq.length).to eq(1) + end + end + end + + # NOTE: Concurrent ID generation with same timestamp is not tested here because: + # 1. The mock responses are synchronized, which doesn't truly test concurrency + # 2. RSpec mocks aren't thread-safe without additional synchronization + # 3. The real concurrency guarantees come from Redis's atomic operations (INCRBY) + # and the Lua script's distributed locking mechanism + # 4. True concurrent testing would require integration tests with a real Redis instance + # + # The uniqueness of IDs with the same timestamp is still tested through: + # - Sequential calls with the same timestamp (see tests above) + # - The round-robin logical shard ID assignment + # - The sequence incrementing within each shard + + describe "timestamp conversion and epoch handling" do + let(:unix_timestamp_ms) { config.custom_epoch + 3_600_000 } # 1 hour after custom epoch + let(:custom_timestamp) { unix_timestamp_ms } + + it "correctly converts Unix timestamp to custom epoch relative timestamp" do + id = subject.generate_id(data_type, timestamp: custom_timestamp) + vin_id = VIN::Id.new(id: id, config: config) + + # The timestamp should be relative to custom epoch + expected_ms_from_epoch = custom_timestamp - config.custom_epoch + expect(vin_id.timestamp.milliseconds).to eq(expected_ms_from_epoch) + + # When converted back to Unix time, it should match the original + unix_ms = vin_id.timestamp.with_unix_epoch.milliseconds + expect(unix_ms).to eq(custom_timestamp) + end + + it "produces the same Time object as IDs generated without custom timestamp" do + # Generate ID with custom timestamp + custom_id = subject.generate_id(data_type, timestamp: custom_timestamp) + custom_vin_id = VIN::Id.new(id: custom_id, config: config) + + # The to_time method should produce the correct Time object + time_from_custom = custom_vin_id.timestamp.to_time + expected_time = Time.at(custom_timestamp / 1000.0) + + expect(time_from_custom.to_i).to eq(expected_time.to_i) + end + end +end diff --git a/spec/features/generate_id_spec.rb b/spec/features/generate_id_spec.rb index 8db00b5..dd47c3c 100644 --- a/spec/features/generate_id_spec.rb +++ b/spec/features/generate_id_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe "VIN.generate_id" do include DummyData @@ -60,7 +58,7 @@ it "contains a current timestamp" do expect(id.timestamp).to(be_a(VIN::Timestamp)) expect(id.custom_timestamp).to(be_between(0, ~(-1 << config.timestamp_bits))) - expect(id.timestamp.to_time).to(be_between((Time.now - 1), (Time.now + 1))) + expect(id.timestamp.to_time).to(be_between(Time.now - 1, Time.now + 1)) end context "when logical_shard_id_range is a range" do diff --git a/spec/features/generate_ids_spec.rb b/spec/features/generate_ids_spec.rb index fab73fb..67fffc8 100644 --- a/spec/features/generate_ids_spec.rb +++ b/spec/features/generate_ids_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe "VIN.generate_ids" do include DummyData @@ -34,7 +32,7 @@ it "contains a current timestamp" do expect(ids.map(&:timestamp)).to(all(be_a(VIN::Timestamp))) expect(ids.map(&:custom_timestamp)).to(all(be_between(0, ~(-1 << config.timestamp_bits)))) - expect(ids.map(&:timestamp).map(&:to_time)).to(all(be_between((Time.now - 1), (Time.now + 1)))) + expect(ids.map(&:timestamp).map(&:to_time)).to(all(be_between(Time.now - 1, Time.now + 1))) end context "when logical_shard_id_range is a range" do diff --git a/spec/lib/vin/config_spec.rb b/spec/lib/vin/config_spec.rb index 6f20c62..25c63a3 100644 --- a/spec/lib/vin/config_spec.rb +++ b/spec/lib/vin/config_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Config do subject { described_class.new } diff --git a/spec/lib/vin/generator_spec.rb b/spec/lib/vin/generator_spec.rb index a1b2a6c..23fe2dd 100644 --- a/spec/lib/vin/generator_spec.rb +++ b/spec/lib/vin/generator_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Generator do include DummyData @@ -22,6 +20,32 @@ let(:response) { VIN::Response.new(redis_response) } describe "#generate_ids" do + context "with custom timestamp" do + let(:custom_timestamp) { config.custom_epoch + 86_400_000 } # 1 day after custom epoch + let(:generator) { described_class.new(config: config) } + + context "when timestamp is valid" do + let(:request_double) { instance_double(VIN::Request) } + + before do + allow(VIN::Request).to receive(:new).with(config, data_type, count, custom_timestamp: custom_timestamp).and_return(request_double) + allow(request_double).to receive(:response).and_return(response) + end + + it "validates timestamp is an integer" do + expect do + generator.generate_ids(data_type, count, timestamp: "not_a_number") + end.to raise_error(ArgumentError, /timestamp must be an integer/) + end + + it "validates timestamp is not before custom epoch" do + expect do + generator.generate_ids(data_type, count, timestamp: config.custom_epoch - 1000) + end.to raise_error(ArgumentError, /timestamp cannot be before the custom epoch/) + end + end + end + context "when arguments are invalid" do context "when data_type is not an integer" do let(:data_type) { "foo" } @@ -55,11 +79,11 @@ end context "when arguments are valid" do + let(:request_double) { instance_double(VIN::Request) } + before do - # The generator's request is not exposed as an instance variable, so we just stub any instance of it. - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(VIN::Request).to(receive(:response).and_return(response)) - # rubocop:enable RSpec/AnyInstance + allow(VIN::Request).to receive(:new).and_return(request_double) + allow(request_double).to receive(:response).and_return(response) end context "when count is 1" do diff --git a/spec/lib/vin/id_spec.rb b/spec/lib/vin/id_spec.rb index 71df550..83951c8 100644 --- a/spec/lib/vin/id_spec.rb +++ b/spec/lib/vin/id_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Id do include DummyData diff --git a/spec/lib/vin/lua_script_spec.rb b/spec/lib/vin/lua_script_spec.rb index 8213190..7af8419 100644 --- a/spec/lib/vin/lua_script_spec.rb +++ b/spec/lib/vin/lua_script_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::LuaScript do include DummyData diff --git a/spec/lib/vin/request_spec.rb b/spec/lib/vin/request_spec.rb index e449b65..e906b86 100644 --- a/spec/lib/vin/request_spec.rb +++ b/spec/lib/vin/request_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Request do include DummyData diff --git a/spec/lib/vin/response_spec.rb b/spec/lib/vin/response_spec.rb index a7f07c8..522f997 100644 --- a/spec/lib/vin/response_spec.rb +++ b/spec/lib/vin/response_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Response do include DummyData diff --git a/spec/lib/vin/timestamp_spec.rb b/spec/lib/vin/timestamp_spec.rb index 36d09fc..ba0ad52 100644 --- a/spec/lib/vin/timestamp_spec.rb +++ b/spec/lib/vin/timestamp_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN::Timestamp do subject { described_class.new(milliseconds, epoch:) } diff --git a/spec/lib/vin_spec.rb b/spec/lib/vin_spec.rb index f64e750..1794f6e 100644 --- a/spec/lib/vin_spec.rb +++ b/spec/lib/vin_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe VIN do include DummyData @@ -16,6 +14,16 @@ allow(generator).to(receive(:generate_ids).and_return(ids)) expect(subject.generate_id(data_type)).to(eql(id)) end + + context "with custom timestamp" do + let(:custom_timestamp) { 1_646_160_000_000 + 86_400_000 } # 1 day after custom epoch + + it "passes timestamp to generator" do + allow(generator).to receive(:generate_ids).with(data_type, 1, timestamp: custom_timestamp).and_return(ids) + subject.generate_id(data_type, timestamp: custom_timestamp) + expect(generator).to have_received(:generate_ids).with(data_type, 1, timestamp: custom_timestamp) + end + end end describe ".generate_ids" do @@ -37,5 +45,33 @@ expect(subject.generate_ids(data_type, count)).to(eql(ids)) end end + + context "with custom timestamp" do + let(:custom_timestamp) { 1_646_160_000_000 + 86_400_000 } # 1 day after custom epoch + let(:count) { 3 } + + it "passes timestamp to generator" do + allow(generator).to receive(:generate_ids).with(data_type, count, timestamp: custom_timestamp).and_return(ids) + subject.generate_ids(data_type, count, timestamp: custom_timestamp) + expect(generator).to have_received(:generate_ids).with(data_type, count, timestamp: custom_timestamp) + end + + it "passes timestamp on subsequent calls when more IDs are needed" do + small_batch = [dummy_id] + allow(generator).to receive(:generate_ids) + .with(data_type, count, timestamp: custom_timestamp) + .and_return(small_batch) + allow(generator).to receive(:generate_ids) + .with(data_type, count - small_batch.length, timestamp: custom_timestamp) + .and_return(ids[0...(count - small_batch.length)]) + + result = subject.generate_ids(data_type, count, timestamp: custom_timestamp) + expect(result.length).to eq(count) + expect(generator).to have_received(:generate_ids) + .with(data_type, count, timestamp: custom_timestamp) + expect(generator).to have_received(:generate_ids) + .with(data_type, count - small_batch.length, timestamp: custom_timestamp) + end + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 64b9d62..99abca1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "bundler/setup" require "date" require "dotenv" diff --git a/spec/support/dummy_data.rb b/spec/support/dummy_data.rb index b53c545..f821ba1 100644 --- a/spec/support/dummy_data.rb +++ b/spec/support/dummy_data.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module DummyData def dummy_config @dummy_config ||= VIN::Config.new