ruby_reactor 0.5.1 → 0.5.3

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +179 -26
  5. data/lib/ruby_reactor/configuration.rb +66 -2
  6. data/lib/ruby_reactor/context_serializer.rb +9 -4
  7. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  8. data/lib/ruby_reactor/dsl/lockable.rb +41 -1
  9. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  10. data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
  11. data/lib/ruby_reactor/executor/step_executor.rb +25 -5
  12. data/lib/ruby_reactor/executor.rb +166 -52
  13. data/lib/ruby_reactor/lock.rb +13 -0
  14. data/lib/ruby_reactor/map/collector.rb +41 -0
  15. data/lib/ruby_reactor/map/dispatcher.rb +42 -0
  16. data/lib/ruby_reactor/map/element_executor.rb +39 -0
  17. data/lib/ruby_reactor/map/helpers.rb +10 -3
  18. data/lib/ruby_reactor/map/sweeper.rb +110 -0
  19. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  20. data/lib/ruby_reactor/reactor.rb +48 -5
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_adapter.rb +9 -8
  28. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
  30. data/lib/ruby_reactor/step/map_step.rb +18 -2
  31. data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
  32. data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
  33. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  34. data/lib/ruby_reactor/sweeper.rb +58 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor.rb +43 -0
  37. metadata +9 -1
@@ -17,15 +17,26 @@ module RubyReactor
17
17
  # Handle infrastructure failures (network, Redis, etc.)
18
18
  end
19
19
 
20
- def perform(serialized_context, reactor_class_name = nil, snooze_count = 0)
20
+ # Identity-only payload: storage is the source of truth. Rehydrate the live
21
+ # context from storage by id, then resume. A nil read means the context was
22
+ # swept, expired, or already terminal-and-collected — nothing to resume.
23
+ def perform(context_id, reactor_class_name = nil, snooze_count = 0)
24
+ # Normalize so a nil/omitted name resolves to the same storage key the
25
+ # enqueue path wrote (always via reactor_storage_name). Without this a
26
+ # nil here builds "reactor::context:<id>" and misses the stored
27
+ # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
28
+ reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
29
+ data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
30
+ return if data.nil?
31
+
21
32
  begin
22
- context = ContextSerializer.deserialize(serialized_context)
33
+ context = ContextSerializer.deserialize_hash(data)
23
34
  rescue RubyReactor::Error::DeserializationError,
24
35
  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)
36
+ # Permanent failures — re-reading the same stored blob will keep
37
+ # failing. Mark the context as failed (best-effort) and return so
38
+ # Sidekiq does not burn its retry budget.
39
+ handle_deserialization_failure(context_id, reactor_class_name, e)
29
40
  return
30
41
  end
31
42
 
@@ -48,18 +59,26 @@ module RubyReactor
48
59
  # Resume execution from the failed step
49
60
  executor = Executor.new(context.reactor_class, {}, context)
50
61
  executor.resume_execution
51
- executor.save_context
62
+ # No explicit save here: resume_execution's ensure block already persists
63
+ # the final root state (`save_context unless skip_context_persist?`), and
64
+ # in the worker the executor's context IS the root, so an extra checkpoint!
65
+ # would just re-write the identical blob to the identical key. The
66
+ # skip_context_persist? guard (stale-batch redelivery of an already-terminal
67
+ # context) is likewise honored there.
52
68
 
53
69
  # Return the executor (which now has the result stored in it)
54
70
  executor
55
71
  rescue RubyReactor::Lock::AcquisitionError,
56
72
  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)
73
+ RubyReactor::RateLimit::ExceededError,
74
+ RubyReactor::OrderedLock::WaitError => e
75
+ # Snooze on expected concurrency, rate, or ordering contention.
76
+ # OrderedLock::WaitError carries a poison-pill-derived retry hint,
77
+ # consumed by compute_snooze_delay below. We avoid Sidekiq's native
78
+ # retry path so this doesn't burn the job's retry budget or appear
79
+ # as an error in dashboards. After the configured cap is reached we
80
+ # escalate by marking the reactor as failed.
81
+ handle_snooze(context_id, reactor_class_name, context, snooze_count, e)
63
82
  rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
64
83
  # Permanent configuration error — snoozing or retrying the same job
