ruby_reactor 0.5.3 → 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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +14 -0
  41. data/README.md +146 -65
  42. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  43. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
  46. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  47. data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
  48. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  49. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
  53. data/lib/ruby_reactor/configuration.rb +24 -4
  54. data/lib/ruby_reactor/dsl/template_helpers.rb +7 -1
  55. data/lib/ruby_reactor/map/element_executor.rb +1 -1
  56. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  57. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  58. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  59. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  60. data/lib/ruby_reactor/rspec.rb +4 -0
  61. data/lib/ruby_reactor/step.rb +4 -0
  62. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  63. data/lib/ruby_reactor/version.rb +1 -1
  64. data/lib/ruby_reactor/worker.rb +226 -0
  65. data/lib/ruby_reactor.rb +40 -1
  66. data/specs/active_job.md +259 -0
  67. metadata +54 -6
  68. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  69. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  70. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  71. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  72. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ class Router
7
+ # Identity-only payload: the worker rehydrates the live context from storage
8
+ # by (context_id, reactor_class_name). The caller already holds context_id, so
9
+ # there is no blob to deserialize here.
10
+ def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
11
+ job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
12
+ RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
13
+ execution_id: context_id)
14
+ end
15
+
16
+ def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
17
+ job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
18
+ RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
19
+ execution_id: context_id)
20
+ end
21
+
22
+ # rubocop:disable Metrics/ParameterLists
23
+ def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
24
+ strict_ordering:, parent_context_id:, parent_reactor_class_name:,
25
+ step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
26
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
27
+ {
28
+ "map_id" => map_id,
29
+ "element_id" => element_id,
30
+ "index" => index,
31
+ "serialized_inputs" => serialized_inputs,
32
+ "reactor_class_info" => reactor_class_info,
33
+ "strict_ordering" => strict_ordering,
34
+ "parent_context_id" => parent_context_id,
35
+ "parent_reactor_class_name" => parent_reactor_class_name,
36
+ "step_name" => step_name,
37
+ "batch_size" => batch_size,
38
+ "serialized_context" => serialized_context,
39
+ "fail_fast" => fail_fast
40
+ }
41
+ )
42
+ RubyReactor::AsyncResult.new(job_id: job_id)
43
+ end
44
+
45
+ def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
46
+ reactor_class_info:, strict_ordering:, parent_context_id:,
47
+ parent_reactor_class_name:, step_name:, batch_size: nil,
48
+ serialized_context: nil, fail_fast: nil)
49
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
50
+ delay,
51
+ {
52
+ "map_id" => map_id,
53
+ "element_id" => element_id,
54
+ "index" => index,
55
+ "serialized_inputs" => serialized_inputs,
56
+ "reactor_class_info" => reactor_class_info,
57
+ "strict_ordering" => strict_ordering,
58
+ "parent_context_id" => parent_context_id,
59
+ "parent_reactor_class_name" => parent_reactor_class_name,
60
+ "step_name" => step_name,
61
+ "batch_size" => batch_size,
62
+ "serialized_context" => serialized_context,
63
+ "fail_fast" => fail_fast
64
+ }
65
+ )
66
+ # Return an AsyncResult so RetryManager#handle_async_retry recognises the
67
+ # element was successfully requeued and yields a RetryQueuedResult.
68
+ RubyReactor::AsyncResult.new(job_id: job_id)
69
+ end
70
+ # rubocop:enable Metrics/ParameterLists
71
+
72
+ # rubocop:disable Metrics/ParameterLists
73
+ def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
74
+ strict_ordering:, timeout:)
75
+ job_id = RubyReactor::Adapters::Sidekiq::MapCollectorWorker.perform_async(
76
+ {
77
+ "parent_context_id" => parent_context_id,
78
+ "map_id" => map_id,
79
+ "parent_reactor_class_name" => parent_reactor_class_name,
80
+ "step_name" => step_name,
81
+ "strict_ordering" => strict_ordering,
82
+ "timeout" => timeout
83
+ }
84
+ )
85
+ RubyReactor::AsyncResult.new(job_id: job_id)
86
+ end
87
+ # rubocop:enable Metrics/ParameterLists
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module Sidekiq
8
+ class SweeperWorker
9
+ include ::Sidekiq::Worker
10
+ include RubyReactor::SweeperJob
11
+
12
+ # retry: false — the sweep is idempotent and self-rescheduling, so a failed
13
+ # tick must not pile up Sidekiq retries; the next tick (or a super_fetch
14
+ # recovery) re-runs it anyway.
15
+ sidekiq_options retry: false, queue: RubyReactor.configuration.queue_name
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module Sidekiq
8
+ # Sidekiq worker for executing RubyReactor reactors asynchronously
9
+ # with non-blocking retry capabilities. All resume/snooze/escalate logic
10
+ # lives in `RubyReactor::Worker` — this class only wires it to Sidekiq.
11
+ class Worker
12
+ include ::Sidekiq::Worker
13
+ include RubyReactor::Worker
14
+
15
+ # Enable Sidekiq retries for infrastructure failures only
16
+ sidekiq_options retry: RubyReactor.configuration.job_retry_count, dead: false,
17
+ queue: RubyReactor.configuration.queue_name
18
+
19
+ sidekiq_retries_exhausted do |_, _exception|
20
+ # Handle infrastructure failures (network, Redis, etc.)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -7,13 +7,23 @@ module RubyReactor
7
7
  class Configuration
