ruby_reactor 0.2.0 → 0.3.1

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/README.md +132 -0
  3. data/Rakefile +2 -2
  4. data/documentation/data_pipelines.md +90 -84
  5. data/documentation/testing.md +812 -0
  6. data/lib/ruby_reactor/configuration.rb +1 -1
  7. data/lib/ruby_reactor/context.rb +13 -5
  8. data/lib/ruby_reactor/context_serializer.rb +70 -4
  9. data/lib/ruby_reactor/dsl/map_builder.rb +6 -2
  10. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  11. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  12. data/lib/ruby_reactor/executor/result_handler.rb +9 -2
  13. data/lib/ruby_reactor/executor/retry_manager.rb +26 -8
  14. data/lib/ruby_reactor/executor/step_executor.rb +24 -99
  15. data/lib/ruby_reactor/executor.rb +3 -13
  16. data/lib/ruby_reactor/map/collector.rb +72 -33
  17. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  18. data/lib/ruby_reactor/map/element_executor.rb +103 -114
  19. data/lib/ruby_reactor/map/execution.rb +18 -4
  20. data/lib/ruby_reactor/map/helpers.rb +4 -3
  21. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  22. data/lib/ruby_reactor/reactor.rb +174 -16
  23. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  24. data/lib/ruby_reactor/rspec/matchers.rb +256 -0
  25. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  26. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  27. data/lib/ruby_reactor/rspec.rb +18 -0
  28. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +15 -10
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -3
  30. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  31. data/lib/ruby_reactor/step/map_step.rb +52 -27
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +59 -0
  33. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +32 -24
  36. data/lib/ruby_reactor.rb +70 -10
  37. metadata +12 -3
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ class Dispatcher
6
+ extend Helpers
7
+
8
+ def self.perform(arguments)
9
+ arguments = arguments.transform_keys(&:to_sym)
10
+ parent_reactor_class_name = arguments[:parent_reactor_class_name]
11
+
12
+ storage = RubyReactor.configuration.storage_adapter
13
+
14
+ # Load parent context to resolve source
15
+ parent_context = load_parent_context_from_storage(
16
+ arguments[:parent_context_id],
17
+ parent_reactor_class_name,
18
+ storage
19
+ )
20
+
21
+ # Initialize metadata if first run
22
+ initialize_map_metadata(arguments, storage) unless arguments[:continuation]
23
+
24
+ # Resolve Source
25
+ # We need to resolve the source to know what we are iterating.
26
+ # Strict "Array Only" rule means we expect an Array-like object or we handle the
27
+ # "Query Builder" result if user used it.
28
+ source = resolve_source(arguments, parent_context)
29
+
30
+ # Dispatch next batch
31
+ dispatch_batch(source, arguments, parent_context, storage)
32
+ end
33
+
34
+ def self.initialize_map_metadata(arguments, storage)
35
+ map_id = arguments[:map_id]
36
+ reactor_class_name = arguments[:parent_reactor_class_name]
37
+
38
+ # Reset or set initial offset. Use NX to act as a mutex/guard against duplicate initialization.
39
+ storage.set_map_offset_if_not_exists(map_id, 0, reactor_class_name)
40
+ end
41
+
42
+ def self.resolve_source(arguments, context)
43
+ # Arguments has :source which is a Template::Input or similar.
44
+ # We need to resolve it against the context.
45
+ source_template = arguments[:source]
46
+
47
+ # Fallback: look up from step config if missing (e.g. called from ElementExecutor)
48
+ if source_template.nil? && context
49
+ step_name = arguments[:step_name]
50
+ step_config = context.reactor_class.steps[step_name.to_sym]
51
+ source_template = step_config.arguments[:source][:source]
52
+ end
53
+
54
+ # If source is packaged in arguments as a value (deserialized)
55
+ return source_template if source_template.is_a?(Array)
56
+
57
+ # Resolve template
58
+ return source_template.resolve(context) if source_template.respond_to?(:resolve)
59
+
60
+ source_template
61
+ end
62
+
63
+ def self.dispatch_batch(source, arguments, parent_context, storage)
64
+ map_id = arguments[:map_id]
65
+ reactor_class_name = arguments[:parent_reactor_class_name]
66
+
67
+ # Fail Fast Check
68
+ if arguments[:fail_fast]
69
+ failed_context_id = storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
70
+ return if failed_context_id
71
+ end
72
+
73
+ batch_size = arguments[:batch_size] || source.size # Default to all if no batch_size (async=true only)
74
+
75
+ # Atomically reserve a batch
76
+ new_offset = storage.increment_map_offset(map_id, batch_size, reactor_class_name)
77
+ current_offset = new_offset - batch_size
78
+
79
+ batch_elements = if source.is_a?(Array)
80
+ source.slice(current_offset, batch_size) || []
81
+ elsif source.respond_to?(:offset) && source.respond_to?(:limit)
82
+ # Optimized for ActiveRecord and similar query builders
83
+ source.offset(current_offset).limit(batch_size).to_a
84
+ else
85
+ # Fallback for generic Enumerable
86
+ # This is inefficient for huge sets if not Array, but compliant
87
+ source.drop(current_offset).take(batch_size)
88
+ end
89
+
90
+ return if batch_elements.empty?
91
+
92
+ # Queue Jobs
93
+ queue_options = {
94
+ map_id: map_id,
95
+ arguments: arguments,
96
+ context: parent_context,
97
+ reactor_class_info: resolve_reactor_class_info(arguments, parent_context),
98
+ step_name: arguments[:step_name]
99
+ }
100
+
101
+ batch_elements.each_with_index do |element, i|
102
+ absolute_index = current_offset + i
103
+ queue_element_job(element, absolute_index, queue_options)
104
+ end
105
+ end
106
+
107
+ def self.queue_element_job(element, index, options)
108
+ arguments = options[:arguments]
109
+ context = options[:context]
110
+
111
+ # Resolve mappings
112
+ mappings_template = arguments[:argument_mappings]
113
+
114
+ # Fallback: look up from step config if missing (e.g. called from ElementExecutor)
115
+ if mappings_template.nil? && context
116
+ step_name = options[:step_name] || arguments[:step_name]
117
+ step_config = context.reactor_class.steps[step_name.to_sym]
118
+ mappings_template = step_config.arguments[:argument_mappings]
119
+ end
120
+
121
+ mappings = if mappings_template.respond_to?(:resolve)
122
+ mappings_template.resolve(context)
123
+ else
124
+ mappings_template || {}
125
+ end
126
+
127
+ # Fix for weird structure observed in fallback (wrapped in :source -> Template::Value)
128
+ if mappings.key?(:source) && mappings[:source].respond_to?(:value) && mappings[:source].value.is_a?(Hash)
129
+ mappings = mappings[:source].value
130
+ end
131
+
132
+ mapped_inputs = build_element_inputs(mappings, context, element)
133
+ serialized_inputs = ContextSerializer.serialize_value(mapped_inputs)
134
+
135
+ RubyReactor.configuration.async_router.perform_map_element_async(
136
+ map_id: options[:map_id],
137
+ element_id: "#{options[:map_id]}:#{index}",
138
+ index: index,
139
+ serialized_inputs: serialized_inputs,
140
+ reactor_class_info: options[:reactor_class_info],
141
+ strict_ordering: arguments[:strict_ordering],
142
+ parent_context_id: context.context_id,
143
+ parent_reactor_class_name: context.reactor_class.name,
144
+ step_name: options[:step_name].to_s,
145
+ batch_size: arguments[:batch_size], # Passed to worker so it knows to trigger next batch?
146
+ fail_fast: arguments[:fail_fast]
147
+ )
148
+ end
149
+
150
+ def self.resolve_reactor_class_info(arguments, context)
151
+ mapped_reactor_class = arguments[:mapped_reactor_class]
152
+ step_name = arguments[:step_name]
153
+
154
+ if mapped_reactor_class.respond_to?(:name)
155
+ { "type" => "class", "name" => mapped_reactor_class.name }
156
+ else
157
+ { "type" => "inline", "parent" => context.reactor_class.name, "step" => step_name.to_s }
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -5,153 +5,142 @@ module RubyReactor
5
5
  class ElementExecutor