65
84
  # will keep failing. Mark the context failed immediately.
@@ -69,34 +88,64 @@ module RubyReactor
69
88
 
70
89
  private
71
90
 
72
- def handle_snooze(serialized_context, reactor_class_name, context, snooze_count, error)
91
+ def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
73
92
  config = RubyReactor.configuration
74
93
  max = config.lock_snooze_max_attempts
75
94
 
76
- if max != :infinity && snooze_count >= max
95
+ # OrderedLock::WaitError bypasses the snooze cap. The gate's
96
+ # poison_pill_timeout is the only meaningful upper bound on how long a
97
+ # nonce can legitimately wait; capping snoozes would either fail jobs
98
+ # prematurely or strand the nonce in `assigned_at` until poison_pill
99
+ # eventually advances past it. Snooze until the gate passes (or poison
100
+ # auto-advance moves the cursor past us).
101
+ # The per-context liveness lock (`async:<id>`) is also uncapped: a
102
+ # duplicate of the *same* execution may wait arbitrarily long for the
103
+ # live original to finish (e.g. a sweeper re-enqueue racing a slow but
104
+ # alive worker). Capping it would fail a legitimately-waiting duplicate.
105
+ capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
106
+ error.is_a?(RubyReactor::Lock::ContextLockContention))
107
+
108
+ if capped && max != :infinity && snooze_count >= max
77
109
  escalate_snooze(context, snooze_count, error)
78
110
  return
79
111
  end
80
112
 
81
113
  delay = compute_snooze_delay(config, error)
82
- self.class.perform_in(delay, serialized_context, reactor_class_name, snooze_count + 1)
114
+ # Re-enqueue by id: the context is already persisted in storage, so the
115
+ # rescheduled job rehydrates fresh state (no stale blob).
116
+ self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
83
117
  end
84
118
 
85
119
  # Use the error's `retry_after_seconds` hint when available
86
120
  # (RateLimit::ExceededError carries the time until the bucket rolls);
87
121
  # otherwise fall back to the configured base + jitter for lock/semaphore
88
122
  # contention which has no precise hint.
123
+ #
124
+ # OrderedLock::WaitError is deliberately excluded from the hint path: its
125
+ # `retry_after_seconds` is the poison-pill window (the upper bound before
126
+ # a *dead* blocker is force-advanced), NOT how long the *live* blocker
127
+ # will take — which is usually milliseconds. Snoozing for the full window
128
+ # would make every out-of-order nonce sleep up to poison_pill_timeout even
129
+ # though its blocker finishes immediately, collapsing throughput. Re-poll
130
+ # at the base delay instead; poison auto-advance still clears a genuinely
131
+ # dead blocker on a later gate.
89
132
  def compute_snooze_delay(config, error)
90
133
  jitter = config.lock_snooze_jitter.to_f
91
134
  jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
92
135
 
