ruby_reactor 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-config.json +15 -0
  3. data/.release-please-manifest.json +3 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +13 -0
  6. data/README.md +194 -9
  7. data/lib/ruby_reactor/configuration.rb +18 -1
  8. data/lib/ruby_reactor/context_serializer.rb +10 -1
  9. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  10. data/lib/ruby_reactor/executor/result_handler.rb +19 -0
  11. data/lib/ruby_reactor/executor/step_executor.rb +5 -0
  12. data/lib/ruby_reactor/executor.rb +145 -2
  13. data/lib/ruby_reactor/lock.rb +92 -0
  14. data/lib/ruby_reactor/map/result_enumerator.rb +4 -3
  15. data/lib/ruby_reactor/period.rb +67 -0
  16. data/lib/ruby_reactor/rate_limit.rb +74 -0
  17. data/lib/ruby_reactor/reactor.rb +1 -0
  18. data/lib/ruby_reactor/rspec/matchers.rb +171 -4
  19. data/lib/ruby_reactor/semaphore.rb +58 -0
  20. data/lib/ruby_reactor/sidekiq_workers/worker.rb +128 -9
  21. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  22. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor.rb +49 -0
  25. metadata +13 -51
  26. data/documentation/DAG.md +0 -457
  27. data/documentation/README.md +0 -123
  28. data/documentation/async_reactors.md +0 -369
  29. data/documentation/composition.md +0 -199
  30. data/documentation/core_concepts.md +0 -662
  31. data/documentation/data_pipelines.md +0 -230
  32. data/documentation/examples/inventory_management.md +0 -749
  33. data/documentation/examples/order_processing.md +0 -365
  34. data/documentation/examples/payment_processing.md +0 -654
  35. data/documentation/getting_started.md +0 -224
  36. data/documentation/images/failed_order_processing.png +0 -0
  37. data/documentation/images/payment_workflow.png +0 -0
  38. data/documentation/interrupts.md +0 -161
  39. data/documentation/retry_configuration.md +0 -357
  40. data/documentation/testing.md +0 -812
  41. data/gui/.gitignore +0 -24
  42. data/gui/README.md +0 -73
  43. data/gui/eslint.config.js +0 -23
  44. data/gui/index.html +0 -13
  45. data/gui/package-lock.json +0 -5925
  46. data/gui/package.json +0 -46
  47. data/gui/postcss.config.js +0 -6
  48. data/gui/public/vite.svg +0 -1
  49. data/gui/src/App.css +0 -42
  50. data/gui/src/App.tsx +0 -51
  51. data/gui/src/assets/react.svg +0 -1
  52. data/gui/src/components/DagVisualizer.tsx +0 -424
  53. data/gui/src/components/Dashboard.tsx +0 -163
  54. data/gui/src/components/ErrorBoundary.tsx +0 -47
  55. data/gui/src/components/ReactorDetail.tsx +0 -135
  56. data/gui/src/components/StepInspector.tsx +0 -492
  57. data/gui/src/components/__tests__/DagVisualizer.test.tsx +0 -140
  58. data/gui/src/components/__tests__/ReactorDetail.test.tsx +0 -111
  59. data/gui/src/components/__tests__/StepInspector.test.tsx +0 -408
  60. data/gui/src/globals.d.ts +0 -7
  61. data/gui/src/index.css +0 -14
  62. data/gui/src/lib/utils.ts +0 -13
  63. data/gui/src/main.tsx +0 -14
  64. data/gui/src/test/setup.ts +0 -11
  65. data/gui/tailwind.config.js +0 -11
  66. data/gui/tsconfig.app.json +0 -28
  67. data/gui/tsconfig.json +0 -7
  68. data/gui/tsconfig.node.json +0 -26
  69. data/gui/vite.config.ts +0 -8
  70. data/gui/vitest.config.ts +0 -13
@@ -34,9 +34,22 @@ module RubyReactor
34
34
  }
35
35
  )
36
36
  @result = nil
37
+ @acquired_lock = nil
38
+ @acquired_semaphore = nil
37
39
  end
38
40
 
39
41
  def execute