8
8
  include Singleton
9
9
 
10
- attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
10
+ attr_writer :queue_name, :job_retry_count, :logger, :async_router,
11
11
  :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
12
12
  :middlewares, :context_ttl, :context_lock_ttl, :checkpoint_min_interval,
13
13
  :sweeper_enabled, :sweeper_interval, :sweeper_limit
14
14
 
15
+ def queue_name
16
+ @queue_name ||= :default
17
+ end
18
+
19
+ # Deprecated alias for `queue_name` — kept so existing Sidekiq-only configs
20
+ # don't break.
15
21
  def sidekiq_queue
16
- @sidekiq_queue ||= :default
22
+ queue_name
23
+ end
24
+
25
+ def sidekiq_queue=(value)
26
+ self.queue_name = value
17
27
  end
18
28
 
19
29
  # Retention TTL (seconds) for a stored reactor context. Storage is
@@ -79,8 +89,18 @@ module RubyReactor
79
89
  @context_lock_ttl ||= 60
80
90
  end
81
91
 
92
+ def job_retry_count
93
+ @job_retry_count ||= 3
94
+ end
95
+
96
+ # Deprecated alias for `job_retry_count` — kept so existing Sidekiq-only
97
+ # configs don't break.
82
98
  def sidekiq_retry_count
83
- @sidekiq_retry_count ||= 3
99
+ job_retry_count
100
+ end
101
+
102
+ def sidekiq_retry_count=(value)
103
+ self.job_retry_count = value
84
104
  end
85
105
 
86
106
  # Base seconds the Sidekiq worker waits before re-checking a contended lock.
@@ -104,7 +124,7 @@ module RubyReactor
104
124
  end
105
125
 
106
126
  def async_router
107
- @async_router ||= RubyReactor::SidekiqAdapter
127
+ @async_router ||= RubyReactor::Adapters::Sidekiq::Router
108
128
  end
109
129
 
110
130
  def storage
@@ -19,7 +19,7 @@ module RubyReactor
19
19
  RubyReactor::Template::Element.new(map_name, path)
20
20
  end
21
21
 
22
- # Make Success and Failure available in DSL contexts
22
+ # Make Success, Failure, and Skipped available in DSL contexts
23
23
  # rubocop:disable Naming/MethodName
24
24
  def Success(value = nil)
25
25
  # rubocop:enable Naming/MethodName