93
- if error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
136
+ if hinted_retry?(error)
94
137
  [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
95
138
  else
96
139
  config.lock_snooze_base_delay.to_f + jitter_amount
97
140
  end
98
141
  end
99
142
 
143
+ def hinted_retry?(error)
144
+ return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
145
+
146
+ error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
147
+ end
148
+
100
149
  def escalate_snooze(context, snooze_count, error)
101
150
  RubyReactor.configuration.logger.warn(
102
151
  "RubyReactor snooze limit reached after #{snooze_count} attempts " \
@@ -111,12 +160,20 @@ module RubyReactor
111
160
  }
112
161
 
113
162
  serialized = ContextSerializer.serialize(context)
114
- reactor_class_name = context.reactor_class&.name || "AnonymousReactor"
163
+ reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
115
164
  RubyReactor.configuration.storage_adapter.store_context(
116
165
  context.context_id,
117
166
  serialized,
118
167
  reactor_class_name
119
168
  )
169
+
170
+ # Escalation is a terminal Failure that never reaches the Executor's
171
+ # ensure path, so advance the ordered-lock cursor here. Without this
172
+ # the nonce stays stranded in assigned_at (successors stall for the
173
+ # full poison_pill_timeout) and, worse, the strict-mode chain marker
174
+ # is never recorded — successors would RUN instead of being skipped.
175
+ info = Executor::OrderedLockSupport.info_from(context)
176
+ Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
120
177
  end
121
178
 
122
179
  def log_infrastructure_failure(msg, exception)
@@ -124,23 +181,22 @@ module RubyReactor
124
181
  RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
125
182
  end
126
183
 
127
- def handle_deserialization_failure(serialized_context, reactor_class_name, error)
128
- metadata = extract_failure_metadata(serialized_context)
129
- context_id = metadata[:context_id]
130
- resolved_reactor_class_name = reactor_class_name || metadata[:reactor_class_name]
131
-
184
+ # The id-only payload already carries context_id and reactor_class_name, so
185
+ # there is no blob to parse for metadata just mark the stored context
186
+ # failed (best-effort) so the job stops retrying a permanently-broken blob.
187
+ def handle_deserialization_failure(context_id, reactor_class_name, error)
132
188
  RubyReactor.configuration.logger.error(
133
189
  "RubyReactor deserialization failure for context " \
134
190
  "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
135
191
  )
136
192
 
137
- return unless context_id && resolved_reactor_class_name
193
+ return unless context_id && reactor_class_name
138
194
 
139
- payload = build_failed_context_payload(context_id, resolved_reactor_class_name, error)
195
+ payload = build_failed_context_payload(context_id, reactor_class_name, error)
140
196
  RubyReactor.configuration.storage_adapter.store_context(
141
197
  context_id,
142
198
  payload,
143
- resolved_reactor_class_name
199
+ reactor_class_name
144
200
  )
145
201
  rescue StandardError => e
146
202
  # Don't let a persistence failure mask the original deserialization error.
@@ -149,16 +205,6 @@ module RubyReactor
149
205
  )
150
206
  end
151
207
 
152
- def extract_failure_metadata(serialized_context)
153
- data = JSON.parse(serialized_context)
154
- {
155
- context_id: data["context_id"],
156
- reactor_class_name: data["reactor_class"]
157
- }
158
- rescue StandardError
159
- {}
160
- end
161
-
162
208
  def build_failed_context_payload(context_id, reactor_class_name, error)