42
+ skipped = check_period_gate
43
+ if skipped
44
+ @result = skipped
45
+ update_context_status(@result)
46
+ save_context
47
+ return @result
48
+ end
49
+
50
+ check_rate_limit
51
+ acquire_locks
52
+
40
53
  input_validator = InputValidator.new(@reactor_class, @context)
41
54
  input_validator.validate!
42
55
 
@@ -49,18 +62,25 @@ module RubyReactor
49
62
 
50
63
  @result = @step_executor.execute_all_steps
51
64
  update_context_status(@result)
65
+ mark_period_on_success(@result)
52
66
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
53
67
  @result
68
+ rescue RubyReactor::Lock::AcquisitionError,
69
+ RubyReactor::Semaphore::AcquisitionError,
70
+ RubyReactor::RateLimit::ExceededError => e
71
+ raise e
54
72
  rescue StandardError => e
55
73
  @result = @result_handler.handle_execution_error(e)
56
74
  update_context_status(@result)
57
75
  @result
58
76
  ensure
77
+ release_locks
59
78
  save_context
60
79
  end
61
80
 
62
81
  def resume_execution
63
82
  @context.status = :running
83
+ acquire_locks
64
84
  prepare_for_resume
65
85
  save_context
66
86
 
@@ -71,15 +91,21 @@ module RubyReactor
71
91
  end
72
92
 
73
93
  update_context_status(@result)
94
+ mark_period_on_success(@result)
74
95
 
75
96
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
76
97
 
77
98
  @result
99
+ rescue RubyReactor::Lock::AcquisitionError,
100
+ RubyReactor::Semaphore::AcquisitionError,
101
+ RubyReactor::RateLimit::ExceededError => e
102
+ raise e
78
103
  rescue StandardError => e
79
104
  handle_resume_error(e)
80
105
  update_context_status(@result)
81
106
  @result
82
107
  ensure
108
+ release_locks
83
109
  save_context
84
110
  end
85
111
 
@@ -110,12 +136,122 @@ module RubyReactor
110
136
 
111
137
  private
112
138
 
