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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Semaphore
5
+ class AcquisitionError < StandardError; end
6
+
7
+ attr_reader :key, :limit, :wait, :token
8
+
9
+ def initialize(key, limit: 1, wait: 0)
10
+ @key = "semaphore:#{key}"
11
+ @limit = limit
12
+ @wait = wait
13
+ @token = nil
14
+ end
15
+
16
+ def acquire # rubocop:disable Naming/PredicateMethod
17
+ ensure_initialized
18
+
19
+ token = adapter.semaphore_acquire(@key, timeout: @wait)
20
+ raise AcquisitionError, "Could not acquire semaphore '#{@key}' within #{@wait} seconds" unless token
21
+
22
+ @token = token
23
+ true
24
+ end
25
+
26
+ # Returns true if a held token was returned to the pool.
27
+ # Idempotent: a second release (or one without a prior acquire) is a no-op.
28
+ def release
29
+ return false unless @token
30
+
31
+ result = adapter.semaphore_release(@key, @token, @limit)
32
+ @token = nil
33
+ result
34
+ end
35
+
36
+ def synchronize
37
+ acquire
38
+ yield
39
+ ensure
40
+ release
41
+ end
42
+
43
+ private
44
+
45
+ def ensure_initialized
46
+ # Double-checked locking pattern optimized for Redis
47
+ # 1. Optimistic check (if key exists)
48
+ return if adapter.semaphore_exists?(@key)
49
+
50
+ # 2. Try to init
51
+ adapter.semaphore_init(@key, @limit)
52
+ end
53
+
54
+ def adapter
55
+ RubyReactor.configuration.storage_adapter
56
+ end
57
+ end
58
+ end
@@ -17,8 +17,17 @@ module RubyReactor
17
17
  # Handle infrastructure failures (network, Redis, etc.)
18
18
  end
19
19
 
20
- def perform(serialized_context, reactor_class_name = nil)
21
- context = ContextSerializer.deserialize(serialized_context)
20
+ def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
21
+ begin
22
+ context = ContextSerializer.deserialize(serialized_context)
23
+ rescue RubyReactor::Error::DeserializationError,
24
+ RubyReactor::Error::SchemaVersionError => e
25
+ # Permanent failures — retrying the same blob will keep failing.
26
+ # Mark the context as failed (best-effort) and return so Sidekiq
27
+ # does not burn its retry budget.
28
+ handle_deserialization_failure(serialized_context, reactor_class_name, e)
29
+ return
30
+ end
22
31
 
23
32
  # If reactor_class_name is provided, use it to get the reactor class
24
33
  # This handles cases where the class can't be found via const_get
@@ -35,18 +44,128 @@ module RubyReactor
35
44
  # Mark that we're executing inline to prevent nested async calls
36
45
  context.inline_async_execution = true
37
46
 
38
- # Resume execution from the failed step
39
- executor = Executor.new(context.reactor_class, {}, context)
40
- executor.resume_execution
41
- executor.save_context
42
- executor.result
47
+ begin
48
+ # Resume execution from the failed step
49
+ executor = Executor.new(context.reactor_class, {}, context)
50
+ executor.resume_execution
51
+ executor.save_context
52
+
53
+ # Return the executor (which now has the result stored in it)
54
+ executor
55
+ rescue RubyReactor::Lock::AcquisitionError,
56
+ RubyReactor::Semaphore::AcquisitionError,
57
+ RubyReactor::RateLimit::ExceededError => e
58
+ # Snooze on expected concurrency or rate contention. We avoid
59
+ # Sidekiq's native retry path so this doesn't burn the job's retry
60
+ # budget or appear as an error in dashboards. After the configured
61
+ # cap is reached we escalate by marking the reactor as failed.
62
+ handle_snooze(serialized_context, reactor_class_name, context, snooze_count, e)
63
+ end
43
64
  end
44
65
 
45
66
  private