6
6
  extend Helpers
7
7
 
8
- # rubocop:disable Metrics/MethodLength
9
8
  def self.perform(arguments)
10
9
  arguments = arguments.transform_keys(&:to_sym)
11
- map_id = arguments[:map_id]
12
- _element_id = arguments[:element_id]
13
- index = arguments[:index]
14
- serialized_inputs = arguments[:serialized_inputs]
15
- reactor_class_info = arguments[:reactor_class_info]
16
- strict_ordering = arguments[:strict_ordering]
17
- parent_context_id = arguments[:parent_context_id]
18
- parent_reactor_class_name = arguments[:parent_reactor_class_name]
19
- step_name = arguments[:step_name]
20
- batch_size = arguments[:batch_size]
21
- # rubocop:enable Metrics/MethodLength
22
- serialized_context = arguments[:serialized_context]
23
10
 
24
- if serialized_context
25
- context = ContextSerializer.deserialize(serialized_context)
11
+ context = hydrate_or_create_context(arguments)
12
+ storage = RubyReactor.configuration.storage_adapter
13
+ storage.store_map_element_context_id(arguments[:map_id], context.context_id,
14
+ arguments[:parent_reactor_class_name])
15
+
16
+ return if check_fail_fast?(arguments, storage)
17
+
18
+ executor = Executor.new(context.reactor_class, {}, context)
19
+ arguments[:serialized_context] ? executor.resume_execution : executor.execute
20
+
21
+ handle_result(executor.result, arguments, context, storage, executor)
22
+ finalize_execution(arguments, storage)
23
+ end
24
+
25
+ def self.load_parent_context(arguments, reactor_class_name, storage)
26
+ parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
27
+ parent_reactor_class = Object.const_get(reactor_class_name)
28
+ parent_context = Context.new(
29
+ ContextSerializer.deserialize_value(parent_context_data["inputs"]),
30
+ parent_reactor_class
31
+ )
32
+ parent_context.context_id = arguments[:parent_context_id]
33
+ parent_context
34
+ end
35
+
36
+ # Legacy helpers resolved_next_element, build_serialized_inputs, queue_element_job
37
+ # are REMOVED as they are no longer used for self-queuing.
38
+
39
+ # Basic helper to build inputs for the CURRENT element (still needed for perform)
40
+ # Wait, perform uses `serialized_inputs` passed to it.
41
+ # We don't need `build_element_inputs` here?
42
+ # `perform` uses `params[:serialized_inputs]`.
43
+ # So we can remove input building helpers too?
44
+ # Let's check if they are used elsewhere.
45
+ # `resolve_reactor_class` is used in `perform`.
46
+ # `build_element_inputs` is likely in Helpers or mixed in?
47
+
48
+ # rubocop:disable Style/IdenticalConditionalBranches
49
+ def self.hydrate_or_create_context(arguments)
50
+ if arguments[:serialized_context]
51
+ context = ContextSerializer.deserialize(arguments[:serialized_context])
26
52
  context.map_metadata = arguments