139
+ def acquire_locks
140
+ acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
141
+ acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
142
+ end
143
+
144
+ # Consume one slot from each configured rate-limit window. Raises
145
+ # `RubyReactor::RateLimit::ExceededError` (carrying a `retry_after_seconds`
146
+ # hint) if any window is full. Only consulted on initial `execute`; resumes
147
+ # never re-check (a paused reactor must not block itself on resume).
148
+ def check_rate_limit
149
+ return unless @reactor_class.respond_to?(:rate_limit_config) && @reactor_class.rate_limit_config
150
+
151
+ config = @reactor_class.rate_limit_config
152
+ key_base = config[:key_proc].call(@context.inputs)
153
+
154
+ RubyReactor::RateLimit.new(key_base, limits: config[:limits]).check_and_increment!
155
+ end
156
+
157
+ # Returns a Skipped result if the period bucket is already marked, else nil.
158
+ # Only consulted on initial `execute`; resumes never re-check (a paused run
159
+ # must not skip itself when its own marker eventually appears).
160
+ def check_period_gate
161
+ return nil unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
162
+
163
+ config = @reactor_class.period_config
164
+ key = period_key(config)
165
+ return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
166
+
167
+ RubyReactor::Skipped.new(reason: :period, period_key: key)
168
+ end
169
+
170
+ def mark_period_on_success(result)
171
+ return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
172
+ return unless result.is_a?(RubyReactor::Success)
173
+ return if result.is_a?(RubyReactor::Skipped)
174
+
175
+ config = @reactor_class.period_config
176
+ ttl = RubyReactor::Period.ttl_seconds(config[:every])
177
+ RubyReactor.configuration.storage_adapter.period_mark(period_key(config), ttl)
178
+ end
179
+
180
+ def period_key(config)
181
+ base = config[:key_proc].call(@context.inputs)
182
+ RubyReactor::Period.key(base, config[:every])
183
+ end
184
+
185
+ def acquire_exclusive_lock
186
+ config = @reactor_class.lock_config
187
+ key = config[:key_proc].call(@context.inputs)
188
+
189
+ # Use root context ID as owner to allow re-entrancy across nested reactors
190
+ owner = (@context.root_context || @context).context_id
191
+
192
+ lock = RubyReactor::Lock.new(
193
+ key,
194
+ owner: owner,
195
+ ttl: config[:ttl],
196
+ wait: contention_wait(config[:wait]),
197
+ auto_extend: config.fetch(:auto_extend, true)
198
+ )
199
+ lock.acquire
200
+ @acquired_lock = lock
201
+ end
202
+
203
+ def acquire_semaphore
204
+ config = @reactor_class.semaphore_config
205
+ key = config[:key_proc].call(@context.inputs)
206
+ limit = config[:limit]
207
+
208
+ semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
209
+ semaphore.acquire
210
+ @acquired_semaphore = semaphore
211
+ end
212
+
213
+ # Inside a Sidekiq worker we'd rather snooze the job via perform_in than
214
+ # tie up the worker thread on a BLPOP / sleep loop. The non-blocking path
215
+ # fails fast and the Worker rescue branch reschedules.
216
+ def contention_wait(configured_wait)
217
+ return 0 if @context.inline_async_execution
218
+
219
+ configured_wait
220
+ end
221
+
222
+ def release_locks
223
+ release_one("semaphore", @acquired_semaphore) if @acquired_semaphore
224
+ @acquired_semaphore = nil
225
+
226
+ return unless @acquired_lock
227
+
228
+ release_one("lock", @acquired_lock)
229
+ @acquired_lock = nil
230
+ end
231
+
232
+ def release_one(kind, primitive)
233
+ released = primitive.release
234
+ return if released
235
+
236
+ RubyReactor.configuration.logger.warn(
237
+ "RubyReactor #{kind} '#{primitive.key}' was not held at release time " \
238
+ "(likely TTL expired or owner changed)"
239
+ )
240
+ rescue StandardError => e
241
+ # Never let release break the ensure chain — log and move on.
242
+ RubyReactor.configuration.logger.warn(
243
+ "RubyReactor failed to release #{kind} '#{primitive.key}': #{e.message}"
244
+ )
245
+ end
246
+
113
247
  def update_context_status(result)
114
248
  return unless result
115
249
 
116
250
  case result
117
251
  when RubyReactor::AsyncResult
118
252
  @context.status = :running
253
+ when RubyReactor::Skipped
254
+ @context.status = :skipped
119
255
  when RubyReactor::Success
120
256
  @context.status = :completed
121
257
  when RubyReactor::Failure
@@ -148,8 +284,15 @@ module RubyReactor
148
284
  @result = @step_executor.execute_all_steps
149
285
  else
150
286
  case result
151
- when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult, RubyReactor::InterruptResult
152
- # Step was requeued, failed, or handed off to async - return the result
287
+ # Skipped must be listed before Success (Skipped < Success) so the
288
+ # halt path wins over the "continue with remaining steps" path.
289
+ when RubyReactor::Skipped,
290
+ RetryQueuedResult,
291
+ RubyReactor::Failure,
292
+ RubyReactor::AsyncResult,
293
+ RubyReactor::InterruptResult
294
+ # Terminal: step was skipped, requeued, failed, paused, or handed
295
+ # off to async. Return the result as-is.
153
296
  @result = result
154
297
  when RubyReactor::Success