@@ -31,6 +31,12 @@ module RubyReactor
31
31
  # rubocop:enable Naming/MethodName
32
32
  RubyReactor.Failure(error)
33
33
  end
34
+
35
+ # rubocop:disable Naming/MethodName
36
+ def Skipped(reason: nil, **kwargs)
37
+ # rubocop:enable Naming/MethodName
38
+ RubyReactor.Skipped(reason: reason, **kwargs)
39
+ end
34
40
  end
35
41
  end
36
42
  end
@@ -51,7 +51,7 @@ module RubyReactor
51
51
  # The element already runs inside its own background worker, so any async
52
52
  # steps (and async retries) must execute inline here rather than handing
53
53
  # off to a detached Worker that would escape map result/counter tracking.
54
- # This mirrors SidekiqWorkers::Worker, which sets the same flag.
54
+ # This mirrors RubyReactor::Worker, which sets the same flag.
55
55
  context.inline_async_execution = true
56
56
 
57
57
  storage = RubyReactor.configuration.storage_adapter
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ # ActiveJob counterpart to `SidekiqHelpers`, used internally by
6
+ # `AsyncTestHelpers` to drain the ActiveJob `:test` queue adapter. Only
7
+ # the static methods are needed (`AsyncTestHelpers` dispatches to them);
8
+ # `Rails::ActiveJob::TestHelper` already covers most spec-author-facing
9
+ # assertions, so this module is not auto-included into examples.
10
+ module ActiveJobHelpers
11
+ PendingJob = Struct.new(:job_class, :raw) do
12
+ def perform!
13
+ ::ActiveJob::Base.queue_adapter.enqueued_jobs.delete(raw)
14
+ job_class.new(*raw[:args]).perform_now
15
+ end
16
+
17
+ def args
18
+ raw[:args]
19
+ end
20
+ end
21
+
22
+ def self.test_adapter?
23
+ defined?(::ActiveJob::Base) &&
24
+ ::ActiveJob::Base.queue_adapter.is_a?(::ActiveJob::QueueAdapters::TestAdapter)
25
+ end
26
+
27
+ # Drain every queued job in the ActiveJob `:test` adapter until the
28
+ # queue is empty. Recursive — handles jobs that re-enqueue themselves
29
+ # (e.g. ordered_lock snoozes) and job chains that queue additional jobs.
30
+ def self.drain_async_jobs(max_iterations: 100)
31
+ return unless test_adapter?
32
+
33
+ queue = ::ActiveJob::Base.queue_adapter.enqueued_jobs
34
+
35
+ max_iterations.times do
36
+ break if queue.empty?
37
+
38
+ queue.dup.each do |job|
39
+ queue.delete(job)
40
+ job[:job].new(*job[:args]).perform_now
41
+ end
42
+ end
43
+ end
44
+
45
+ def self.pending_async_jobs
46
+ return [] unless test_adapter?
47
+
48
+ ::ActiveJob::Base.queue_adapter.enqueued_jobs.map { |raw| PendingJob.new(raw[:job], raw) }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ # Single entry point `TestSubject` uses to detect and drain whichever
6
+ # async testing framework — Sidekiq::Testing fake mode or ActiveJob's
7
+ # `:test` queue adapter — is currently active, so the job-processing gate
8
+ # isn't hardcoded to one background processor.
9
+ module AsyncTestHelpers
10
+ def self.active?
11
+ sidekiq_testing? || active_job_testing?
12
+ end
13
+
14
+ def self.sidekiq_testing?
15
+ defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.fake?
16
+ end
17
+
18
+ def self.active_job_testing?
19
+ ActiveJobHelpers.test_adapter?
20
+ end
21
+
22
+ def self.drain_async_jobs(max_iterations: 100)
23
+ if sidekiq_testing?
24
+ SidekiqHelpers.drain_async_jobs(max_iterations: max_iterations)
25
+ elsif active_job_testing?
26
+ ActiveJobHelpers.drain_async_jobs(max_iterations: max_iterations)
27
+ end
28
+ end
29
+
30
+ def self.pending_async_jobs
31
+ if sidekiq_testing?
32
+ SidekiqHelpers.pending_async_jobs
33
+ elsif active_job_testing?
34
+ ActiveJobHelpers.pending_async_jobs
35
+ else
36
+ []
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -36,9 +36,9 @@ module RubyReactor
36
36
 
