Skip to content

Commit 2971702

Browse files
committed
Add thread safety date periods and a null option
1 parent 0c9135c commit 2971702

6 files changed

Lines changed: 502 additions & 14 deletions

File tree

lib/sof/cycle.rb

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,18 @@ def for(notation)
7070
raise InvalidInput, "'#{notation}' is not a valid input"
7171
end
7272

73-
cycle = registry.cycle_classes.find do |klass|
74-
parser.parses?(klass.notation_id)
75-
end.new(notation, parser:)
73+
cycle_class = registry.cycle_classes.find do |klass|
74+
klass.respond_to?(:notation_id) && parser.parses?(klass.notation_id)
75+
end
76+
77+
raise InvalidKind, "No cycle class found for notation '#{notation}'" unless cycle_class
78+
79+
# Validate period if applicable
80+
if cycle_class.respond_to?(:valid_periods) && !cycle_class.valid_periods.empty? && parser.period_key
81+
cycle_class.validate_period(parser.period_key)
82+
end
83+
84+
cycle = cycle_class.new(notation, parser:)
7685
return cycle if parser.active?
7786

7887
Cycles::Dormant.new(cycle, parser:)
@@ -131,6 +140,15 @@ def volume_only? = @volume_only
131140

132141
def recurring? = raise "#{name} must implement #{__method__}"
133142

143+
def inherited(subclass)
144+
registry.register(subclass)
145+
end
146+
147+
def handles?(kind)
148+
return false if kind.nil?
149+
@kind == kind.to_sym
150+
end
151+
134152
# Raises an error if the given period isn't in the list of valid periods.
135153
#
136154
# @param period [String] period matching the class valid periods
@@ -142,14 +160,6 @@ def validate_period(period)
142160
ERR
143161
end
144162

145-
def handles?(sym)
146-
kind.to_s == sym.to_s
147-
end
148-
149-
def inherited(klass)
150-
registry.register(klass)
151-
end
152-
153163
private
154164

155165
def build_kind_legend

lib/sof/cycle_registry.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def cycle_classes
1414
end
1515