27
- reactor_class = context.reactor_class
28
- else
29
- # Deserialize inputs
30
- inputs = ContextSerializer.deserialize_value(serialized_inputs)
31
53
 
32
- # Resolve reactor class
33
- reactor_class = resolve_reactor_class(reactor_class_info)
54
+ if context.inputs.empty? && arguments[:serialized_inputs]
55
+ context.inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
56
+ end
57
+ context
58
+ else
59
+ inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
60
+ reactor_class = resolve_reactor_class(arguments[:reactor_class_info])
34
61
 
35
- # Create context
36
62
  context = Context.new(inputs, reactor_class)
37
- context.parent_context_id = parent_context_id
63
+ context.parent_context_id = arguments[:parent_context_id]
38
64
  context.map_metadata = arguments
65
+ context
39
66
  end
67
+ end
68
+ # rubocop:enable Style/IdenticalConditionalBranches
40
69
 
41
- storage = RubyReactor.configuration.storage_adapter
42
- storage.store_map_element_context_id(map_id, context.context_id, parent_reactor_class_name)
43
-
44
- # Execute
45
- executor = Executor.new(reactor_class, {}, context)
70
+ def self.check_fail_fast?(arguments, storage)
71
+ return false unless arguments[:fail_fast]
46
72
 
47
- if serialized_context
48
- executor.resume_execution
49
- else
50
- executor.execute
51
- end
73
+ map_id = arguments[:map_id]
74
+ parent_reactor_class_name = arguments[:parent_reactor_class_name]
52
75
 
53
- result = executor.result
76
+ failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
77
+ return false unless failed_context_id
54
78
 
