ruby_reactor 0.5.4 → 0.6.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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +7 -0
- data/README.md +51 -24
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
- data/lib/ruby_reactor/configuration.rb +24 -4
- data/lib/ruby_reactor/map/element_executor.rb +1 -1
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
- data/lib/ruby_reactor/rspec.rb +4 -0
- data/lib/ruby_reactor/sweeper_job.rb +70 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/worker.rb +226 -0
- data/lib/ruby_reactor.rb +40 -1
- data/specs/active_job.md +259 -0
- metadata +54 -6
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubyReactor
|
|
4
|
-
class SidekiqAdapter
|
|
5
|
-
# Identity-only payload: the worker rehydrates the live context from storage
|
|
6
|
-
# by (context_id, reactor_class_name). The caller already holds context_id, so
|
|
7
|
-
# there is no blob to deserialize here.
|
|
8
|
-
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
9
|
-
job_id = SidekiqWorkers::Worker.perform_async(context_id, reactor_class_name)
|
|
10
|
-
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
11
|
-
execution_id: context_id)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
15
|
-
job_id = SidekiqWorkers::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
16
|
-
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
17
|
-
execution_id: context_id)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# rubocop:disable Metrics/ParameterLists
|
|
21
|
-
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
22
|
-
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
23
|
-
batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
24
|
-
job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
|
|
25
|
-
{
|
|
26
|
-
"map_id" => map_id,
|
|
27
|
-
"element_id" => element_id,
|
|
28
|
-
"index" => index,
|
|
29
|
-
"serialized_inputs" => serialized_inputs,
|
|
30
|
-
"reactor_class_info" => reactor_class_info,
|
|
31
|
-
"strict_ordering" => strict_ordering,
|
|
32
|
-
"parent_context_id" => parent_context_id,
|
|
33
|
-
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
34
|
-
"step_name" => step_name,
|
|
35
|
-
"batch_size" => batch_size,
|
|
36
|
-
"serialized_context" => serialized_context,
|
|
37
|
-
"fail_fast" => fail_fast
|
|
38
|
-
}
|
|
39
|
-
)
|
|
40
|
-
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
44
|
-
strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
|
|
45
|
-
batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
46
|
-
job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_in(
|
|
47
|
-
delay,
|
|
48
|
-
{
|
|
49
|
-
"map_id" => map_id,
|
|
50
|
-
"element_id" => element_id,
|
|
51
|
-
"index" => index,
|
|
52
|
-
"serialized_inputs" => serialized_inputs,
|
|
53
|
-
"reactor_class_info" => reactor_class_info,
|
|
54
|
-
"strict_ordering" => strict_ordering,
|
|
55
|
-
"parent_context_id" => parent_context_id,
|
|
56
|
-
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
57
|
-
"step_name" => step_name,
|
|
58
|
-
"batch_size" => batch_size,
|
|
59
|
-
"serialized_context" => serialized_context,
|
|
60
|
-
"fail_fast" => fail_fast
|
|
61
|
-
}
|
|
62
|
-
)
|
|
63
|
-
# Return an AsyncResult so RetryManager#handle_async_retry recognises the
|
|
64
|
-
# element was successfully requeued and yields a RetryQueuedResult.
|
|
65
|
-
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
66
|
-
end
|
|
67
|
-
# rubocop:enable Metrics/ParameterLists
|
|
68
|
-
|
|
69
|
-
# rubocop:disable Metrics/ParameterLists
|
|
70
|
-
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
71
|
-
strict_ordering:, timeout:)
|
|
72
|
-
job_id = RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
|
|
73
|
-
{
|
|
74
|
-
"parent_context_id" => parent_context_id,
|
|
75
|
-
"map_id" => map_id,
|
|
76
|
-
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
77
|
-
"step_name" => step_name,
|
|
78
|
-
"strict_ordering" => strict_ordering,
|
|
79
|
-
"timeout" => timeout
|
|
80
|
-
}
|
|
81
|
-
)
|
|
82
|
-
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# rubocop:enable Metrics/ParameterLists
|
|
86
|
-
end
|
|
87
|
-
end
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "sidekiq"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
|
|
6
|
-
module RubyReactor
|
|
7
|
-
module SidekiqWorkers
|
|
8
|
-
# Self-rescheduling recovery tick. Each run sweeps both the top-level reactor
|
|
9
|
-
# sweeper and the map sweeper, then schedules the next tick — a perpetual
|
|
10
|
-
# chain the host kicks once via `RubyReactor.start_sweeper!`.
|
|
11
|
-
#
|
|
12
|
-
# super_fetch safety. Sidekiq Enterprise `super_fetch` reliably re-runs a job
|
|
13
|
-
# whose worker died mid-execution. For a self-rescheduling chain that is a
|
|
14
|
-
# hazard: a tick can crash AFTER enqueuing its successor but BEFORE acking, so
|
|
15
|
-
# super_fetch recovers the crashed tick *alongside* the successor it already
|
|
16
|
-
# scheduled — the chain forks and then doubles every interval. We therefore do
|
|
17
|
-
# NOT rely on "exactly one job exists". The next tick is claimed by a
|
|
18
|
-
# per-time-window lock: every duplicate computes the SAME target window and
|
|
19
|
-
# only one wins the claim, so recovered/duplicated ticks collapse back to a
|
|
20
|
-
# single chain. The claim lock is never released — it simply expires — so no
|
|
21
|
-
# delete can race two duplicates into both winning.
|
|
22
|
-
class SweeperWorker
|
|
23
|
-
include ::Sidekiq::Worker
|
|
24
|
-
|
|
25
|
-
# retry: false — the sweep is idempotent and self-rescheduling, so a failed
|
|
26
|
-
# tick must not pile up Sidekiq retries; the next tick (or a super_fetch
|
|
27
|
-
# recovery) re-runs it anyway.
|
|
28
|
-
sidekiq_options retry: false, queue: RubyReactor.configuration.sidekiq_queue
|
|
29
|
-
|
|
30
|
-
def perform
|
|
31
|
-
config = RubyReactor.configuration
|
|
32
|
-
return unless config.sweeper_enabled
|
|
33
|
-
|
|
34
|
-
run_sweeps(config)
|
|
35
|
-
ensure
|
|
36
|
-
# Always chain forward (unless disabled), even after an error above, so a
|
|
37
|
-
# single bad sweep can't kill recovery. The window lock keeps this from
|
|
38
|
-
# forking under super_fetch.
|
|
39
|
-
self.class.schedule_next if RubyReactor.configuration.sweeper_enabled
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def run_sweeps(config)
|
|
43
|
-
RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
|
|
44
|
-
RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
|
|
45
|
-
rescue StandardError => e
|
|
46
|
-
config.logger.error("RubyReactor::SweeperWorker sweep failed: #{e.class}: #{e.message}")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Enqueue the next tick for the upcoming time window, claiming that window
|
|
50
|
-
# so concurrent/duplicate/recovered ticks produce exactly one successor.
|
|
51
|
-
# Idempotent: also safe to call from `start_sweeper!` on every process boot.
|
|
52
|
-
def self.schedule_next
|
|
53
|
-
interval = RubyReactor.configuration.sweeper_interval
|
|
54
|
-
window = (Time.now.to_i / interval) + 1
|
|
55
|
-
|
|
56
|
-
lock = RubyReactor::Lock.new(
|
|
57
|
-
"sweeper:window:#{window}",
|
|
58
|
-
owner: SecureRandom.uuid,
|
|
59
|
-
ttl: interval * 2, # outlive the window; expires on its own (never released)
|
|
60
|
-
wait: 0,
|
|
61
|
-
auto_extend: false
|
|
62
|
-
)
|
|
63
|
-
lock.acquire # raises AcquisitionError if this window is already claimed
|
|
64
|
-
|
|
65
|
-
delay = (window * interval) - Time.now.to_i
|
|
66
|
-
perform_in([delay, 1].max)
|
|
67
|
-
rescue RubyReactor::Lock::AcquisitionError
|
|
68
|
-
# Another tick already scheduled this window — collapse the duplicate.
|
|
69
|
-
nil
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "sidekiq"
|
|
4
|
-
|
|
5
|
-
module RubyReactor
|
|
6
|
-
module SidekiqWorkers
|
|
7
|
-
# Sidekiq worker for executing RubyReactor reactors asynchronously
|
|
8
|
-
# with non-blocking retry capabilities
|
|
9
|
-
class Worker
|
|
10
|
-
include ::Sidekiq::Worker
|
|
11
|
-
|
|
12
|
-
# Enable Sidekiq retries for infrastructure failures only
|
|
13
|
-
sidekiq_options retry: RubyReactor.configuration.sidekiq_retry_count, dead: false,
|
|
14
|
-
queue: RubyReactor.configuration.sidekiq_queue
|
|
15
|
-
|
|
16
|
-
sidekiq_retries_exhausted do |_, exception|
|
|
17
|
-
# Handle infrastructure failures (network, Redis, etc.)
|
|
18
|
-
end
|
|
19
|
-
|
|
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
|
-
|
|
32
|
-
begin
|
|
33
|
-
context = ContextSerializer.deserialize_hash(data)
|
|
34
|
-
rescue RubyReactor::Error::DeserializationError,
|
|
35
|
-
RubyReactor::Error::SchemaVersionError => 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)
|
|
40
|
-
return
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# If reactor_class_name is provided, use it to get the reactor class
|
|
44
|
-
# This handles cases where the class can't be found via const_get
|
|
45
|
-
if reactor_class_name && context.reactor_class.nil?
|
|
46
|
-
begin
|
|
47
|
-
context.reactor_class = Object.const_get(reactor_class_name)
|
|
48
|
-
rescue NameError
|
|
49
|
-
# If not found, try to find it in the current namespace
|
|
50
|
-
# This is a fallback for test environments
|
|
51
|
-
context.reactor_class = reactor_class_name.constantize if reactor_class_name.respond_to?(:constantize)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Mark that we're executing inline to prevent nested async calls
|
|
56
|
-
context.inline_async_execution = true
|
|
57
|
-
|
|
58
|
-
begin
|
|
59
|
-
# Resume execution from the failed step
|
|
60
|
-
executor = Executor.new(context.reactor_class, {}, context)
|
|
61
|
-
executor.resume_execution
|
|
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.
|
|
68
|
-
|
|
69
|
-
# Return the executor (which now has the result stored in it)
|
|
70
|
-
executor
|
|
71
|
-
rescue RubyReactor::Lock::AcquisitionError,
|
|
72
|
-
RubyReactor::Semaphore::AcquisitionError,
|
|
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)
|
|
82
|
-
rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
|
|
83
|
-
# Permanent configuration error — snoozing or retrying the same job
|
|
84
|
-
# will keep failing. Mark the context failed immediately.
|
|
85
|
-
escalate_snooze(context, snooze_count, e)
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
private
|
|
90
|
-
|
|
91
|
-
def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
|
|
92
|
-
config = RubyReactor.configuration
|
|
93
|
-
max = config.lock_snooze_max_attempts
|
|
94
|
-
|
|
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
|
|
109
|
-
escalate_snooze(context, snooze_count, error)
|
|
110
|
-
return
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
delay = compute_snooze_delay(config, error)
|
|
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)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Use the error's `retry_after_seconds` hint when available
|
|
120
|
-
# (RateLimit::ExceededError carries the time until the bucket rolls);
|
|
121
|
-
# otherwise fall back to the configured base + jitter for lock/semaphore
|
|
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.
|
|
132
|
-
def compute_snooze_delay(config, error)
|
|
133
|
-
jitter = config.lock_snooze_jitter.to_f
|
|
134
|
-
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
|
|
135
|
-
|
|
136
|
-
if hinted_retry?(error)
|
|
137
|
-
[error.retry_after_seconds.to_f, 0.1].max + jitter_amount
|
|
138
|
-
else
|
|
139
|
-
config.lock_snooze_base_delay.to_f + jitter_amount
|
|
140
|
-
end
|
|
141
|
-
end
|
|
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
|
-
|
|
149
|
-
def escalate_snooze(context, snooze_count, error)
|
|
150
|
-
RubyReactor.configuration.logger.warn(
|
|
151
|
-
"RubyReactor snooze limit reached after #{snooze_count} attempts " \
|
|
152
|
-
"for context #{context.context_id}: #{error.message}"
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
context.status = :failed
|
|
156
|
-
context.failure_reason = {
|
|
157
|
-
message: error.message,
|
|
158
|
-
exception_class: error.class.name,
|
|
159
|
-
snooze_attempts: snooze_count
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
serialized = ContextSerializer.serialize(context)
|
|
163
|
-
reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
|
|
164
|
-
RubyReactor.configuration.storage_adapter.store_context(
|
|
165
|
-
context.context_id,
|
|
166
|
-
serialized,
|
|
167
|
-
reactor_class_name
|
|
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
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def log_infrastructure_failure(msg, exception)
|
|
180
|
-
RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
|
|
181
|
-
RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
|
|
182
|
-
end
|
|
183
|
-
|
|
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)
|
|
188
|
-
RubyReactor.configuration.logger.error(
|
|
189
|
-
"RubyReactor deserialization failure for context " \
|
|
190
|
-
"#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
return unless context_id && reactor_class_name
|
|
194
|
-
|
|
195
|
-
payload = build_failed_context_payload(context_id, reactor_class_name, error)
|
|
196
|
-
RubyReactor.configuration.storage_adapter.store_context(
|
|
197
|
-
context_id,
|
|
198
|
-
payload,
|
|
199
|
-
reactor_class_name
|
|
200
|
-
)
|
|
201
|
-
rescue StandardError => e
|
|
202
|
-
# Don't let a persistence failure mask the original deserialization error.
|
|
203
|
-
RubyReactor.configuration.logger.error(
|
|
204
|
-
"RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
|
|
205
|
-
)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def build_failed_context_payload(context_id, reactor_class_name, error)
|
|
209
|
-
JSON.generate(
|
|
210
|
-
"schema_version" => ContextSerializer::SCHEMA_VERSION,
|
|
211
|
-
"context_id" => context_id,
|
|
212
|
-
"reactor_class" => reactor_class_name,
|
|
213
|
-
"status" => "failed",
|
|
214
|
-
"failure_reason" => {
|
|
215
|
-
"message" => error.message,
|
|
216
|
-
"exception_class" => error.class.name
|
|
217
|
-
}
|
|
218
|
-
)
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|