46
67
 
68
+ def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
69
+ config = RubyReactor.configuration
70
+ max = config.lock_snooze_max_attempts
71
+
72
+ if max != :infinity && snooze_count >= max
73
+ escalate_snooze(context, snooze_count, error)
74
+ return
75
+ end
76
+
77
+ delay = compute_snooze_delay(config, error)
78
+ self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
79
+ end
80
+
81
+ # Use the error's `retry_after_seconds` hint when available
82
+ # (RateLimit::ExceededError carries the time until the bucket rolls);
83
+ # otherwise fall back to the configured base + jitter for lock/semaphore
84
+ # contention which has no precise hint.
85
+ def compute_snooze_delay(config, error)
86
+ jitter = config.lock_snooze_jitter.to_f
87
+ jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
88
+
89
+ if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
90
+ [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
91
+ else
92
+ config.lock_snooze_base_delay.to_f + jitter_amount
93
+ end
94
+ end
95
+
96
+ def escalate_snooze(context, snooze_count, error)
97
+ RubyReactor.configuration.logger.warn(
98
+ "RubyReactor snooze limit reached after #{snooze_count} attempts " \
99
+ "for context #{context.context_id}: #{error.message}"
100
+ )
101
+
102
+ context.status = :failed
103
+ context.failure_reason = {
104
+ message: error.message,
105
+ exception_class: error.class.name,
106
+ snooze_attempts: snooze_count
107
+ }
108
+
109
+ serialized = ContextSerializer.serialize(context)
110
+ reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
111
+ RubyReactor.configuration.storage_adapter.store_context(
112
+ context.context_id,
113
+ serialized,
114
+ reactor_class_name
115
+ )
116
+ end
117
+
47
118
  def log_infrastructure_failure(msg, exception)
48
- ::Sidekiq.logger.error("RubyReactor infrastructure failure: #{exception.message}")
49
- ::Sidekiq.logger.error("Job details: #{msg.inspect}")
119
+ RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
120
+ RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
121
+ end
122
+
123
+ def handle_deserialization_failure(serialized_context, reactor_class_name, error)
124
+ metadata = extract_failure_metadata(serialized_context)
125
+ context_id = metadata[:context_id]
126
+ resolved_reactor_class_name = reactor_class_name || metadata[:reactor_class_name]
127
+
128
+ RubyReactor.configuration.logger.error(
129
+ "RubyReactor deserialization failure for context " \
130
+ "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
131
+ )
132
+
133
+ return unless context_id && resolved_reactor_class_name
134
+
135
+ payload = build_failed_context_payload(context_id, resolved_reactor_class_name, error)
136
+ RubyReactor.configuration.storage_adapter.store_context(
137
+ context_id,
138
+ payload,
139
+ resolved_reactor_class_name
140
+ )
141
+ rescue StandardError => e
142
+ # Don't let a persistence failure mask the original deserialization error.
143
+ RubyReactor.configuration.logger.error(
144
+ "RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
145
+ )
146
+ end
147
+
148
+ def extract_failure_metadata(serialized_context)
149
+ data = JSON.parse(serialized_context)
150
+ {
151
+ context_id: data["context_id"],
152
+ reactor_class_name: data["reactor_class"]
153
+ }
154
+ rescue StandardError
155
+ {}
156
+ end
157
+
158
+ def build_failed_context_payload(context_id, reactor_class_name, error)
159
+ JSON.generate(
160
+ "schema_version" => ContextSerializer::SCHEMA_VERSION,
161
+ "context_id" => context_id,
162
+ "reactor_class" => reactor_class_name,
163
+ "status" => "failed",
164
+ "failure_reason" => {
165
+ "message" => error.message,
166
+ "exception_class" => error.class.name
167
+ }
168
+ )
50
169
  end
51
170
  end
52
171
  end
@@ -6,6 +6,8 @@ require "json"
6
6
  module RubyReactor
7
7
  module Storage
8
8
  class RedisAdapter < Adapter
9
+ include RedisLocking
10
+
9
11
  def initialize(redis_config)
10
12
  super()
11
13
  @redis = Redis.new(redis_config)
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # Adapter contract uses non-`?` names for methods that return booleans
6
+ # (`lock_acquire`, `semaphore_release`, etc). Renaming would break the
7
+ # public storage adapter API, so silence the predicate-name cop here.
8
+ # rubocop:disable Naming/PredicateMethod
9
+ module RedisLocking
10
+ # Scripts for Lock Primitives
11
+ LOCK_ACQUIRE_SCRIPT = <<~LUA
12
+ local key = KEYS[1]
13
+ local owner = ARGV[1]
14
+ local ttl = tonumber(ARGV[2])
15
+
16
+ if redis.call('exists', key) == 0 then
17
+ redis.call('hset', key, 'owner', owner)
18
+ redis.call('hset', key, 'count', 1)
19
+ redis.call('expire', key, ttl)
20
+ return 1
21
+ elseif redis.call('hget', key, 'owner') == owner then
22
+ redis.call('hincrby', key, 'count', 1)
23
+ redis.call('expire', key, ttl)
24
+ return 1
25
+ else
26
+ return 0
27
+ end
28
+ LUA
29
+
30
+ LOCK_RELEASE_SCRIPT = <<~LUA
31
+ local key = KEYS[1]
32
+ local owner = ARGV[1]
33
+
34
+ if redis.call('hget', key, 'owner') == owner then
35
+ local new_count = redis.call('hincrby', key, 'count', -1)
36
+ if new_count <= 0 then
37
+ redis.call('del', key)
38
+ end
39
+ return 1
40
+ else
41
+ return 0
42
+ end
43
+ LUA
44
+
45
+ LOCK_EXTEND_SCRIPT = <<~LUA
46
+ local key = KEYS[1]
47
+ local owner = ARGV[1]
48
+ local ttl = tonumber(ARGV[2])
49
+
50
+ if redis.call('hget', key, 'owner') == owner then
51
+ redis.call('expire', key, ttl)
52
+ return 1
53
+ else
54
+ return 0
55
+ end
56
+ LUA
57
+
58
+ # Lock Primitives
59
+
60
+ def lock_acquire(key, owner, ttl)
61
+ result = @redis.eval(LOCK_ACQUIRE_SCRIPT, keys: [key], argv: [owner, ttl])
62
+ result == 1
63
+ end
64
+
65
+ def lock_release(key, owner)
66
+ result = @redis.eval(LOCK_RELEASE_SCRIPT, keys: [key], argv: [owner])
67
+ result == 1
68
+ end
69
+
70
+ def lock_extend(key, owner, ttl)
71
+ result = @redis.eval(LOCK_EXTEND_SCRIPT, keys: [key], argv: [owner, ttl])
72
+ result == 1
73
+ end
74
+
75
+ # Semaphore Primitives
76
+ #
77
+ # Storage layout:
78
+ # LIST <key> — available token UUIDs
79
+ # SET <key>:held — tokens currently checked out
80
+ # STRING <key>:init — sentinel marking that init has run; value = limit
81
+ #
82
+ # Tokens are unique UUIDs so release can verify the caller actually holds
83
+ # one before pushing it back, blocking double-release and over-cap RPUSH.
84
+
85
+ SEM_ACQUIRE_SCRIPT = <<~LUA
86
+ local list_key = KEYS[1]
87
+ local held_key = KEYS[2]
88
+ local token = redis.call('lpop', list_key)
89
+ if not token then return false end
90
+ redis.call('sadd', held_key, token)
91
+ return token
92
+ LUA
93
+
94
+ SEM_RELEASE_SCRIPT = <<~LUA
95
+ local list_key = KEYS[1]
96
+ local held_key = KEYS[2]
97
+ local limit = tonumber(ARGV[1])
98
+ local token = ARGV[2]
99
+ if redis.call('srem', held_key, token) == 0 then
100
+ return 0
101
+ end
102
+ if redis.call('llen', list_key) >= limit then
103
+ return 0
104
+ end
105
+ redis.call('rpush', list_key, token)
106
+ return 1
107
+ LUA
108
+
109
+ SEMAPHORE_TTL = 86_400
110
+
111
+ def semaphore_init(key, limit)
112
+ return false unless @redis.set("#{key}:init", limit, nx: true, ex: SEMAPHORE_TTL)
113
+
114
+ tokens = Array.new(limit) { SecureRandom.uuid }
115
+ @redis.rpush(key, tokens)
116
+ @redis.expire(key, SEMAPHORE_TTL)
117
+ true
118
+ end
119
+
120
+ def semaphore_reset(key)
121
+ @redis.del(key)
122
+ @redis.del("#{key}:held")
123
+ @redis.del("#{key}:init")
124
+ end
125
+
126
+ def semaphore_acquire(key, timeout: 0)
127
+ held_key = "#{key}:held"
128
+
129
+ token = if timeout.to_f.positive?
130
+ result = @redis.blpop(key, timeout: timeout)
131
+ next_token = result&.last
132
+ @redis.sadd(held_key, next_token) if next_token
133
+ next_token
134
+ else
135
+ @redis.eval(SEM_ACQUIRE_SCRIPT, keys: [key, held_key], argv: [])
136
+ end
137
+
138
+ return nil unless token
139
+
140
+ @redis.expire(held_key, SEMAPHORE_TTL)
141
+ token
142
+ end
143
+
144
+ def semaphore_release(key, token, limit)
145
+ return false unless token
146
+
147
+ result = @redis.eval(
148
+ SEM_RELEASE_SCRIPT,
149
+ keys: [key, "#{key}:held"],
150
+ argv: [limit, token]
151
+ )
152
+ result == 1
153
+ end
154
+
155
+ def semaphore_exists?(key)
156
+ @redis.exists?("#{key}:init")
157
+ end
158
+
159
+ # Period Primitives — used by `with_period` to dedup bucketed runs.
160
+
161
+ def period_seen?(key)
162
+ @redis.exists?(key)
163
+ end
164
+
165
+ def period_mark(key, ttl)
166
+ @redis.set(key, "1", ex: ttl)
167
+ end
168
+
169
+ # Rate Limit Primitives — fixed-window counter, supports multiple
170
+ # windows in one atomic call. Two-pass inside Lua so a miss on the Nth
171
+ # window does not leave the previous N-1 incremented.
172
+ #
173
+ # ARGV layout: [now, period_1, limit_1, ttl_1, period_2, limit_2, ttl_2, ...]
174
+ # KEYS: [bucket_key_1, bucket_key_2, ...]
175
+ # Returns: [allowed (1|0), retry_after_seconds, failed_index]
176
+
177
+ RATE_LIMIT_SCRIPT = <<~LUA
178
+ local now = tonumber(ARGV[1])
179
+ local n = #KEYS
180
+
181
+ for i = 1, n do
182
+ local base = 2 + (i - 1) * 3
183
+ local period = tonumber(ARGV[base])
184
+ local lim = tonumber(ARGV[base + 1])
185
+ local count = tonumber(redis.call('get', KEYS[i]) or '0')
186
+ if count >= lim then
187
+ local retry_after = period - (now % period)
188
+ if retry_after <= 0 then retry_after = 1 end
189
+ return {0, retry_after, i}
190
+ end
191
+ end
192
+
193
+ for i = 1, n do
194
+ local base = 2 + (i - 1) * 3
195
+ local ttl = tonumber(ARGV[base + 2])
196
+ local count = redis.call('incr', KEYS[i])
197
+ if count == 1 then
198
+ redis.call('expire', KEYS[i], ttl)
199
+ end
200
+ end
201
+
202
+ return {1, 0, 0}
203
+ LUA
204
+
205
+ def rate_limit_check_and_increment(keys, argv)
206
+ @redis.eval(RATE_LIMIT_SCRIPT, keys: keys, argv: argv)
207
+ end
208
+
209
+ # Inspectors — used by RSpec matchers to assert on lock/semaphore/
210
+ # rate-limit/period state without leaking Redis-specific calls into
211
+ # test code.
212
+
213
+ # Returns { owner:, count: } for a held lock, or nil if free.
214
+ # `prefixed_key` is the full key (e.g. "lock:order:42").
215
+ def lock_info(prefixed_key)
216
+ return nil unless @redis.exists?(prefixed_key)
217
+
218
+ data = @redis.hgetall(prefixed_key)
219
+ return nil if data.empty?
220
+
221
+ { owner: data["owner"], count: data["count"].to_i }
222
+ end
223
+
224
+ # Returns { available:, held:, limit: } for a semaphore. `name` is the
225
+ # user-provided semaphore key (without the "semaphore:" prefix).
226
+ def semaphore_state(name)
227
+ prefix = "semaphore:#{name}"
228
+ {
229
+ available: @redis.llen(prefix),
230
+ held: @redis.scard("#{prefix}:held"),
231
+ limit: @redis.get("#{prefix}:init").to_i
232
+ }
233
+ end
234
+
235
+ # Current count for a rate-limit bucket. `key_base` is the user's
236
+ # `with_rate_limit` key, `every` is the period (symbol or seconds).
237
+ def rate_limit_count(key_base, every, now: Time.now.to_i)
238
+ period_seconds = RubyReactor::Period.period_seconds(every)
239
+ bucket = now / period_seconds
240
+ @redis.get("rate:#{key_base}:#{every}:#{bucket}").to_i
241
+ end
242
+
243
+ # Has a period bucket been marked? `key_base` is the user's
244
+ # `with_period` key, `every` is the period.
245
+ def period_marker?(key_base, every, now: Time.now.utc)
246
+ @redis.exists?(RubyReactor::Period.key(key_base, every, now: now))
247
+ end
248
+ end
249
+ # rubocop:enable Naming/PredicateMethod
250
+ end
251
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/ruby_reactor.rb CHANGED
@@ -4,6 +4,11 @@ require "zeitwerk"
4
4
  require "pathname"
5
5
  require_relative "ruby_reactor/registry"
6
6
  require_relative "ruby_reactor/utils/code_extractor"
7
+ require_relative "ruby_reactor/dsl/lockable" # Add this
8
+ require_relative "ruby_reactor/lock"
9
+ require_relative "ruby_reactor/semaphore"
10
+ require_relative "ruby_reactor/period"
11
+ require_relative "ruby_reactor/rate_limit"
7
12
 
8
13
  # Load dry-validation if available (for validation features)
9
14
  begin
@@ -40,11 +45,45 @@ module RubyReactor
40
45
  false
41
46
  end
42
47
 
48
+ def skipped?
49
+ false
50
+ end
51
+
43
52
  def to_h
44
53
  { success: true, value: @value }
45
54
  end
46
55
  end
47
56
 
57
+ # A "clean halt" signal. Two ways to produce one:
58
+ #
59
+ # 1. Implicitly, when a reactor's `with_period` gate finds the bucket has
60
+ # already been claimed. The executor short-circuits before any step
61
+ # runs.
62
+ #
63
+ # 2. Explicitly, by returning `RubyReactor.Skipped(reason: "...")` from a
64
+ # step's `run` block. The reactor halts immediately — no further steps,
65
+ # and crucially **no compensation** of already-completed steps. Use this
66
+ # when a step discovers that the rest of the workflow is not needed
67
+ # (e.g. "user already opted out", "nothing to do this round") and the
68
+ # partial progress is still correct to keep.
69
+ #
70
+ # Subclass of Success so callers that only check `success?` continue to work;
71
+ # `skipped?` distinguishes it.
72
+ class Skipped < Success
73
+ attr_reader :reason, :period_key, :step_name
74
+
75
+ def initialize(reason: nil, period_key: nil, step_name: nil)
76
+ super(nil)
77
+ @reason = reason
78
+ @period_key = period_key
79
+ @step_name = step_name
80
+ end
81
+
82
+ def skipped?
83
+ true
84
+ end
85
+ end
86
+
48
87
  class Failure
49
88
  attr_reader :error, :retryable, :step_name, :inputs, :backtrace, :reactor_name, :step_arguments, :exception_class,
50
89
  :file_path, :line_number, :code_snippet, :validation_errors
@@ -105,6 +144,10 @@ module RubyReactor
105
144
  @retryable
106
145
  end
107
146
 
147
+ def skipped?
148
+ false
149
+ end
150
+
108
151
  def invalid_payload?
109
152
  @invalid_payload
110
153
  end
@@ -271,6 +314,12 @@ module RubyReactor
271
314
  Failure.new(error, **kwargs)
272
315
  end
273
316
 
317
+ # Build a `Skipped` result. Return one from a step's `run` block to halt the
318
+ # reactor cleanly without triggering compensation of previous steps.
319
+ def self.Skipped(reason: nil, **kwargs)
320
+ Skipped.new(reason: reason, **kwargs)
321
+ end
322
+
274
323
  def self.configure
275
324
  yield(Configuration.instance) if block_given?
276
325
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: dry-validation
@@ -89,56 +88,15 @@ executables: []
89
88
  extensions: []
90
89
  extra_rdoc_files: []
91
90
  files:
91
+ - ".release-please-config.json"
92
+ - ".release-please-manifest.json"
92
93
  - ".rspec"
93
94
  - ".rubocop.yml"
95
+ - ".tool-versions"
96
+ - CHANGELOG.md
94
97
  - CODE_OF_CONDUCT.md
95
98
  - README.md
96
99
  - Rakefile
97
- - documentation/DAG.md
98
- - documentation/README.md
99
- - documentation/async_reactors.md
100
- - documentation/composition.md
101
- - documentation/core_concepts.md
102
- - documentation/data_pipelines.md
103
- - documentation/examples/inventory_management.md
104
- - documentation/examples/order_processing.md
105
- - documentation/examples/payment_processing.md
106
- - documentation/getting_started.md
107
- - documentation/images/failed_order_processing.png
108
- - documentation/images/payment_workflow.png
109
- - documentation/interrupts.md
110
- - documentation/retry_configuration.md
111
- - documentation/testing.md
112
- - gui/.gitignore
113
- - gui/README.md
114
- - gui/eslint.config.js
115
- - gui/index.html
116
- - gui/package-lock.json
117
- - gui/package.json
118
- - gui/postcss.config.js
119
- - gui/public/vite.svg
120
- - gui/src/App.css
121
- - gui/src/App.tsx
122
- - gui/src/assets/react.svg
123
- - gui/src/components/DagVisualizer.tsx
124
- - gui/src/components/Dashboard.tsx
125
- - gui/src/components/ErrorBoundary.tsx
126
- - gui/src/components/ReactorDetail.tsx
127
- - gui/src/components/StepInspector.tsx
128
- - gui/src/components/__tests__/DagVisualizer.test.tsx
129
- - gui/src/components/__tests__/ReactorDetail.test.tsx
130
- - gui/src/components/__tests__/StepInspector.test.tsx
131
- - gui/src/globals.d.ts
132
- - gui/src/index.css
133
- - gui/src/lib/utils.ts
134
- - gui/src/main.tsx
135
- - gui/src/test/setup.ts
136
- - gui/tailwind.config.js
137
- - gui/tsconfig.app.json
138
- - gui/tsconfig.json
139
- - gui/tsconfig.node.json
140
- - gui/vite.config.ts
141
- - gui/vitest.config.ts
142
100
  - lib/ruby_reactor.rb
143
101
  - lib/ruby_reactor/configuration.rb
144
102
  - lib/ruby_reactor/context.rb
@@ -147,6 +105,7 @@ files:
147
105
  - lib/ruby_reactor/dsl/compose_builder.rb
148
106
  - lib/ruby_reactor/dsl/interrupt_builder.rb
149
107
  - lib/ruby_reactor/dsl/interrupt_step_config.rb
108
+ - lib/ruby_reactor/dsl/lockable.rb
150
109
  - lib/ruby_reactor/dsl/map_builder.rb
151
110
  - lib/ruby_reactor/dsl/reactor.rb
152
111
  - lib/ruby_reactor/dsl/step_builder.rb
@@ -170,6 +129,7 @@ files:
170
129
  - lib/ruby_reactor/executor/retry_manager.rb
171
130
  - lib/ruby_reactor/executor/step_executor.rb
172
131
  - lib/ruby_reactor/interrupt_result.rb
132
+ - lib/ruby_reactor/lock.rb
173
133
  - lib/ruby_reactor/map/collector.rb
174
134
  - lib/ruby_reactor/map/dispatcher.rb
175
135
  - lib/ruby_reactor/map/element_executor.rb
@@ -177,6 +137,8 @@ files:
177
137
  - lib/ruby_reactor/map/helpers.rb
178
138
  - lib/ruby_reactor/map/result_enumerator.rb
179
139
  - lib/ruby_reactor/max_retries_exhausted_failure.rb
140
+ - lib/ruby_reactor/period.rb
141
+ - lib/ruby_reactor/rate_limit.rb
180
142
  - lib/ruby_reactor/reactor.rb
181
143
  - lib/ruby_reactor/registry.rb
182
144
  - lib/ruby_reactor/retry_context.rb
@@ -186,6 +148,7 @@ files:
186
148
  - lib/ruby_reactor/rspec/matchers.rb
187
149
  - lib/ruby_reactor/rspec/step_executor_patch.rb
188
150
  - lib/ruby_reactor/rspec/test_subject.rb
151
+ - lib/ruby_reactor/semaphore.rb
189
152
  - lib/ruby_reactor/sidekiq_adapter.rb
190
153
  - lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
191
154
  - lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
@@ -197,6 +160,7 @@ files:
197
160
  - lib/ruby_reactor/storage/adapter.rb
198
161
  - lib/ruby_reactor/storage/configuration.rb
199
162
  - lib/ruby_reactor/storage/redis_adapter.rb
163
+ - lib/ruby_reactor/storage/redis_locking.rb
200
164
  - lib/ruby_reactor/template/base.rb
201
165
  - lib/ruby_reactor/template/dynamic_source.rb
202
166
  - lib/ruby_reactor/template/element.rb
@@ -226,7 +190,6 @@ metadata:
226
190
  homepage_uri: https://github.com/arturictus/ruby_reactor
227
191
  source_code_uri: https://github.com/arturictus/ruby_reactor
228
192
  changelog_uri: https://github.com/arturictus/ruby_reactor/blob/main/CHANGELOG.md
229
- post_install_message:
230
193
  rdoc_options: []
231
194
  require_paths:
232
195
  - lib
@@ -241,8 +204,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
241
204
  - !ruby/object:Gem::Version
242
205
  version: '0'
243
206
  requirements: []
244
- rubygems_version: 3.4.19
245
- signing_key:
207
+ rubygems_version: 4.0.10
246
208
  specification_version: 4
247
209
  summary: A dynamic, concurrent, dependency-resolving saga orchestrator for Ruby.
248
210
  test_files: []