55
- if result.is_a?(RetryQueuedResult)
56
- queue_next_batch(arguments) if batch_size
57
- return
58
- end
79
+ # Skip execution
80
+ finalize_execution(arguments, storage)
81
+ true
82
+ end
59
83
 
60
- # Store result
84
+ def self.handle_result(result, arguments, context, storage, executor)
85
+ return if result.is_a?(RetryQueuedResult)
61
86
 
62
- # Store result
87
+ map_id = arguments[:map_id]
88
+ index = arguments[:index]
89
+ parent_class = arguments[:parent_reactor_class_name] # Using short name for variable
63
90
 
64
91
  if result.success?
65
- storage.store_map_result(map_id, index, result.value, parent_reactor_class_name,
66
- strict_ordering: strict_ordering)
92
+ storage.store_map_result(map_id, index, ContextSerializer.serialize_value(result.value),
93
+ parent_class, strict_ordering: arguments[:strict_ordering])
67
94
  else
68
- # Store error
69
- storage.store_map_result(map_id, index, { _error: result.error }, parent_reactor_class_name,
70
- strict_ordering: strict_ordering)
95
+ executor.undo_all
96
+ storage.store_map_result(map_id, index, { _error: result.error }, parent_class,
97
+ strict_ordering: arguments[:strict_ordering])
98
+
99
+ if arguments[:fail_fast]
100
+ storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
101
+ # FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
102
+ RubyReactor.configuration.async_router.perform_map_collection_async(
103
+ parent_context_id: arguments[:parent_context_id],
104
+ map_id: map_id,
105
+ parent_reactor_class_name: parent_class,
106
+ step_name: arguments[:step_name],
107
+ strict_ordering: arguments[:strict_ordering],
108
+ timeout: 3600
109
+ )
110
+ end
71
111
  end
112
+ end
72
113
 
73
- # Decrement counter
74
- new_count = storage.decrement_map_counter(map_id, parent_reactor_class_name)
114
+ def self.finalize_execution(arguments, storage)
115
+ map_id = arguments[:map_id]
116
+ parent_class = arguments[:parent_reactor_class_name]
75
117
 
76
- queue_next_batch(arguments) if batch_size
118
+ new_count = storage.decrement_map_counter(map_id, parent_class)
119
+ trigger_next_batch_if_needed(arguments, arguments[:index], arguments[:batch_size])
77
120
 
78
121
  return unless new_count.zero?
79
122
 
80
- # Trigger collection
81
123
  RubyReactor.configuration.async_router.perform_map_collection_async(
82
- parent_context_id: parent_context_id,
124
+ parent_context_id: arguments[:parent_context_id],
83
125
  map_id: map_id,
84
- parent_reactor_class_name: parent_reactor_class_name,
85
- step_name: step_name,
86
- strict_ordering: strict_ordering,
126
+ parent_reactor_class_name: parent_class,
127
+ step_name: arguments[:step_name],
128
+ strict_ordering: arguments[:strict_ordering],
87
129
  timeout: 3600
88
130
  )
89
131
  end
90
132
 
91
- def self.queue_next_batch(arguments)
92
- storage = RubyReactor.configuration.storage_adapter
93
- map_id = arguments[:map_id]
94
- reactor_class_name = arguments[:parent_reactor_class_name]
95
-
96
- next_index = storage.increment_last_queued_index(map_id, reactor_class_name)
97
- total_count = storage.retrieve_map_metadata(map_id, reactor_class_name)["count"]
98
-
99
- return unless next_index < total_count
100
-
101
- parent_context = load_parent_context(arguments, reactor_class_name, storage)
102
- element = resolve_next_element(arguments, parent_context, next_index)
103
- serialized_inputs = build_serialized_inputs(arguments, parent_context, element)
133
+ def self.trigger_next_batch_if_needed(arguments, index, batch_size)
134
+ return unless batch_size && ((index + 1) % batch_size).zero?
104
135
 
105
- queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
136
+ # Trigger Dispatcher for next batch
137
+ next_batch_args = arguments.dup
138
+ # Ensure we don't carry over temporary execution flags if any
139
+ next_batch_args[:continuation] = true
140
+ RubyReactor::Map::Dispatcher.perform(next_batch_args)
106
141
  end
107
142
 