155
298
  # Step succeeded, continue with remaining steps
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Lock
5
+ class AcquisitionError < StandardError; end
6
+
7
+ # Minimum interval between auto-extend pings; protects very small TTLs.
8
+ MIN_EXTEND_INTERVAL = 1.0
9
+
10
+ attr_reader :key, :owner, :ttl, :wait, :auto_extend
11
+
12
+ def initialize(key, owner:, ttl: 60, wait: 0, auto_extend: true)
13
+ @key = "lock:#{key}"
14
+ @owner = owner
15
+ @ttl = ttl
16
+ @wait = wait
17
+ @auto_extend = auto_extend
18
+ @extender = nil
19
+ @extender_running = false
20
+ end
21
+
22
+ def acquire
23
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ loop do
26
+ if adapter.lock_acquire(@key, @owner, @ttl)
27
+ start_extender if @auto_extend
28
+ return true
29
+ end
30
+
31
+ if @wait.zero? || (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) >= @wait
32
+ raise AcquisitionError, "Could not acquire lock '#{@key}' for owner '#{@owner}'"
33
+ end
34
+
35
+ sleep 0.1
36
+ end
37
+ end
38
+
39
+ def release
40
+ stop_extender
41
+ adapter.lock_release(@key, @owner)
42
+ end
43
+
44
+ def synchronize
45
+ acquire
46
+ yield
47
+ ensure
48
+ release
49
+ end
50
+
51
+ private
52
+
53
+ def start_extender
54
+ return if @extender&.alive?
55
+
56
+ interval = [@ttl / 3.0, MIN_EXTEND_INTERVAL].max
57
+ @extender_running = true
58
+
59
+ @extender = Thread.new do
60
+ while @extender_running
61
+ sleep interval
62
+ break unless @extender_running
63
+
64
+ begin
65
+ adapter.lock_extend(@key, @owner, @ttl)
66
+ rescue StandardError => e
67
+ RubyReactor.configuration.logger.warn(
68
+ "Lock auto-extend failed for '#{@key}': #{e.message}"
69
+ )
70
+ break
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def stop_extender
77
+ @extender_running = false
78
+ thread = @extender
79
+ @extender = nil
80
+ return unless thread
81
+
82
+ thread.wakeup if thread.alive?
83
+ thread.join(0.1)
84
+ rescue StandardError
85
+ # Best-effort shutdown; never let extender teardown break release.
86
+ end
87
+
88
+ def adapter
89
+ RubyReactor.configuration.storage_adapter
90
+ end
91
+ end
92
+ end
@@ -60,6 +60,7 @@ module RubyReactor
60
60
  end
61
61
 
62
62
  def [](index)
63
+ index += count if index.negative?
63
64
  return nil if index.negative? || index >= count
64
65
 
