From be355e2aadaf21a9ee8039919be6bee547ecd7e6 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Thu, 4 Sep 2025 21:40:44 -0400 Subject: [PATCH 1/3] Add registry to track different cycles --- CHANGELOG.md | 4 ++++ lib/sof/cycle.rb | 25 ++++++++++--------------- lib/sof/cycle_registry.rb | 19 +++++++++++++++++++ lib/sof/cycles/end_of.rb | 2 +- 4 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 lib/sof/cycle_registry.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c602b14..6318254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.13] - Unreleased +### Added + +- Registry class to manage Cycle classes. + ## [0.1.12] - 2025-09-05 ### Added diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 9adf732..6f6657c 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -2,6 +2,7 @@ require "forwardable" require_relative "parser" +require_relative "cycle_registry" module SOF class Cycle @@ -25,7 +26,7 @@ def dump(cycle_or_string) # Return a Cycle object from a hash def load(hash) symbolized_hash = hash.symbolize_keys - cycle_class = class_for_kind(symbolized_hash[:kind]) + cycle_class = registry.handling(symbolized_hash[:kind]) unless cycle_class.valid_periods.empty? cycle_class.validate_period( @@ -46,7 +47,7 @@ def notation(hash) volume_notation = "V#{hash.fetch(:volume) { 1 }}" return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only - cycle_class = class_for_kind(hash[:kind].to_sym) + cycle_class = registry.handling(hash[:kind].to_sym) [ volume_notation, cycle_class.notation_id, @@ -69,7 +70,7 @@ def for(notation) raise InvalidInput, "'#{notation}' is not a valid input" end - cycle = Cycle.cycle_handlers.find do |klass| + cycle = registry.cycle_classes.find do |klass| parser.parses?(klass.notation_id) end.new(notation, parser:) return cycle if parser.active? @@ -77,6 +78,8 @@ def for(notation) Cycles::Dormant.new(cycle, parser:) end + def registry = CycleRegistry.instance + # Return the appropriate class for the give notation id # # @param notation [String] notation id matching the kind of Cycle class @@ -84,9 +87,7 @@ def for(notation) # class_for_notation_id('L') # def class_for_notation_id(notation_id) - Cycle.cycle_handlers.find do |klass| - klass.notation_id == notation_id - end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}") + registry.handling(notation_id) end # Return the class handling the kind @@ -95,9 +96,7 @@ def class_for_notation_id(notation_id) # @example # class_for_kind(:lookback) def class_for_kind(sym) - Cycle.cycle_handlers.find do |klass| - klass.handles?(sym) - end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle") + registry.handling(sym) end # Return a legend explaining all notation components @@ -147,19 +146,15 @@ def handles?(sym) kind.to_s == sym.to_s end - def cycle_handlers - @cycle_handlers ||= Set.new - end - def inherited(klass) - Cycle.cycle_handlers << klass + registry.register(klass) end private def build_kind_legend legend = {} - Cycle.cycle_handlers.each do |handler| + registry.cycle_classes.each do |handler| # Skip volume_only since it doesn't have a notation_id next if handler.instance_variable_get(:@volume_only) diff --git a/lib/sof/cycle_registry.rb b/lib/sof/cycle_registry.rb new file mode 100644 index 0000000..97d5028 --- /dev/null +++ b/lib/sof/cycle_registry.rb @@ -0,0 +1,19 @@ +require "singleton" + +module SOF + class CycleRegistry + include Singleton + + def register(cycle_class) + cycle_classes << cycle_class + end + + def cycle_classes + @cycle_classes ||= Set.new + end + + def handling(kind) + cycle_classes.find { |klass| klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle") + end + end +end diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index b86c572..fe4a005 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -27,7 +27,7 @@ def self.examples end def to_s - return dormant_to_s if parser.dormant? || from_date.nil? + return dormant_to_s if parser.parser.dormant? || from_date.nil? "#{volume}x by #{final_date.to_fs(:american)}" end From 0c9135ce9b9a642d27113547920677c20188f9c9 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Fri, 5 Sep 2025 10:17:23 -0400 Subject: [PATCH 2/3] Use Concurrent::Set for registry items --- CHANGELOG.md | 1 + Gemfile.lock | 41 +++++++++++++++++++-------------------- lib/sof/cycle_registry.rb | 3 ++- lib/sof/time_span.rb | 13 ++++++++----- sof-cycle.gemspec | 1 + 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6318254..8121841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Registry class to manage Cycle classes. +- Use of Concurrent::Set for thread safety. ## [0.1.12] - 2025-09-05 diff --git a/Gemfile.lock b/Gemfile.lock index d30cde8..b360a1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,29 +3,29 @@ PATH specs: sof-cycle (0.1.13) activesupport (>= 6.0) + concurrent-ruby (~> 1.0) forwardable GEM remote: https://rubygems.org/ specs: - activesupport (7.1.5.2) + activesupport (8.0.2.1) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) minitest (>= 5.1) - mutex_m securerandom (>= 0.3) - tzinfo (~> 2.0) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) ast (2.4.3) base64 (0.3.0) benchmark (0.4.1) bigdecimal (3.2.3) - cgi (0.5.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) date (3.4.1) @@ -33,26 +33,24 @@ GEM irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.2) - docile (1.4.0) + docile (1.4.1) drb (2.2.3) - erb (4.0.4) - cgi (>= 0.3.3) + erb (5.0.2) forwardable (1.3.3) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.12.2) + json (2.13.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) minitest (5.25.5) - mutex_m (0.3.0) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc pp (0.6.2) @@ -65,19 +63,19 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.1) + rdoc (6.14.2) erb psych (>= 4.0.0) - regexp_parser (2.10.0) + regexp_parser (2.11.2) reissue (0.4.1) rake - reline (0.6.1) + reline (0.6.2) io-console (~> 0.5) rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.4) + rspec-core (3.13.5) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) @@ -85,7 +83,7 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.4) + rspec-support (3.13.5) rubocop (1.75.8) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -97,7 +95,7 @@ GEM rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.0) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-performance (1.25.0) @@ -105,12 +103,12 @@ GEM rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) - securerandom (0.3.2) + securerandom (0.4.1) 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) standard (1.50.0) language_server-protocol (~> 3.17.0.2) @@ -127,9 +125,10 @@ GEM stringio (3.1.7) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) + unicode-display_width (3.1.5) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) + uri (1.0.3) PLATFORMS arm64-darwin-23 diff --git a/lib/sof/cycle_registry.rb b/lib/sof/cycle_registry.rb index 97d5028..9c769f4 100644 --- a/lib/sof/cycle_registry.rb +++ b/lib/sof/cycle_registry.rb @@ -1,4 +1,5 @@ require "singleton" +require "concurrent/set" module SOF class CycleRegistry @@ -9,7 +10,7 @@ def register(cycle_class) end def cycle_classes - @cycle_classes ||= Set.new + @cycle_classes ||= Concurrent::Set.new end def handling(kind) diff --git a/lib/sof/time_span.rb b/lib/sof/time_span.rb index b853e86..b76a8b5 100644 --- a/lib/sof/time_span.rb +++ b/lib/sof/time_span.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "concurrent/set" +require "concurrent/hash" + module SOF # This class is not intended to be referenced directly. # This is an internal implementation of Cycle behavior. @@ -60,8 +63,8 @@ class DatePeriod extend Forwardable class << self def for(count, period_notation) - @cached_periods ||= {} - @cached_periods[period_notation] ||= {} + @cached_periods ||= Concurrent::Hash.new + @cached_periods[period_notation] ||= Concurrent::Hash.new @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count) @cached_periods[period_notation][count] end @@ -72,7 +75,7 @@ def for_notation(notation) end end - def types = @types ||= Set.new + def types = @types ||= Concurrent::Set.new def inherited(klass) DatePeriod.types << klass @@ -92,12 +95,12 @@ def initialize(count) attr_reader :count def end_date(date) - @end_date ||= {} + @end_date ||= Concurrent::Hash.new @end_date[date] ||= date + duration end def begin_date(date) - @begin_date ||= {} + @begin_date ||= Concurrent::Hash.new @begin_date[date] ||= date - duration end diff --git a/sof-cycle.gemspec b/sof-cycle.gemspec index 93227ab..4325d9d 100644 --- a/sof-cycle.gemspec +++ b/sof-cycle.gemspec @@ -30,4 +30,5 @@ Gem::Specification.new do |spec| spec.add_dependency "forwardable" spec.add_dependency "activesupport", ">= 6.0" + spec.add_dependency "concurrent-ruby", "~> 1.0" end From 29717023b4a43b0f5222b2bb4fdca0756bb6c93b Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Fri, 5 Sep 2025 11:02:19 -0400 Subject: [PATCH 3/3] Add thread safety date periods and a null option --- lib/sof/cycle.rb | 32 +- lib/sof/cycle_registry.rb | 2 +- lib/sof/cycles/end_of.rb | 2 +- lib/sof/time_span.rb | 26 +- spec/sof/cycle_registry_thread_safety_spec.rb | 166 ++++++++++ spec/sof/time_span_thread_safety_spec.rb | 288 ++++++++++++++++++ 6 files changed, 502 insertions(+), 14 deletions(-) create mode 100644 spec/sof/cycle_registry_thread_safety_spec.rb create mode 100644 spec/sof/time_span_thread_safety_spec.rb diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 6f6657c..9222a3b 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -70,9 +70,18 @@ def for(notation) raise InvalidInput, "'#{notation}' is not a valid input" end - cycle = registry.cycle_classes.find do |klass| - parser.parses?(klass.notation_id) - end.new(notation, parser:) + cycle_class = registry.cycle_classes.find do |klass| + klass.respond_to?(:notation_id) && parser.parses?(klass.notation_id) + end + + raise InvalidKind, "No cycle class found for notation '#{notation}'" unless cycle_class + + # Validate period if applicable + if cycle_class.respond_to?(:valid_periods) && !cycle_class.valid_periods.empty? && parser.period_key + cycle_class.validate_period(parser.period_key) + end + + cycle = cycle_class.new(notation, parser:) return cycle if parser.active? Cycles::Dormant.new(cycle, parser:) @@ -131,6 +140,15 @@ def volume_only? = @volume_only def recurring? = raise "#{name} must implement #{__method__}" + def inherited(subclass) + registry.register(subclass) + end + + def handles?(kind) + return false if kind.nil? + @kind == kind.to_sym + end + # Raises an error if the given period isn't in the list of valid periods. # # @param period [String] period matching the class valid periods @@ -142,14 +160,6 @@ def validate_period(period) ERR end - def handles?(sym) - kind.to_s == sym.to_s - end - - def inherited(klass) - registry.register(klass) - end - private def build_kind_legend diff --git a/lib/sof/cycle_registry.rb b/lib/sof/cycle_registry.rb index 9c769f4..359cc27 100644 --- a/lib/sof/cycle_registry.rb +++ b/lib/sof/cycle_registry.rb @@ -14,7 +14,7 @@ def cycle_classes end def handling(kind) - cycle_classes.find { |klass| klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle") + cycle_classes.find { |klass| klass.respond_to?(:handles?) && klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle") end end end diff --git a/lib/sof/cycles/end_of.rb b/lib/sof/cycles/end_of.rb index fe4a005..b86c572 100644 --- a/lib/sof/cycles/end_of.rb +++ b/lib/sof/cycles/end_of.rb @@ -27,7 +27,7 @@ def self.examples end def to_s - return dormant_to_s if parser.parser.dormant? || from_date.nil? + return dormant_to_s if parser.dormant? || from_date.nil? "#{volume}x by #{final_date.to_fs(:american)}" end diff --git a/lib/sof/time_span.rb b/lib/sof/time_span.rb index b76a8b5..4b52fec 100644 --- a/lib/sof/time_span.rb +++ b/lib/sof/time_span.rb @@ -70,9 +70,11 @@ def for(count, period_notation) end def for_notation(notation) + return NullPeriod if notation.nil? || notation.to_s.empty? + DatePeriod.types.find do |klass| klass.code == notation.to_s.upcase - end + end || raise(InvalidPeriod, "'#{notation}' is not a valid period") end def types = @types ||= Concurrent::Set.new @@ -114,6 +116,28 @@ def humanized_period "#{period}s" end + class NullPeriod < self + @period = nil + @code = nil + @interval = nil + + def initialize(count = nil) + @count = nil + end + + def duration = 0 + + def end_date(date) = date + + def begin_date(date) = date + + def end_of_period(date) = nil + + def beginning_of_period(date) = nil + + def humanized_period = "" + end + class Year < self @period = :year @code = "Y" diff --git a/spec/sof/cycle_registry_thread_safety_spec.rb b/spec/sof/cycle_registry_thread_safety_spec.rb new file mode 100644 index 0000000..5475092 --- /dev/null +++ b/spec/sof/cycle_registry_thread_safety_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SOF::CycleRegistry do + describe "thread safety" do + let(:registry) { described_class.instance } + let(:original_classes) { [] } + + before do + # Save the original classes + @original_classes = registry.cycle_classes.to_a + # Clear the registry before each test + registry.cycle_classes.clear + end + + after do + # Restore the original classes + registry.cycle_classes.clear + @original_classes.each { |klass| registry.register(klass) } + end + + it "safely registers multiple cycle classes concurrently" do + # Create some test cycle classes + test_classes = 10.times.map do |i| + Class.new do + define_singleton_method(:handles?) { |kind| kind == "test#{i}" } + define_singleton_method(:name) { "TestCycle#{i}" } + end + end + + threads = test_classes.map do |klass| + Thread.new { registry.register(klass) } + end + + threads.each(&:join) + + expect(registry.cycle_classes.size).to eq(10) + test_classes.each do |klass| + expect(registry.cycle_classes).to include(klass) + end + end + + it "safely handles concurrent reads while registering" do + # Pre-register some classes + 5.times do |i| + klass = Class.new do + define_singleton_method(:handles?) { |kind| kind == "existing#{i}" } + end + registry.register(klass) + end + + read_errors = [] + write_errors = [] + + # Create threads that read + read_threads = 20.times.map do + Thread.new do + 100.times do + registry.cycle_classes.to_a + registry.cycle_classes.size + rescue => e + read_errors << e + end + end + end + + # Create threads that write + write_threads = 5.times.map do |i| + Thread.new do + klass = Class.new do + define_singleton_method(:handles?) { |kind| kind == "new#{i}" } + end + begin + registry.register(klass) + rescue => e + write_errors << e + end + end + end + + (read_threads + write_threads).each(&:join) + + expect(read_errors).to be_empty + expect(write_errors).to be_empty + expect(registry.cycle_classes.size).to eq(10) # 5 existing + 5 new + end + + it "safely finds handlers concurrently" do + # Register test classes + 10.times do |i| + klass = Class.new do + define_singleton_method(:handles?) { |kind| kind == "kind#{i}" } + define_singleton_method(:name) { "Handler#{i}" } + end + registry.register(klass) + end + + errors = [] + results = Concurrent::Array.new + + threads = 100.times.map do |i| + Thread.new do + kind_index = i % 10 + begin + handler = registry.handling("kind#{kind_index}") + results << handler + rescue => e + errors << e + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(100) + + # Verify all lookups found the correct handler + results.each_with_index do |handler, i| + expected_kind = "kind#{i % 10}" + expect(handler.handles?(expected_kind)).to be true + end + end + + it "raises error for unknown kind even under concurrent access" do + # Register a known handler + known_class = Class.new do + define_singleton_method(:handles?) { |kind| kind == "known" } + end + registry.register(known_class) + + errors = Concurrent::Array.new + + threads = 10.times.map do + Thread.new do + registry.handling("unknown") + rescue SOF::Cycle::InvalidKind => e + errors << e + end + end + + threads.each(&:join) + + expect(errors.size).to eq(10) + errors.each do |error| + expect(error.message).to include("':unknown' is not a valid kind of Cycle") + end + end + + it "maintains singleton behavior across threads" do + instances = Concurrent::Array.new + + threads = 20.times.map do + Thread.new do + instances << described_class.instance + end + end + + threads.each(&:join) + + expect(instances.uniq.size).to eq(1) + expect(instances.first).to be(described_class.instance) + end + end +end diff --git a/spec/sof/time_span_thread_safety_spec.rb b/spec/sof/time_span_thread_safety_spec.rb new file mode 100644 index 0000000..cb7bbd5 --- /dev/null +++ b/spec/sof/time_span_thread_safety_spec.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SOF::TimeSpan do + describe "thread safety" do + describe "TimeSpan.for" do + it "safely creates TimeSpan instances concurrently" do + results = Concurrent::Array.new + errors = [] + + threads = 100.times.map do |i| + Thread.new do + count = i % 10 + period = ["D", "W", "M"][i % 3] + result = described_class.for(count, period) + results << result + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(100) + + # Verify correct class is returned based on count + results.each_with_index do |result, i| + count = i % 10 + case count + when 0 + expect(result).to be_a(SOF::TimeSpan::TimeSpanNothing) + when 1 + expect(result).to be_a(SOF::TimeSpan::TimeSpanOne) + else + expect(result).to be_a(SOF::TimeSpan) + end + end + end + end + + describe "TimeSpan.notation_id_from_name" do + it "safely looks up notation IDs concurrently" do + names = [:day, :week, :month, :quarter, :year] + results = Concurrent::Array.new + errors = [] + + threads = 100.times.map do |i| + Thread.new do + name = names[i % names.size] + result = described_class.notation_id_from_name(name) + results << [name, result] + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(100) + + # Verify correct mappings + expected_mappings = { + day: "D", + week: "W", + month: "M", + quarter: "Q", + year: "Y" + } + + results.each do |name, code| + expect(code).to eq(expected_mappings[name]) + end + end + + it "raises InvalidPeriod for unknown periods even under concurrent access" do + errors = Concurrent::Array.new + + threads = 10.times.map do + Thread.new do + described_class.notation_id_from_name(:invalid_period) + rescue SOF::TimeSpan::InvalidPeriod => e + errors << e + end + end + + threads.each(&:join) + + expect(errors.size).to eq(10) + errors.each do |error| + expect(error.message).to include("'invalid_period' is not a valid period") + end + end + end + + describe "TimeSpan instance methods" do + let(:time_span) { described_class.for(3, "D") } + let(:test_date) { Date.new(2024, 1, 15) } + + it "safely calculates end_date concurrently" do + results = Concurrent::Array.new + errors = [] + + threads = 50.times.map do + Thread.new do + result = time_span.end_date(test_date) + results << result + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(50) + # All results should be the same + expect(results.uniq.size).to eq(1) + expect(results.first).to eq(test_date + 3.days) + end + + it "safely calculates begin_date concurrently" do + results = Concurrent::Array.new + errors = [] + + threads = 50.times.map do + Thread.new do + result = time_span.begin_date(test_date) + results << result + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(50) + # All results should be the same + expect(results.uniq.size).to eq(1) + expect(results.first).to eq(test_date - 3.days) + end + + it "handles multiple dates concurrently" do + dates = 10.times.map { |i| Date.new(2024, 1, i + 1) } + results = Concurrent::Hash.new + errors = [] + + threads = dates.flat_map do |date| + # Multiple threads for each date + 5.times.map do + Thread.new do + results[date] ||= [] + results[date] << time_span.end_date(date) + rescue => e + errors << e + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + + # Verify each date has consistent results + dates.each do |date| + date_results = results[date] + expect(date_results).not_to be_nil + expect(date_results.uniq.size).to eq(1) + expect(date_results.first).to eq(date + 3.days) + end + end + + it "handles final_date calculations concurrently" do + results = Concurrent::Array.new + errors = [] + + threads = 50.times.map do + Thread.new do + result = time_span.final_date(test_date) + results << result + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(50) + # All results should be the same + expect(results.uniq.size).to eq(1) + expect(results.first).to eq(test_date + 3.days) + end + end + + describe "TimeSpan.notation" do + it "safely creates notation strings concurrently" do + test_hashes = [ + {period: "day", period_count: 1}, + {period: "week", period_count: 2}, + {period: "month", period_count: 3}, + {period: "quarter", period_count: 4}, + {period: "year", period_count: 5} + ] + + results = Concurrent::Array.new + errors = [] + + threads = 100.times.map do |i| + Thread.new do + hash = test_hashes[i % test_hashes.size] + result = described_class.notation(hash) + results << [hash, result] + rescue => e + errors << e + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.size).to eq(100) + + expected_notations = { + "day" => "1D", + "week" => "2W", + "month" => "3M", + "quarter" => "4Q", + "year" => "5Y" + } + + results.each do |hash, notation| + period = hash[:period] + expected = expected_notations[period] + expect(notation).to eq(expected) + end + end + end + + describe "Multiple TimeSpan instances" do + it "safely creates and uses multiple TimeSpan instances concurrently" do + errors = [] + results = Concurrent::Hash.new + + threads = [] + + # Create different TimeSpan instances + ["D", "W", "M"].each do |period| + [1, 2, 3].each do |count| + threads << Thread.new do + time_span = described_class.for(count, period) + test_date = Date.new(2024, 1, 15) + + # Perform multiple operations + 10.times do + key = "#{count}#{period}" + results[key] ||= Concurrent::Array.new + results[key] << time_span.end_date(test_date) + results[key] << time_span.begin_date(test_date) + results[key] << time_span.final_date(test_date) + end + rescue => e + errors << e + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + + # Verify consistency for each time_span + results.each do |key, values| + end_dates = values.select.with_index { |_, i| i % 3 == 0 } + begin_dates = values.select.with_index { |_, i| i % 3 == 1 } + final_dates = values.select.with_index { |_, i| i % 3 == 2 } + + expect(end_dates.uniq.size).to eq(1) + expect(begin_dates.uniq.size).to eq(1) + expect(final_dates.uniq.size).to eq(1) + end + end + end + end +end