108
- def self.load_parent_context(arguments, reactor_class_name, storage)
109
- parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
110
- parent_reactor_class = Object.const_get(reactor_class_name)
111
- parent_context = Context.new(
112
- ContextSerializer.deserialize_value(parent_context_data["inputs"]),
113
- parent_reactor_class
114
- )
115
- parent_context.context_id = arguments[:parent_context_id]
116
- parent_context
117
- end
118
-
119
- def self.resolve_next_element(arguments, parent_context, next_index)
120
- parent_reactor_class = parent_context.reactor_class
121
- step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
122
-
123
- source_template = step_config.arguments[:source][:source]
124
- source = source_template.resolve(parent_context)
125
- source[next_index]
126
- end
127
-
128
- def self.build_serialized_inputs(arguments, parent_context, element)
129
- parent_reactor_class = parent_context.reactor_class
130
- step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
131
-
132
- mappings_template = step_config.arguments[:argument_mappings][:source]
133
- mappings = mappings_template.resolve(parent_context) || {}
134
-
135
- mapped_inputs = build_element_inputs(mappings, parent_context, element)
136
- ContextSerializer.serialize_value(mapped_inputs)
137
- end
138
-
139
- def self.queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
140
- RubyReactor.configuration.async_router.perform_map_element_async(
141
- map_id: map_id,
142
- element_id: "#{map_id}:#{next_index}",
143
- index: next_index,
144
- serialized_inputs: serialized_inputs,
145
- reactor_class_info: arguments[:reactor_class_info],
146
- strict_ordering: arguments[:strict_ordering],
147
- parent_context_id: arguments[:parent_context_id],
148
- parent_reactor_class_name: reactor_class_name,
149
- step_name: arguments[:step_name],
150
- batch_size: arguments[:batch_size]
151
- )
152
- end
153
- private_class_method :queue_next_batch, :load_parent_context,
154
- :resolve_next_element, :build_serialized_inputs, :queue_element_job
143
+ private_class_method :load_parent_context, :trigger_next_batch_if_needed
155
144
  end
156
145
  end
157
146
  end
@@ -21,7 +21,8 @@ module RubyReactor
21
21
  storage_options: {
22
22
  map_id: arguments[:map_id], storage: storage,
23
23
  parent_reactor_class_name: arguments[:parent_reactor_class_name],
24
- strict_ordering: arguments[:strict_ordering]
24
+ strict_ordering: arguments[:strict_ordering],
25
+ fail_fast: arguments[:fail_fast]
25
26
  }
26
27
  )
27
28
 
@@ -30,7 +31,14 @@ module RubyReactor
30
31
  end
31
32
 
32
33
  def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
34
+ # rubocop:disable Metrics/BlockLength
33
35
  source.map.with_index do |element, index|
36
+ if storage_options[:fail_fast]
37
+ failed_context_id = storage_options[:storage].retrieve_map_failed_context_id(
38
+ storage_options[:map_id], storage_options[:parent_reactor_class_name]
39
+ )
40
+ next if failed_context_id
41
+ end
34
42
  element_inputs = build_element_inputs(mappings, parent_context, element)
35
43
 
36
44
  # Manually create and link context to ensure parent_context_id is set
@@ -56,21 +64,27 @@ module RubyReactor
56
64
 
57
65
  store_result(result, index, storage_options)
58
66
 
67
+ if result.failure? && storage_options[:fail_fast]
68
+ storage_options[:storage].store_map_failed_context_id(
69
+ storage_options[:map_id], child_context.context_id, storage_options[:parent_reactor_class_name]
70
+ )
71
+ end
72
+
59
73
  result
60
- end
74
+ end.compact
75
+ # rubocop:enable Metrics/BlockLength
61
76
  end
62
77
 
63
78
  def self.link_contexts(child_context, parent_context)
64
79
  child_context.parent_context = parent_context
65
80
  child_context.root_context = parent_context.root_context || parent_context
66
- child_context.test_mode = parent_context.test_mode
67
81
  child_context.inline_async_execution = parent_context.inline_async_execution
68
82
  end
69
83
 
70
84
  def self.store_result(result, index, options)
71
85
  value = result.success? ? result.value : { _error: result.error }
72
86
  options[:storage].store_map_result(
73
- options[:map_id], index, value, options[:parent_reactor_class_name],
87
+ options[:map_id], index, ContextSerializer.serialize_value(value), options[:parent_reactor_class_name],
74
88
  strict_ordering: options[:strict_ordering]
75
89
  )