163
209
  JSON.generate(
164
210
  "schema_version" => ContextSerializer::SCHEMA_VERSION,
@@ -179,10 +179,25 @@ module RubyReactor
179
179
  storage = RubyReactor.configuration.storage_adapter
180
180
  storage.initialize_map_operation(
181
181
  map_id, arguments[:source].size, context.reactor_class.name,
182
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info
182
+ strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
183
+ **map_recovery_metadata(context, arguments[:step_name] || context.current_step)
183
184
  )
184
185
  end
185
186
 
187
+ # Recovery metadata for the map sweeper. When this map runs inside a map
188
+ # element (context.map_metadata present), it is a NESTED map: its parent
189
+ # holds the element's `map_element:` lock, not an `async:` lock (N1).
190
+ def map_recovery_metadata(context, step_name)
191
+ outer = context.map_metadata
192
+ {
193
+ parent_context_id: context.context_id,
194
+ step_name: step_name.to_s,
195
+ parent_is_map_element: !outer.nil?,
196
+ outer_map_id: outer && (outer[:map_id] || outer["map_id"]),
197
+ outer_index: outer && (outer[:index] || outer["index"])
198
+ }
199
+ end
200
+
186
201
  def dispatch_async_map(map_id, arguments, context, _reactor_class_info, step_name)
187
202
  # Every async map runs through the per-element Dispatcher path. When no
188
203
  # batch_size is given we default to the full source size (one fan-out
@@ -231,7 +246,8 @@ module RubyReactor
231
246
  storage = RubyReactor.configuration.storage_adapter
232
247
  storage.initialize_map_operation(
233
248
  map_id, arguments[:source].size, context.reactor_class.name,
234
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info
249
+ strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
250
+ **map_recovery_metadata(context, step_name)
235
251
  )
236
252
 
237
253
  limit ||= arguments[:source].size
@@ -7,6 +7,7 @@ module RubyReactor
7
7
  module Storage
8
8
  class RedisAdapter < Adapter
9
9
  include RedisLocking
10
+ include RedisOrderedLocking
10
11
 
11
12
  def initialize(redis_config)
12
13
  super()
@@ -15,8 +16,10 @@ module RubyReactor
15
16
 
16
17
  def store_context(context_id, serialized_context, reactor_class_name)
17
18
  key = context_key(context_id, reactor_class_name)
18
- # Use standard SET for compatibility (ReJSON not strictly required for full docs)
19
- @redis.set(key, serialized_context, ex: 86_400) # 24h TTL
19
+ # Use standard SET for compatibility (ReJSON not strictly required for full docs).
20
+ # TTL is re-stamped on every write so long-running / snoozed contexts
21
+ # never expire mid-flight (Phase 4).
22
+ @redis.set(key, serialized_context, ex: durability_ttl)
20
23
  end
21
24
 
22
25
  def retrieve_context(context_id, reactor_class_name)
@@ -27,52 +30,84 @@ module RubyReactor
27
30
  JSON.parse(json)
28
31
  end
29
32
 
33
+ # Durable map storage is ALWAYS index-keyed (HSET), regardless of
34
+ # strict_ordering. The index->slot mapping makes completion recoverable
35
+ # (missing = (0...count) - HKEYS) and re-dispatch idempotent (re-running
36
+ # index i overwrites slot i, never duplicates). strict_ordering is now only
37
+ # a read-order convenience, not a storage-layout switch (Phase 5).
38
+ # rubocop:disable Lint/UnusedMethodArgument
30
39
  def store_map_result(map_id, index, serialized_result, reactor_class_name, strict_ordering: true)
31
40
  key = map_results_key(map_id, reactor_class_name)
32
-
33
- if strict_ordering
34
- # Use Hash for strict ordering by index
35
- # HSET key index serialized_result
36
- @redis.hset(key, index.to_s, serialized_result.to_json)
37
- else
38
- # Loose ordering: just push to list
39
- @redis.rpush(key, serialized_result.to_json)
40
- end
41
-
42
- @redis.expire(key, 86_400)
41
+ @redis.hset(key, index.to_s, serialized_result.to_json)
42
+ @redis.expire(key, durability_ttl)
43
43
  end
44
44
 
45
45
  def retrieve_map_results(map_id, reactor_class_name, strict_ordering: true)
46
+ # rubocop:enable Lint/UnusedMethodArgument
46
47
  key = map_results_key(map_id, reactor_class_name)
48
+ results = @redis.hgetall(key)
49
+ # Index-keyed for both modes; sort by index so reads are deterministic.
50
+ results.keys.sort_by(&:to_i).map { |k| JSON.parse(results[k]) }
51
+ end
47
52
 
48
- if strict_ordering
49
- results = @redis.hgetall(key)
50
- # Sort by index (key)
51
- results.keys.sort_by(&:to_i).map { |k| JSON.parse(results[k]) }
52
- else
53
- results = @redis.lrange(key, 0, -1)
54
- results.map { |r| JSON.parse(r) }
55
- end
53
+ # Indices that have NO stored result yet: the authoritative, idempotent
54
+ # signal for what the map sweeper must (re)dispatch.
55
+ def missing_map_indices(map_id, count, reactor_class_name)
56
+ key = map_results_key(map_id, reactor_class_name)
57
+ present = @redis.hkeys(key).map(&:to_i)
58
+ (0...count).to_a - present
56
59
  end
57
60
 
58
61
  def set_map_counter(map_id, count, reactor_class_name)
59
62
  key = map_counter_key(map_id, reactor_class_name)
60
- @redis.set(key, count, ex: 86_400)
63
+ @redis.set(key, count, ex: durability_ttl)
61
64
  end
62
65
 
63
- def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true)
66
+ # rubocop:disable Metrics/ParameterLists
67
+ def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true,
68
+ parent_context_id: nil, step_name: nil, parent_is_map_element: false,
69
+ outer_map_id: nil, outer_index: nil)
64
70
  # Ensure counter is set
65
71
  set_map_counter(map_id, count, parent_reactor_class_name)
66
72
 
67
- # Store metadata
73
+ # Store metadata. parent_context_id/step_name let the map sweeper recover
74
+ # without re-deriving the map_id (which is brittle to split on ':'). The
75
+ # nested-map fields (parent_is_map_element + outer_map_id/outer_index)
76
+ # record which liveness lock the parent actually holds (N1): a nested
77
+ # map's parent is itself a map element running under a `map_element:` lock,
78
+ # not an `async:` lock.
68
79
  key = "reactor:#{parent_reactor_class_name}:map:#{map_id}:metadata"
