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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +179 -26
- data/lib/ruby_reactor/configuration.rb +66 -2
- data/lib/ruby_reactor/context_serializer.rb +9 -4
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/lockable.rb +41 -1
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
- data/lib/ruby_reactor/executor/step_executor.rb +25 -5
- data/lib/ruby_reactor/executor.rb +166 -52
- data/lib/ruby_reactor/lock.rb +13 -0
- data/lib/ruby_reactor/map/collector.rb +41 -0
- data/lib/ruby_reactor/map/dispatcher.rb +42 -0
- data/lib/ruby_reactor/map/element_executor.rb +39 -0
- data/lib/ruby_reactor/map/helpers.rb +10 -3
- data/lib/ruby_reactor/map/sweeper.rb +110 -0
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/reactor.rb +48 -5
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_adapter.rb +9 -8
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
- data/lib/ruby_reactor/step/map_step.rb +18 -2
- data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
- data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/sweeper.rb +58 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +43 -0
- metadata +9 -1
|
@@ -17,15 +17,26 @@ module RubyReactor
|
|
|
17
17
|
# Handle infrastructure failures (network, Redis, etc.)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
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.
|
|
33
|
+
context = ContextSerializer.deserialize_hash(data)
|
|
23
34
|
rescue RubyReactor::Error::DeserializationError,
|
|
24
35
|
RubyReactor::Error::SchemaVersionError => e
|
|
25
|
-
# Permanent failures —
|
|
26
|
-
# Mark the context as failed (best-effort) and return so
|
|
27
|
-
# does not burn its retry budget.
|
|
28
|
-
handle_deserialization_failure(
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 &&
|
|
193
|
+
return unless context_id && reactor_class_name
|
|
138
194
|
|
|
139
|
-
payload = build_failed_context_payload(context_id,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
63
|
+
@redis.set(key, count, ex: durability_ttl)
|
|
61
64
|
end
|
|
62
65
|
|
|
63
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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,
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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)
|