76
90
  end
@@ -54,16 +54,17 @@ module RubyReactor
54
54
  # Resumes parent reactor execution after map completion
55
55
  def resume_parent_execution(parent_context, step_name, final_result, storage)
56
56
  executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
+ step_name_sym = step_name.to_sym
57
58
 
58
59
  if final_result.failure?
59
- step_name_sym = step_name.to_sym
60
60
  parent_context.current_step = step_name_sym
61
61
 
62
62
  error = RubyReactor::Error::StepFailureError.new(
63
63
  final_result.error,
64
64
  step: step_name_sym,
65
65
  context: parent_context,
66
- original_error: final_result.error.is_a?(Exception) ? final_result.error : nil
66
+ original_error: final_result.error.is_a?(Exception) ? final_result.error : nil,
67
+ exception_class: final_result.respond_to?(:exception_class) ? final_result.exception_class : nil
67
68
  )
68
69
 
69
70
  # Pass backtrace if available
@@ -77,7 +78,7 @@ module RubyReactor
77
78
  # Manually update context status since we're not running executor loop
78
79
  executor.send(:update_context_status, failure_response)
79
80
  else
80
- parent_context.set_result(step_name.to_sym, final_result.value)
81
+ parent_context.set_result(step_name_sym, final_result.value)
81
82
 
82
83
  # Manually update execution trace to reflect completion
83
84
  # This is necessary because resume_execution continues from the NEXT step
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ class ResultEnumerator
6
+ include Enumerable
7
+
8
+ DEFAULT_BATCH_SIZE = 1000
9
+
10
+ attr_reader :map_id, :reactor_class_name, :strict_ordering, :batch_size
11
+
12
+ def initialize(map_id, reactor_class_name, strict_ordering: true, batch_size: DEFAULT_BATCH_SIZE)
13
+ @map_id = map_id
14
+ @reactor_class_name = reactor_class_name
15
+ @strict_ordering = strict_ordering
16
+ @batch_size = batch_size
17
+ @storage = RubyReactor.configuration.storage_adapter
18
+ end
19
+
20
+ def each
21
+ return enum_for(:each) unless block_given?
22
+
23
+ if @strict_ordering
24
+ count.times do |i|
25
+ yield self[i]
26
+ end
27
+ else
28
+ offset = 0
29
+ loop do
30
+ results = @storage.retrieve_map_results_batch(
31
+ @map_id,
32
+ @reactor_class_name,
33
+ offset: offset,
34
+ limit: @batch_size,
35
+ strict_ordering: @strict_ordering
36
+ )
37
+
38
+ break if results.empty?
39
+
40
+ results.each { |result| yield wrap_result(result) }
41
+
42
+ offset += results.size
43
+ break if results.size < @batch_size
44
+ end
45
+ end
46
+ end
47
+
48
+ def count
49
+ @count ||= @storage.count_map_results(@map_id, @reactor_class_name)
50
+ end
51
+ alias size count
52
+ alias length count
53
+
54
+ def empty?
55
+ count.zero?
56
+ end
57
+
58
+ def any?
59
+ !empty?
60
+ end
61
+
62
+ def [](index)
63
+ return nil if index.negative? || index >= count
64
+
65
+ results = @storage.retrieve_map_results_batch(
66
+ @map_id,
67
+ @reactor_class_name,
68
+ offset: index,
69
+ limit: 1,
70
+ strict_ordering: @strict_ordering
71
+ )
72
+
73
+ return nil if results.empty?
74
+
75
+ wrap_result(results.first)
76
+ end
77
+
78
+ def first
79
+ self[0]
80
+ end
81
+
82
+ def last
83
+ self[count - 1]
84
+ end
85
+
86
+ def successes
87
+ lazy.select { |result| result.is_a?(RubyReactor::Success) }.map(&:value)
88
+ end
89
+
90
+ def failures
91
+ lazy.select { |result| result.is_a?(RubyReactor::Failure) }.map(&:error)
92
+ end
93
+
94
+ private
95
+
96
+ def wrap_result(result)
97
+ if result.is_a?(Hash) && result.key?("_error")
98
+ RubyReactor::Failure.new(result["_error"])
99
+ else
100
+ RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end