37
37
  def self.worker_classes
38
38
  @worker_classes ||= [
39
- RubyReactor::SidekiqWorkers::Worker,
40
- RubyReactor::SidekiqWorkers::MapElementWorker,
41
- RubyReactor::SidekiqWorkers::MapCollectorWorker
39
+ RubyReactor::Adapters::Sidekiq::Worker,
40
+ RubyReactor::Adapters::Sidekiq::MapElementWorker,
41
+ RubyReactor::Adapters::Sidekiq::MapCollectorWorker
42
42
  ]
43
43
  end
44
44
 
@@ -191,9 +191,9 @@ module RubyReactor
191
191
  end
192
192
 
193
193
  @run_result = nil
194
- if @process_jobs && defined?(Sidekiq::Testing)
195
- # Ensure SidekiqAdapter is used to capture jobs in fake mode
196
- allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::SidekiqAdapter)
194
+ if @process_jobs && AsyncTestHelpers.sidekiq_testing?
195
+ # Ensure the Sidekiq router is used to capture jobs in fake mode
196
+ allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::Sidekiq::Router)
197
197
 
198
198
  # Avoid nesting error which happens in Sidekiq 7+ if a mode is already set
199
199
  begin
@@ -203,6 +203,10 @@ module RubyReactor
203
203
  rescue Sidekiq::Testing::TestModeAlreadySetError
204
204
  @run_result = execution_class.run(@inputs)
205
205
  end
206
+ elsif @process_jobs && AsyncTestHelpers.active_job_testing?
207
+ # Ensure the ActiveJob router is used to capture jobs in the :test adapter
208
+ allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::ActiveJob::Router)
209
+ @run_result = execution_class.run(@inputs)
206
210
  else
207
211
  @run_result = execution_class.run(@inputs)
208
212
  end
@@ -250,7 +254,7 @@ module RubyReactor
250
254
  skipped_result(ctx)
251
255
  when "running"
252
256
  # Try to determine if it is truly running or if we just missed the completion
253
- if @process_jobs && defined?(Sidekiq::Testing)
257
+ if @process_jobs && AsyncTestHelpers.active?
254
258
  # Force one more check
255
259
  process_pending_jobs
256
260
  # Reload status
@@ -357,7 +361,7 @@ module RubyReactor
357
361
  @reactor_instance.continue(payload: payload, step_name: step_name)
358
362
 
359
363
  # Process any pending async jobs
360
- process_pending_jobs if @process_jobs && defined?(Sidekiq::Testing)
364
+ process_pending_jobs if @process_jobs && AsyncTestHelpers.active?
361
365
 
362
366
  # Reload the reactor instance to get updated state
363
367
  @reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
@@ -430,9 +434,9 @@ module RubyReactor
430
434
  private
431
435
 
432
436
  def process_pending_jobs
433
- return unless defined?(Sidekiq::Testing)
437
+ return unless AsyncTestHelpers.active?
434
438
 
435
- SidekiqHelpers.drain_async_jobs
439
+ AsyncTestHelpers.drain_async_jobs
436
440
  @reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
437
441
  end
438
442
 
@@ -3,6 +3,8 @@
3
3
  require_relative "rspec/helpers"
4
4
  require_relative "rspec/matchers"
5
5
  require_relative "rspec/sidekiq_helpers"
6
+ require_relative "rspec/active_job_helpers"
7
+ require_relative "rspec/async_test_helpers"
6
8
  require_relative "rspec/storage_reset"