65
66
  results = @storage.retrieve_map_results_batch(
@@ -80,15 +81,15 @@ module RubyReactor
80
81
  end
81
82
 
82
83
  def last
83
- self[count - 1]
84
+ self[-1]
84
85
  end
85
86
 
86
87
  def successes
87
- lazy.select { |result| result.is_a?(RubyReactor::Success) }.map(&:value)
88
+ lazy.grep(RubyReactor::Success).map(&:value)
88
89
  end
89
90
 
90
91
  def failures
91
- lazy.select { |result| result.is_a?(RubyReactor::Failure) }.map(&:error)
92
+ lazy.grep(RubyReactor::Failure).map(&:error)
92
93
  end
93
94
 
94
95
  private
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Calendar-aligned bucket helpers for `with_period` dedup gating.
5
+ #
6
+ # A *bucket* is a deterministic string derived from the current UTC time and
7
+ # the configured period. Two calls in the same bucket dedup to the same
8
+ # Redis marker key; calls that cross a calendar boundary land in different
9
+ # buckets and run again.
10
+ module Period
11
+ SYMBOLIC_PERIODS = {
12
+ second: 1,
13
+ minute: 60,
14
+ hour: 60 * 60,
15
+ day: 60 * 60 * 24,
16
+ week: 60 * 60 * 24 * 7,
17
+ month: 60 * 60 * 24 * 31,
18
+ year: 60 * 60 * 24 * 366
19
+ }.freeze
20
+
21
+ # Build the bucket id for a given period at a given moment. UTC, calendar
22
+ # aligned for symbolic periods, index-based for integer seconds.
23
+ def self.bucket_id(every, now: Time.now.utc)
24
+ case every
25
+ when :second then now.strftime("%Y-%m-%dT%H-%M-%S")
26
+ when :minute then now.strftime("%Y-%m-%dT%H-%M")
27
+ when :hour then now.strftime("%Y-%m-%dT%H")
28
+ when :day then now.strftime("%Y-%m-%d")
29
+ when :week then now.strftime("%G-W%V")
30
+ when :month then now.strftime("%Y-%m")
31
+ when :year then now.strftime("%Y")
32
+ when Integer
33
+ raise ArgumentError, "Period seconds must be positive" unless every.positive?
34
+
35
+ "i#{now.to_i / every}"
36
+ else
37
+ raise ArgumentError, "Unknown period: #{every.inspect}"
38
+ end
39
+ end
40
+
41
+ # TTL for the marker. Twice the period length so the marker survives clock
42
+ # skew across the boundary and reliably dedups the very next attempt.
43
+ def self.ttl_seconds(every)
44
+ base = period_seconds(every)
45
+ base * 2
46
+ end
47
+
48
+ def self.period_seconds(every)
49
+ case every
50
+ when Symbol
51
+ SYMBOLIC_PERIODS.fetch(every) do
52
+ raise ArgumentError, "Unknown period: #{every.inspect}"
53
+ end
54
+ when Integer
55
+ raise ArgumentError, "Period seconds must be positive" unless every.positive?
56
+
57
+ every
58
+ else
59
+ raise ArgumentError, "Unknown period: #{every.inspect}"
60
+ end
61
+ end
62
+
63
+ def self.key(base, every, now: Time.now.utc)
64
+ "period:#{base}:#{bucket_id(every, now: now)}"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Distributed rate limiter (fixed-window counter, multi-window aware).
5
+ #
6
+ # A `RateLimit` is configured with one or more (period, limit) tuples.
7
+ # `check_and_increment!` atomically verifies every window has headroom and,
8
+ # if so, increments all of them. The check uses a single Lua script so
9
+ # nothing slips through between read and write.
10
+ #
11
+ # When any window is over-limit the call raises `ExceededError` carrying a
12
+ # `retry_after_seconds` hint (time until the tightest failing bucket rolls).
13
+ # The Sidekiq worker uses this hint to schedule a precise snooze.
14
+ class RateLimit
15
+ class ExceededError < StandardError
16
+ attr_reader :retry_after_seconds, :key_base, :limit, :period_seconds, :period_name
17
+
18
+ def initialize(message, retry_after_seconds:, key_base:, limit:, period_seconds:, period_name:) # rubocop:disable Metrics/ParameterLists
19
+ super(message)
20
+ @retry_after_seconds = retry_after_seconds
21
+ @key_base = key_base
22
+ @limit = limit
23
+ @period_seconds = period_seconds
24
+ @period_name = period_name
25
+ end
26
+ end
27
+
28
+ attr_reader :key_base, :limits
29
+
30
+ # @param key_base [String] caller-provided key (e.g. "stripe:account_42")
31
+ # @param limits [Array<Hash>] each hash needs :period_seconds, :limit,
32
+ # and :name (used in the Redis bucket key and the error message)
33
+ def initialize(key_base, limits:)
34
+ @key_base = key_base
35
+ @limits = limits
36
+ end
37
+
38
+ def check_and_increment!
39
+ now = Time.now.to_i
40
+ keys = @limits.map { |spec| bucket_key(spec, now) }
41
+ argv = [now]
42
+ @limits.each do |spec|
43
+ argv << spec[:period_seconds]
44
+ argv << spec[:limit]
45
+ argv << (spec[:period_seconds] * 2) # TTL: generous, auto-cleans stale buckets
46
+ end
47
+
48
+ allowed, retry_after, failed_index = adapter.rate_limit_check_and_increment(keys, argv)
49
+ return true if allowed == 1
50
+
51
+ failed = @limits[failed_index - 1]
52
+ raise ExceededError.new(
53
+ "Rate limit '#{@key_base}' exceeded (#{failed[:limit]}/#{failed[:name]}); " \
54
+ "retry in #{retry_after}s",
55
+ retry_after_seconds: retry_after,
56
+ key_base: @key_base,
57
+ limit: failed[:limit],
58
+ period_seconds: failed[:period_seconds],
59
+ period_name: failed[:name]
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def bucket_key(spec, now)
66
+ bucket_id = now / spec[:period_seconds]
67
+ "rate:#{@key_base}:#{spec[:name]}:#{bucket_id}"
68
+ end
69
+
70
+ def adapter
71
+ RubyReactor.configuration.storage_adapter
72
+ end
73
+ end
74
+ end
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  # rubocop:disable Metrics/ClassLength
5
5
  class Reactor
6
6
  include RubyReactor::Dsl::Reactor
7
+ include RubyReactor::Dsl::Lockable
7
8
 
8
9
  attr_reader :context, :result, :undo_trace, :execution_trace
9
10
 
@@ -2,20 +2,23 @@
2
2
 
3
3
  module RubyReactor
4
4
  module RSpec
5
+ # rubocop:disable Metrics/ModuleLength
5
6
  module Matchers
6
7
  # rubocop:disable Metrics/BlockLength
7
8
  ::RSpec::Matchers.define :be_success do
8
9
  match do |subject|
9
- subject.ensure_executed!
10
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
10
11
  subject.success?
11
12
  end
12
13
 
13
14
  failure_message do |subject|
14
- result = subject.result
15
+ result = subject.respond_to?(:result) ? subject.result : subject
15
16
  if result&.failure?
16
17
  format_failure_message(result)
17
- else
18
+ elsif subject.respond_to?(:reactor_instance)
18
19
  "expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
20
+ else
21
+ "expected #{subject.inspect} to be success"
19
22
  end
20
23
  end
21
24
 
@@ -62,7 +65,7 @@ module RubyReactor
62
65
 
63
66
  ::RSpec::Matchers.define :be_failure do
64
67
  match do |subject|
65
- subject.ensure_executed!
68
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
66
69
  subject.failure?
67
70
  end
68
71
 
@@ -249,8 +252,172 @@ module RubyReactor
249
252
  end
250
253
  end
251
254
 
255
+ # ---------------------------------------------------------------------
256
+ # Lock / Semaphore / Rate-limit / Period state matchers
257
+ # ---------------------------------------------------------------------
258
+ #
259
+ # These assert against the live Redis state via the configured storage
260
+ # adapter, so they work for any test that has actually exercised the
261
+ # reactor (or interacted with the primitives directly).
262
+
263
+ def self.coordination_adapter
264
+ RubyReactor.configuration.storage_adapter
265
+ end
266
+
267
+ # Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
268
+ # any object with a `skipped?` predicate.
269
+ #
270
+ # Examples:
271
+ # expect(result).to be_skipped
272
+ # expect(result).to be_skipped.because(:period)
273
+ # expect(result).to be_skipped.at_step(:second)
274
+ ::RSpec::Matchers.define :be_skipped do
275
+ match do |subject|
276
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
277
+ actual = subject.respond_to?(:result) ? subject.result : subject
278
+ next false unless actual.respond_to?(:skipped?) && actual.skipped?
279
+ next false if @expected_reason && actual.reason != @expected_reason
280
+ next false if @expected_step && actual.step_name != @expected_step
281
+
282
+ true
283
+ end
284
+
285
+ chain :because do |reason|
286
+ @expected_reason = reason
287
+ end
288
+
289
+ chain :at_step do |step|
290
+ @expected_step = step
291
+ end
292
+
293
+ failure_message do |subject|
294
+ actual = subject.respond_to?(:result) ? subject.result : subject
295
+ if !actual.respond_to?(:skipped?) || !actual.skipped?
296
+ "expected result to be Skipped, got #{actual.class}"
297
+ elsif @expected_reason && actual.reason != @expected_reason
298
+ "expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
299
+ else
300
+ "expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
301
+ end
302
+ end
303
+
304
+ failure_message_when_negated do
305
+ "expected result not to be Skipped"
306
+ end
307
+ end
308
+
309
+ # Asserts that an exclusive lock is currently held in Redis. Subject is
310
+ # the user-provided lock key (without the "lock:" prefix).
311
+ #
312
+ # expect("order:42").to be_locked
313
+ # expect("order:42").to be_locked.by("ctx-abc")
314
+ ::RSpec::Matchers.define :be_locked do
315
+ match do |key|
316
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
317
+ next false unless info
318
+ next true unless @expected_owner
319
+
320
+ info[:owner] == @expected_owner
321
+ end
322
+
323
+ chain :by do |owner|
324
+ @expected_owner = owner
325
+ end
326
+
327
+ failure_message do |key|
328
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
329
+ if info.nil?
330
+ "expected lock 'lock:#{key}' to be held, but it is free"
331
+ else
332
+ "expected lock 'lock:#{key}' to be held by #{@expected_owner.inspect}, " \
333
+ "but is held by #{info[:owner].inspect}"
334
+ end
335
+ end
336
+
337
+ failure_message_when_negated do |key|
338
+ info = Matchers.coordination_adapter.lock_info("lock:#{key}")
339
+ "expected lock 'lock:#{key}' not to be held, but is held by #{info[:owner].inspect}"
340
+ end
341
+ end
342
+
343
+ # Asserts the number of unallocated semaphore tokens. Subject is the
344
+ # user-provided semaphore name (without the "semaphore:" prefix).
345
+ #
346
+ # expect("api_limit").to have_available_tokens(3)
347
+ ::RSpec::Matchers.define :have_available_tokens do |expected|
348
+ match do |name|
349
+ Matchers.coordination_adapter.semaphore_state(name)[:available] == expected
350
+ end
351
+
352
+ failure_message do |name|
353
+ state = Matchers.coordination_adapter.semaphore_state(name)
354
+ "expected semaphore '#{name}' to have #{expected} available tokens, " \
355
+ "got #{state[:available]} (held: #{state[:held]}, limit: #{state[:limit]})"
356
+ end
357
+ end
358
+
359
+ # Asserts the number of currently-checked-out semaphore tokens.
360
+ #
361
+ # expect("api_limit").to have_held_tokens(2)
362
+ ::RSpec::Matchers.define :have_held_tokens do |expected|
363
+ match do |name|
364
+ Matchers.coordination_adapter.semaphore_state(name)[:held] == expected
365
+ end
366
+
367
+ failure_message do |name|
368
+ state = Matchers.coordination_adapter.semaphore_state(name)
369
+ "expected semaphore '#{name}' to have #{expected} held tokens, " \
370
+ "got #{state[:held]} (available: #{state[:available]}, limit: #{state[:limit]})"
371
+ end
372
+ end
373
+
374
+ # Asserts the current rate-limit counter for a (key_base, period) pair.
375
+ # Use `.for(period_unit)` to specify which window.
376
+ #
377
+ # expect("stripe:42").to have_rate_limit_count(3).for(:second)
378
+ ::RSpec::Matchers.define :have_rate_limit_count do |expected|
379
+ match do |key_base|
380
+ raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
381
+
382
+ Matchers.coordination_adapter.rate_limit_count(key_base, @period) == expected
383
+ end
384
+
385
+ chain :for do |period|
386
+ @period = period
387
+ end
388
+
389
+ failure_message do |key_base|
390
+ actual = Matchers.coordination_adapter.rate_limit_count(key_base, @period)
391
+ "expected rate-limit '#{key_base}' (#{@period}) count to be #{expected}, got #{actual}"
392
+ end
393
+ end
394
+
395
+ # Asserts that a `with_period` bucket has been marked. Use `.for(period)`.
396
+ #
397
+ # expect("daily_report:7").to be_period_marked.for(:day)
398
+ ::RSpec::Matchers.define :be_period_marked do
399
+ match do |key_base|
400
+ raise ArgumentError, "be_period_marked requires .for(period)" unless @period
401
+
402
+ Matchers.coordination_adapter.period_marker?(key_base, @period)
403
+ end
404
+
405
+ chain :for do |period|
406
+ @period = period
407
+ end
408
+
409
+ failure_message do |key_base|
410
+ "expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} to be marked, but it is not"
411
+ end
412
+
413
+ failure_message_when_negated do |key_base|
414
+ "expected period bucket #{RubyReactor::Period.key(key_base, @period).inspect} not to be marked, but it is"
415
+ end
416
+ end
417
+
252
418
  # Add more matchers as per plan
253
419
  # rubocop:enable Metrics/BlockLength
254
420
  end
421
+ # rubocop:enable Metrics/ModuleLength
255
422
  end
256
423
  end