69
80
  metadata = {
81
+ map_id: map_id,
70
82
  count: count,
71
83
  strict_ordering: strict_ordering,
72
84
  reactor_class_info: reactor_class_info,
85
+ parent_context_id: parent_context_id,
86
+ parent_reactor_class_name: parent_reactor_class_name,
87
+ step_name: step_name,
88
+ parent_is_map_element: parent_is_map_element,
89
+ outer_map_id: outer_map_id,
90
+ outer_index: outer_index,
73
91
  created_at: Time.now.to_i
74
92
  }
75
- @redis.set(key, metadata.to_json, ex: 86_400)
93
+ @redis.set(key, metadata.to_json, ex: durability_ttl)
94
+ end
95
+ # rubocop:enable Metrics/ParameterLists
96
+
97
+ # Enumerate active map operations for the map sweeper (Phase 5d). Returns
98
+ # the parsed metadata hash for each (includes map_id, count,
99
+ # parent_context_id, step_name, parent_reactor_class_name, and the nested-map
100
+ # lock fields). Bounded by `count` to keep a sweep cheap.
101
+ def scan_maps(count: 1000)
102
+ results = []
103
+ @redis.scan_each(match: "reactor:*:map:*:metadata", count: 100) do |key|
104
+ json = @redis.get(key)
105
+ next unless json
106
+
107
+ results << JSON.parse(json)
108
+ return results if results.size >= count
109
+ end
110
+ results
76
111
  end
77
112
 
78
113
  def retrieve_map_metadata(map_id, reactor_class_name)
@@ -86,7 +121,7 @@ module RubyReactor
86
121
  def increment_map_counter(map_id, reactor_class_name)
87
122
  key = map_counter_key(map_id, reactor_class_name)
88
123
  @redis.incr(key)
89
- @redis.expire(key, 86_400)
124
+ @redis.expire(key, durability_ttl)
90
125
  end
91
126
 
92
127
  def decrement_map_counter(map_id, reactor_class_name)
@@ -96,7 +131,7 @@ module RubyReactor
96
131
 
97
132
  def set_last_queued_index(map_id, index, reactor_class_name)
98
133
  key = map_last_queued_index_key(map_id, reactor_class_name)
99
- @redis.set(key, index, ex: 86_400)
134
+ @redis.set(key, index, ex: durability_ttl)
100
135
  end
101
136
 
102
137
  def increment_last_queued_index(map_id, reactor_class_name)
@@ -108,7 +143,7 @@ module RubyReactor
108
143
  key = correlation_id_key(correlation_id, reactor_class_name)
109
144
  # Store mapping correlation_id -> context_id
110
145
  # Try to set if not exists
111
- success = @redis.set(key, context_id, nx: true, ex: 86_400) # 24h TTL
146
+ success = @redis.set(key, context_id, nx: true, ex: durability_ttl)
112
147
 
113
148
  return if success
114
149
 
@@ -117,7 +152,7 @@ module RubyReactor
117
152
 
118
153
  if existing_context_id == context_id
119
154
  # Refresh TTL
120
- @redis.expire(key, 86_400)
155
+ @redis.expire(key, durability_ttl)
121
156
  return
122
157
  end
123
158
 
@@ -215,7 +250,7 @@ module RubyReactor
215
250
  def store_map_element_context_id(map_id, context_id, reactor_class_name)
216
251
  key = map_element_contexts_key(map_id, reactor_class_name)
217
252
  @redis.rpush(key, context_id)
218
- @redis.expire(key, 86_400)
253
+ @redis.expire(key, durability_ttl)
219
254
  end
220
255
 
221
256
  def retrieve_map_element_context_ids(map_id, reactor_class_name)
@@ -231,7 +266,7 @@ module RubyReactor
231
266
  def store_map_failed_context_id(map_id, context_id, reactor_class_name)
232
267
  key = map_failed_context_key(map_id, reactor_class_name)
233
268
  # Only store the first failure (nx: true)
234
- @redis.set(key, context_id, nx: true, ex: 86_400)
269
+ @redis.set(key, context_id, nx: true, ex: durability_ttl)
235
270
  end