1616
def handling(kind)
17-
cycle_classes.find { |klass| klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle")
17+
cycle_classes.find { |klass| klass.respond_to?(:handles?) && klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle")
1818
end
1919
end
2020
end

lib/sof/cycles/end_of.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def self.examples
2727
end
2828

2929
def to_s
30-
return dormant_to_s if parser.parser.dormant? || from_date.nil?
30+
return dormant_to_s if parser.dormant? || from_date.nil?
3131

3232
"#{volume}x by #{final_date.to_fs(:american)}"
3333
end

lib/sof/time_span.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,11 @@ def for(count, period_notation)
7070
end
7171

7272
def for_notation(notation)
73+
return NullPeriod if notation.nil? || notation.to_s.empty?
74+
7375
DatePeriod.types.find do |klass|
7476
klass.code == notation.to_s.upcase
75-
end
77+
end || raise(InvalidPeriod, "'#{notation}' is not a valid period")
7678
end
7779

7880
def types = @types ||= Concurrent::Set.new
@@ -114,6 +116,28 @@ def humanized_period
114116
"#{period}s"
115117
end
116118

119+
class NullPeriod < self
120+
@period = nil
121+
@code = nil
122+
@interval = nil
123+
124+
def initialize(count = nil)
125+
@count = nil
126+
end
127+
128+
def duration = 0
129+
130+
def end_date(date) = date
131+
132+
def begin_date(date) = date
133+
134+
def end_of_period(date) = nil
135+
136+
def beginning_of_period(date) = nil
137+
138+
def humanized_period = ""
139+
end
140+
117141
class Year < self
118142
@period = :year
119143
@code = "Y"
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe SOF::CycleRegistry do
6+
describe "thread safety" do
7+
let(:registry) { described_class.instance }
8+
let(:original_classes) { [] }
9+
10+
before do
11+
# Save the original classes
12+
@original_classes = registry.cycle_classes.to_a
13+
# Clear the registry before each test
14+
registry.cycle_classes.clear
15+
end
16+
17+
after do
18+
# Restore the original classes
19+
registry.cycle_classes.clear
20+
@original_classes.each { |klass| registry.register(klass) }
21+
end
22+
23+
it "safely registers multiple cycle classes concurrently" do
24+
# Create some test cycle classes
25+
test_classes = 10.times.map do |i|
26+
Class.new do
27+
define_singleton_method(:handles?) { |kind| kind == "test#{i}" }
28+
define_singleton_method(:name) { "TestCycle#{i}" }
29+
end
30+
end
31+
32+
threads = test_classes.map do |klass|
33+
Thread.new { registry.register(klass) }
34+
end
35+
36+
threads.each(&:join)
37+
38+
expect(registry.cycle_classes.size).to eq(10)
39+
test_classes.each do |klass|
40+
expect(registry.cycle_classes).to include(klass)
41+
end
42+
end
43+
44+
it "safely handles concurrent reads while registering" do
45+
# Pre-register some classes
46+
5.times do |i|
47+
klass = Class.new do
48+
define_singleton_method(:handles?) { |kind| kind == "existing#{i}" }
49+
end
50+
registry.register(klass)
51+
end
52+
53+
read_errors = []
54+
write_errors = []
55+
56+
# Create threads that read
57+
read_threads = 20.times.map do
58+
Thread.new do
59+
100.times do
60+
registry.cycle_classes.to_a
61+
registry.cycle_classes.size
62+
rescue => e
63+
read_errors << e
64+
end
65+
end
66+
end
67+
68+
# Create threads that write
69+
write_threads = 5.times.map do |i|
70+
Thread.new do
71+
klass = Class.new do
72+
define_singleton_method(:handles?) { |kind| kind == "new#{i}" }
73+
end
74+
begin
75+
registry.register(klass)
76+
rescue => e
77+
write_errors << e
78+
end
79+
end
80+
end
81+
82+
(read_threads + write_threads).each(&:join)
83+
84+
expect(read_errors).to be_empty
85+
expect(write_errors).to be_empty
86+
expect(registry.cycle_classes.size).to eq(10) # 5 existing + 5 new
87+
end
88+
89+
it "safely finds handlers concurrently" do
90+
# Register test classes
91+
10.times do |i|
92+
klass = Class.new do
93+
define_singleton_method(:handles?) { |kind| kind == "kind#{i}" }
94+
define_singleton_method(:name) { "Handler#{i}" }
95+
end
96+
registry.register(klass)
97+
end
98+
99+
errors = []
100+
results = Concurrent::Array.new
101+
102+
threads = 100.times.map do |i|
103+
Thread.new do
104+
kind_index = i % 10
105+
begin
106+
handler = registry.handling("kind#{kind_index}")
107+
results << handler
108+
rescue => e
109+
errors << e
110+
end
111+
end
112+
end
113+
114+
threads.each(&:join)
115+
116+
expect(errors).to be_empty
117+
expect(results.size).to eq(100)
118+
119+
# Verify all lookups found the correct handler
120+
results.each_with_index do |handler, i|
121+
expected_kind = "kind#{i % 10}"
122+
expect(handler.handles?(expected_kind)).to be true
123+
end
124+
end
125+
126+
it "raises error for unknown kind even under concurrent access" do
127+
# Register a known handler
128+
known_class = Class.new do
129+
define_singleton_method(:handles?) { |kind| kind == "known" }
130+
end
131+
registry.register(known_class)
132+
133+
errors = Concurrent::Array.new
134+
135+
threads = 10.times.map do
136+
Thread.new do
137+
registry.handling("unknown")
138+
rescue SOF::Cycle::InvalidKind => e
139+
errors << e
140+
end
141+
end
142+
143+
threads.each(&:join)
144+
145+
expect(errors.size).to eq(10)
146+
errors.each do |error|
147+
expect(error.message).to include("':unknown' is not a valid kind of Cycle")
148+
end
149+
end
150+
151+
it "maintains singleton behavior across threads" do
152+
instances = Concurrent::Array.new
153+
154+
threads = 20.times.map do
155+
Thread.new do
156+
instances << described_class.instance
157+
end
158+
end
159+
160+
threads.each(&:join)
161+
162+
expect(instances.uniq.size).to eq(1)
163+
expect(instances.first).to be(described_class.instance)
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)