7
9
  require_relative "rspec/test_subject"
8
10
 
@@ -44,6 +46,8 @@ module RubyReactor
44
46
  ::Sidekiq::Worker.clear_all
45
47
  end
46
48
 
49
+ ::ActiveJob::Base.queue_adapter.enqueued_jobs.clear if AsyncTestHelpers.active_job_testing?
50
+
47
51
  adapter = ::RubyReactor.configuration.storage_adapter
48
52
  adapter.reset! if adapter.respond_to?(:reset!)
49
53
 
@@ -15,6 +15,10 @@ module RubyReactor
15
15
  def Failure(error = nil)
16
16
  RubyReactor::Failure(error)
17
17
  end
18
+
19
+ def Skipped(reason: nil, **kwargs)
20
+ RubyReactor.Skipped(reason: reason, **kwargs)
21
+ end
18
22
  # rubocop:enable Naming/MethodName
19
23
 
20
24
  def run(arguments, context)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyReactor
6
+ # Self-rescheduling recovery tick, shared by every queueing backend's
7
+ # sweeper job class. Each run sweeps both the top-level reactor sweeper and
8
+ # the map sweeper, then schedules the next tick — a perpetual chain the
9
+ # host kicks once via `RubyReactor.start_sweeper!`.
10
+ #
11
+ # super_fetch safety. Sidekiq Enterprise `super_fetch` reliably re-runs a job
12
+ # whose worker died mid-execution. For a self-rescheduling chain that is a
13
+ # hazard: a tick can crash AFTER enqueuing its successor but BEFORE acking, so
14
+ # super_fetch recovers the crashed tick *alongside* the successor it already
15
+ # scheduled — the chain forks and then doubles every interval. We therefore do
16
+ # NOT rely on "exactly one job exists". The next tick is claimed by a
17
+ # per-time-window lock: every duplicate computes the SAME target window and
18
+ # only one wins the claim, so recovered/duplicated ticks collapse back to a
19
+ # single chain. The claim lock is never released — it simply expires — so no
20
+ # delete can race two duplicates into both winning.
21
+ module SweeperJob
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ end
25
+
26
+ def perform
27
+ config = RubyReactor.configuration
28
+ return unless config.sweeper_enabled
29
+
30
+ run_sweeps(config)
31
+ ensure
32
+ # Always chain forward (unless disabled), even after an error above, so a
33
+ # single bad sweep can't kill recovery. The window lock keeps this from
34
+ # forking under super_fetch.
35
+ self.class.schedule_next if RubyReactor.configuration.sweeper_enabled
36
+ end
37
+
38
+ def run_sweeps(config)
39
+ RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
40
+ RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
41
+ rescue StandardError => e
42
+ config.logger.error("RubyReactor sweeper sweep failed: #{e.class}: #{e.message}")
43
+ end
44
+
45
+ module ClassMethods
46
+ # Enqueue the next tick for the upcoming time window, claiming that window
47
+ # so concurrent/duplicate/recovered ticks produce exactly one successor.
48
+ # Idempotent: also safe to call from `start_sweeper!` on every process boot.
49
+ def schedule_next
50
+ interval = RubyReactor.configuration.sweeper_interval
51
+ window = (Time.now.to_i / interval) + 1
52
+
53
+ lock = RubyReactor::Lock.new(
54
+ "sweeper:window:#{window}",
55
+ owner: SecureRandom.uuid,
56
+ ttl: interval * 2, # outlive the window; expires on its own (never released)
57
+ wait: 0,
58
+ auto_extend: false
59
+ )
60
+ lock.acquire # raises AcquisitionError if this window is already claimed
61
+
62
+ delay = (window * interval) - Time.now.to_i
63
+ perform_in([delay, 1].max)
64
+ rescue RubyReactor::Lock::AcquisitionError
65
+ # Another tick already scheduled this window — collapse the duplicate.
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.5.3"
4
+ VERSION = "0.6.0"
5
5
  end