236
271
 
237
272
  def retrieve_map_failed_context_id(map_id, reactor_class_name)
@@ -241,12 +276,12 @@ module RubyReactor
241
276
 
242
277
  def set_map_offset(map_id, offset, reactor_class_name)
243
278
  key = map_offset_key(map_id, reactor_class_name)
244
- @redis.set(key, offset, ex: 86_400)
279
+ @redis.set(key, offset, ex: durability_ttl)
245
280
  end
246
281
 
247
282
  def set_map_offset_if_not_exists(map_id, offset, reactor_class_name)
248
283
  key = map_offset_key(map_id, reactor_class_name)
249
- @redis.set(key, offset, nx: true, ex: 86_400)
284
+ @redis.set(key, offset, nx: true, ex: durability_ttl)
250
285
  end
251
286
 
252
287
  def retrieve_map_offset(map_id, reactor_class_name)
@@ -259,43 +294,32 @@ module RubyReactor
259
294
  @redis.incrby(key, increment)
260
295
  end
261
296
 
297
+ # rubocop:disable Lint/UnusedMethodArgument
262
298
  def retrieve_map_results_batch(map_id, reactor_class_name, offset:, limit:, strict_ordering: true)
299
+ # Always index-keyed now (Phase 5): HMGET the contiguous index window.
263
300
  key = map_results_key(map_id, reactor_class_name)
264
-
265
- if strict_ordering
266
- # For Hash based results (indexed), we can use HMGET if we know the keys.
267
- # Since we use 0-based index keys, we can generate the keys for the batch.
268
- fields = (offset...(offset + limit)).map(&:to_s)
269
- results = @redis.hmget(key, *fields)
270
-
271
- # HMGET returns nil for missing fields, compact them?
272
- # Or should we respect the holes?
273
- # Map results are usually dense.
274
- results.compact.map { |r| JSON.parse(r) }
275
- else
276
- # For List based results
277
- # LRANGE uses inclusive ending index
278
- end_index = offset + limit - 1
279
- results = @redis.lrange(key, offset, end_index)
280
- results.map { |r| JSON.parse(r) }
281
- end
301
+ fields = (offset...(offset + limit)).map(&:to_s)
302
+ results = @redis.hmget(key, *fields)
303
+ results.compact.map { |r| JSON.parse(r) }
282
304
  end
305
+ # rubocop:enable Lint/UnusedMethodArgument
283
306
 
284
307
  def count_map_results(map_id, reactor_class_name)
285
308
  key = map_results_key(map_id, reactor_class_name)
286
- type = @redis.type(key)
287
-
288
- if type == "hash"
289
- @redis.hlen(key)
290
- elsif type == "list"
291
- @redis.llen(key)
292
- else
293
- 0
294
- end
309
+ @redis.hlen(key)
295
310
  end
296
311
 
297
312
  private
298
313
 
314
+ # Single source of truth for the retention window of all durability-bearing
315
+ # state (context blob, map results/counters/metadata/offsets, correlation
316
+ # ids). Map state is load-bearing for resume exactly like the context, so it
317
+ # must share the context's configurable TTL — a shorter map TTL would expire
318
+ # map results mid-flight and break recovery. Re-stamped on every write.
319
+ def durability_ttl
320
+ RubyReactor.configuration.context_ttl
321
+ end
322
+
299
323
  def fetch_and_filter_reactors(keys)
300
324
  return [] if keys.empty?
301
325
 
@@ -210,6 +210,14 @@ module RubyReactor
210
210
  # rate-limit/period state without leaking Redis-specific calls into
211
211
  # test code.
212
212
 
213
+ # Liveness check for a logical lock by its BARE key (e.g. "async:<id>").
214
+ # Prepends the "lock:" prefix that Lock applies. True while a worker holds
215
+ # (and auto-extends) the lock; false once it expires — the sweeper's
216
+ # "worker died" signal.
217
+ def lock_held?(key)
218
+ @redis.exists?("lock:#{key}")
219
+ end
220
+
213
221
  # Returns { owner:, count: } for a held lock, or nil if free.
214
222
  # `prefixed_key` is the full key (e.g. "lock:order:42").
215
223
  def lock_info(prefixed_key)