ruby_reactor 0.3.1 → 0.3.2

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.
@@ -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.3.2"
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,14 @@
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.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -107,6 +107,7 @@ files:
107
107
  - documentation/images/failed_order_processing.png
108
108
  - documentation/images/payment_workflow.png
109
109
  - documentation/interrupts.md
110
+ - documentation/locks_and_semaphores.md
110
111
  - documentation/retry_configuration.md
111
112
  - documentation/testing.md
112
113
  - gui/.gitignore
@@ -147,6 +148,7 @@ files:
147
148
  - lib/ruby_reactor/dsl/compose_builder.rb
148
149
  - lib/ruby_reactor/dsl/interrupt_builder.rb
149
150
  - lib/ruby_reactor/dsl/interrupt_step_config.rb
151
+ - lib/ruby_reactor/dsl/lockable.rb
150
152
  - lib/ruby_reactor/dsl/map_builder.rb
151
153
  - lib/ruby_reactor/dsl/reactor.rb
152
154
  - lib/ruby_reactor/dsl/step_builder.rb
@@ -170,6 +172,7 @@ files:
170
172
  - lib/ruby_reactor/executor/retry_manager.rb
171
173
  - lib/ruby_reactor/executor/step_executor.rb
172
174
  - lib/ruby_reactor/interrupt_result.rb
175
+ - lib/ruby_reactor/lock.rb
173
176
  - lib/ruby_reactor/map/collector.rb
174
177
  - lib/ruby_reactor/map/dispatcher.rb
175
178
  - lib/ruby_reactor/map/element_executor.rb
@@ -177,6 +180,8 @@ files:
177
180
  - lib/ruby_reactor/map/helpers.rb
178
181
  - lib/ruby_reactor/map/result_enumerator.rb
179
182
  - lib/ruby_reactor/max_retries_exhausted_failure.rb
183
+ - lib/ruby_reactor/period.rb
184
+ - lib/ruby_reactor/rate_limit.rb
180
185
  - lib/ruby_reactor/reactor.rb
181
186
  - lib/ruby_reactor/registry.rb
182
187
  - lib/ruby_reactor/retry_context.rb
@@ -186,6 +191,7 @@ files:
186
191
  - lib/ruby_reactor/rspec/matchers.rb
187
192
  - lib/ruby_reactor/rspec/step_executor_patch.rb
188
193
  - lib/ruby_reactor/rspec/test_subject.rb
194
+ - lib/ruby_reactor/semaphore.rb
189
195
  - lib/ruby_reactor/sidekiq_adapter.rb
190
196
  - lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
191
197
  - lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
@@ -197,6 +203,7 @@ files:
197
203
  - lib/ruby_reactor/storage/adapter.rb
198
204
  - lib/ruby_reactor/storage/configuration.rb
199
205
  - lib/ruby_reactor/storage/redis_adapter.rb
206
+ - lib/ruby_reactor/storage/redis_locking.rb
200
207
  - lib/ruby_reactor/template/base.rb
201
208
  - lib/ruby_reactor/template/dynamic_source.rb
202
209
  - lib/ruby_reactor/template/element.rb