igniter 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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +296 -1
  5. data/docs/BACKLOG.md +166 -0
  6. data/docs/BRANCHES_V1.md +213 -0
  7. data/docs/COLLECTIONS_V1.md +303 -0
  8. data/docs/EXECUTION_MODEL_V2.md +79 -0
  9. data/docs/PATTERNS.md +222 -0
  10. data/docs/STORE_ADAPTERS.md +126 -0
  11. data/examples/README.md +127 -0
  12. data/examples/async_store.rb +47 -0
  13. data/examples/collection.rb +43 -0
  14. data/examples/collection_partial_failure.rb +50 -0
  15. data/examples/marketing_ergonomics.rb +57 -0
  16. data/examples/ringcentral_routing.rb +269 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +90 -0
  18. data/lib/igniter/compiler/graph_compiler.rb +12 -2
  19. data/lib/igniter/compiler/type_resolver.rb +54 -0
  20. data/lib/igniter/compiler/validation_context.rb +61 -0
  21. data/lib/igniter/compiler/validation_pipeline.rb +30 -0
  22. data/lib/igniter/compiler/validator.rb +1 -187
  23. data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
  24. data/lib/igniter/compiler/validators/dependencies_validator.rb +153 -0
  25. data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
  26. data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
  27. data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
  28. data/lib/igniter/compiler.rb +8 -0
  29. data/lib/igniter/contract.rb +152 -4
  30. data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
  31. data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
  32. data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
  33. data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
  34. data/lib/igniter/diagnostics/report.rb +186 -11
  35. data/lib/igniter/dsl/contract_builder.rb +271 -5
  36. data/lib/igniter/dsl/schema_builder.rb +73 -0
  37. data/lib/igniter/dsl.rb +1 -0
  38. data/lib/igniter/errors.rb +11 -0
  39. data/lib/igniter/events/bus.rb +5 -0
  40. data/lib/igniter/events/event.rb +29 -0
  41. data/lib/igniter/executor.rb +74 -0
  42. data/lib/igniter/executor_registry.rb +44 -0
  43. data/lib/igniter/extensions/auditing/timeline.rb +4 -0
  44. data/lib/igniter/extensions/introspection/graph_formatter.rb +33 -3
  45. data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
  46. data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
  47. data/lib/igniter/extensions/introspection.rb +1 -0
  48. data/lib/igniter/extensions/reactive/engine.rb +49 -2
  49. data/lib/igniter/extensions/reactive/reaction.rb +3 -2
  50. data/lib/igniter/model/branch_node.rb +46 -0
  51. data/lib/igniter/model/collection_node.rb +31 -0
  52. data/lib/igniter/model/composition_node.rb +2 -2
  53. data/lib/igniter/model/compute_node.rb +58 -2
  54. data/lib/igniter/model/input_node.rb +2 -2
  55. data/lib/igniter/model/output_node.rb +24 -4
  56. data/lib/igniter/model.rb +2 -0
  57. data/lib/igniter/runtime/cache.rb +64 -25
  58. data/lib/igniter/runtime/collection_result.rb +111 -0
  59. data/lib/igniter/runtime/deferred_result.rb +40 -0
  60. data/lib/igniter/runtime/execution.rb +261 -11
  61. data/lib/igniter/runtime/input_validator.rb +2 -24
  62. data/lib/igniter/runtime/invalidator.rb +1 -1
  63. data/lib/igniter/runtime/job_worker.rb +18 -0
  64. data/lib/igniter/runtime/node_state.rb +20 -0
  65. data/lib/igniter/runtime/planner.rb +126 -0
  66. data/lib/igniter/runtime/resolver.rb +310 -15
  67. data/lib/igniter/runtime/result.rb +14 -2
  68. data/lib/igniter/runtime/runner_factory.rb +20 -0
  69. data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
  70. data/lib/igniter/runtime/runners/store_runner.rb +29 -0
  71. data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
  72. data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
  73. data/lib/igniter/runtime/stores/file_store.rb +43 -0
  74. data/lib/igniter/runtime/stores/memory_store.rb +40 -0
  75. data/lib/igniter/runtime/stores/redis_store.rb +44 -0
  76. data/lib/igniter/runtime.rb +12 -0
  77. data/lib/igniter/type_system.rb +44 -0
  78. data/lib/igniter/version.rb +1 -1
  79. data/lib/igniter.rb +23 -0
  80. metadata +43 -2
@@ -3,17 +3,6 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class InputValidator
6
- SUPPORTED_TYPES = {
7
- integer: Integer,
8
- float: Float,
9
- numeric: Numeric,
10
- string: String,
11
- boolean: :boolean,
12
- array: Array,
13
- hash: Hash,
14
- symbol: Symbol
15
- }.freeze
16
-
17
6
  def initialize(compiled_graph)
18
7
  @compiled_graph = compiled_graph
19
8
  end
@@ -104,26 +93,15 @@ module Igniter
104
93
  return if value.nil?
105
94
  return unless input_node.type
106
95
 
107
- unless supported_type?(input_node.type)
96
+ unless TypeSystem.supported?(input_node.type)
108
97
  raise input_error(input_node, "Unsupported input type '#{input_node.type}' for '#{input_node.name}'")
109
98
  end
110
99
 
111
- return if type_match?(input_node.type, value)
100
+ return if TypeSystem.match?(input_node.type, value)
112
101
 
113
102
  raise input_error(input_node, "Input '#{input_node.name}' must be of type #{input_node.type}, got #{value.class}")
114
103
  end
115
104
 
116
- def supported_type?(type)
117
- SUPPORTED_TYPES.key?(type.to_sym)
118
- end
119
-
120
- def type_match?(type, value)
121
- matcher = SUPPORTED_TYPES.fetch(type.to_sym)
122
- return value == true || value == false if matcher == :boolean
123
-
124
- value.is_a?(matcher)
125
- end
126
-
127
105
  def symbolize_keys(hash)
128
106
  hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
129
107
  end
@@ -37,7 +37,7 @@ module Igniter
37
37
 
38
38
  def emit_output_invalidations_for(source_name, cause_name)
39
39
  @execution.compiled_graph.outputs.each do |output_node|
40
- next unless output_node.source == source_name.to_sym
40
+ next unless output_node.source_root == source_name.to_sym
41
41
 
42
42
  @execution.events.emit(
43
43
  :node_invalidated,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class JobWorker
6
+ def initialize(contract_class, store: Igniter.execution_store)
7
+ @contract_class = contract_class
8
+ @store = store
9
+ end
10
+
11
+ def resume(execution_id:, token:, value:)
12
+ contract = @contract_class.restore_from_store(execution_id, store: @store)
13
+ contract.execution.resume_by_token(token, value: value)
14
+ contract
15
+ end
16
+ end
17
+ end
18
+ end
@@ -19,6 +19,14 @@ module Igniter
19
19
  status == :stale
20
20
  end
21
21
 
22
+ def pending?
23
+ status == :pending
24
+ end
25
+
26
+ def running?
27
+ status == :running
28
+ end
29
+
22
30
  def succeeded?
23
31
  status == :succeeded
24
32
  end
@@ -26,6 +34,18 @@ module Igniter
26
34
  def failed?
27
35
  status == :failed
28
36
  end
37
+
38
+ def to_h
39
+ {
40
+ node_name: node.name,
41
+ status: status,
42
+ version: version,
43
+ resolved_at: resolved_at,
44
+ invalidated_by: invalidated_by,
45
+ value: value,
46
+ error: error
47
+ }
48
+ end
29
49
  end
30
50
  end
31
51
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class Planner
6
+ def initialize(execution)
7
+ @execution = execution
8
+ end
9
+
10
+ def targets_for_outputs(output_names = nil)
11
+ selected_outputs = if output_names
12
+ Array(output_names).map { |name| @execution.compiled_graph.fetch_output(name) }
13
+ else
14
+ @execution.compiled_graph.outputs
15
+ end
16
+
17
+ selected_outputs
18
+ .map(&:source_root)
19
+ .uniq
20
+ end
21
+
22
+ def plan(output_names = nil)
23
+ targets = targets_for_outputs(output_names)
24
+ nodes = relevant_nodes_for(targets)
25
+
26
+ node_entries = nodes.each_with_object({}) do |node, memo|
27
+ memo[node.name] = plan_entry(node)
28
+ end
29
+
30
+ {
31
+ targets: targets,
32
+ ready: node_entries.values.select { |entry| entry[:ready] }.map { |entry| entry[:name] },
33
+ blocked: node_entries.values.select { |entry| entry[:blocked] }.map { |entry| entry[:name] },
34
+ nodes: node_entries
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def relevant_nodes_for(targets)
41
+ seen = {}
42
+ ordered = []
43
+
44
+ targets.each do |target_name|
45
+ visit(@execution.compiled_graph.fetch_node(target_name), seen, ordered)
46
+ end
47
+
48
+ ordered
49
+ end
50
+
51
+ def visit(node, seen, ordered)
52
+ return if seen[node.name]
53
+
54
+ seen[node.name] = true
55
+ node.dependencies.each do |dependency_name|
56
+ dependency = dependency_node_for(dependency_name)
57
+ visit(dependency, seen, ordered)
58
+ end
59
+ ordered << node
60
+ end
61
+
62
+ def dependency_node_for(dependency_name)
63
+ dependency = @execution.compiled_graph.fetch_dependency(dependency_name)
64
+ return dependency if dependency.kind != :output
65
+
66
+ @execution.compiled_graph.fetch_node(dependency.source_root)
67
+ end
68
+
69
+ def plan_entry(node)
70
+ state = @execution.cache.fetch(node.name)
71
+ dependency_entries = node.dependencies.map { |dependency_name| dependency_entry(dependency_name) }
72
+ blocked_dependencies = dependency_entries.reject { |entry| entry[:satisfied] }.map { |entry| entry[:name] }
73
+ ready = resolution_required?(state) && blocked_dependencies.empty?
74
+
75
+ {
76
+ id: node.id,
77
+ name: node.name,
78
+ path: node.path,
79
+ kind: node.kind,
80
+ status: state&.status || :pending,
81
+ ready: ready,
82
+ blocked: !ready && resolution_required?(state),
83
+ dependencies: dependency_entries,
84
+ waiting_on: blocked_dependencies
85
+ }
86
+ end
87
+
88
+ def dependency_entry(dependency_name)
89
+ dependency = @execution.compiled_graph.fetch_dependency(dependency_name)
90
+ source_node = dependency.kind == :output ? @execution.compiled_graph.fetch_node(dependency.source_root) : dependency
91
+ state = @execution.cache.fetch(source_node.name)
92
+
93
+ {
94
+ name: dependency_name.to_sym,
95
+ source: source_node.name,
96
+ kind: dependency.kind,
97
+ status: state&.status || inferred_status(source_node),
98
+ satisfied: dependency_satisfied?(source_node, state)
99
+ }
100
+ end
101
+
102
+ def dependency_satisfied?(node, state)
103
+ case node.kind
104
+ when :input
105
+ input_available?(node)
106
+ else
107
+ state&.succeeded?
108
+ end
109
+ end
110
+
111
+ def inferred_status(node)
112
+ return :ready if node.kind == :input && input_available?(node)
113
+
114
+ :pending
115
+ end
116
+
117
+ def input_available?(node)
118
+ @execution.inputs.key?(node.name) || node.default?
119
+ end
120
+
121
+ def resolution_required?(state)
122
+ state.nil? || state.stale? || state.pending? || state.running?
123
+ end
124
+ end
125
+ end
126
+ end
@@ -9,8 +9,8 @@ module Igniter
9
9
 
10
10
  def resolve(node_name)
11
11
  node = @execution.compiled_graph.fetch_node(node_name)
12
- cached = @execution.cache.fetch(node.name)
13
- return cached if cached && !cached.stale?
12
+ resolution_status, cached = @execution.cache.begin_resolution(node)
13
+ return cached if resolution_status == :cached
14
14
 
15
15
  @execution.events.emit(:node_started, node: node, status: :running)
16
16
 
@@ -21,17 +21,30 @@ module Igniter
21
21
  resolve_compute(node)
22
22
  when :composition
23
23
  resolve_composition(node)
24
+ when :branch
25
+ resolve_branch(node)
26
+ when :collection
27
+ resolve_collection(node)
24
28
  else
25
29
  raise ResolutionError, "Unsupported node kind: #{node.kind}"
26
30
  end
27
31
 
28
32
  @execution.cache.write(state)
29
- @execution.events.emit(
30
- state.failed? ? :node_failed : :node_succeeded,
33
+ emit_resolution_event(node, state)
34
+ state
35
+ rescue PendingDependencyError => e
36
+ state = NodeState.new(
31
37
  node: node,
32
- status: state.status,
33
- payload: success_payload(node, state)
38
+ status: :pending,
39
+ value: Runtime::DeferredResult.build(
40
+ token: e.deferred_result.token,
41
+ payload: e.deferred_result.payload,
42
+ source_node: e.deferred_result.source_node,
43
+ waiting_on: e.deferred_result.waiting_on || node.name
44
+ )
34
45
  )
46
+ @execution.cache.write(state)
47
+ @execution.events.emit(:node_pending, node: node, status: :pending, payload: pending_payload(state))
35
48
  state
36
49
  rescue StandardError => e
37
50
  state = NodeState.new(node: node, status: :failed, error: normalize_error(e, node))
@@ -48,13 +61,13 @@ module Igniter
48
61
 
49
62
  def resolve_compute(node)
50
63
  dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
51
- dependency_state = resolve(dependency_name)
52
- raise dependency_state.error if dependency_state.failed?
53
-
54
- memo[dependency_name] = dependency_state.value
64
+ memo[dependency_name] = resolve_dependency_value(dependency_name)
55
65
  end
56
66
 
57
67
  value = call_compute(node.callable, dependencies)
68
+ return NodeState.new(node: node, status: :pending, value: normalize_deferred_result(value, node)) if deferred_result?(value)
69
+ value = normalize_guard_value(node, value)
70
+
58
71
  NodeState.new(node: node, status: :succeeded, value: value)
59
72
  end
60
73
 
@@ -62,8 +75,28 @@ module Igniter
62
75
  case callable
63
76
  when Proc
64
77
  callable.call(**dependencies)
78
+ when Class
79
+ call_compute_class(callable, dependencies)
65
80
  when Symbol, String
66
81
  @execution.contract_instance.public_send(callable.to_sym, **dependencies)
82
+ else
83
+ call_compute_object(callable, dependencies)
84
+ end
85
+ end
86
+
87
+ def call_compute_class(callable, dependencies)
88
+ if callable <= Igniter::Executor
89
+ callable.new(execution: @execution, contract: @execution.contract_instance).call(**dependencies)
90
+ elsif callable.respond_to?(:call)
91
+ callable.call(**dependencies)
92
+ else
93
+ raise ResolutionError, "Unsupported callable: #{callable}"
94
+ end
95
+ end
96
+
97
+ def call_compute_object(callable, dependencies)
98
+ if callable.respond_to?(:call)
99
+ callable.call(**dependencies)
67
100
  else
68
101
  raise ResolutionError, "Unsupported callable: #{callable.class}"
69
102
  end
@@ -71,10 +104,7 @@ module Igniter
71
104
 
72
105
  def resolve_composition(node)
73
106
  child_inputs = node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
74
- dependency_state = resolve(dependency_name)
75
- raise dependency_state.error if dependency_state.failed?
76
-
77
- memo[child_input_name] = dependency_state.value
107
+ memo[child_input_name] = resolve_dependency_value(dependency_name)
78
108
  end
79
109
 
80
110
  child_contract = node.contract_class.new(child_inputs)
@@ -85,8 +115,183 @@ module Igniter
85
115
  NodeState.new(node: node, status: :succeeded, value: child_contract.result)
86
116
  end
87
117
 
118
+ def resolve_branch(node)
119
+ selector_value = resolve_dependency_value(node.selector_dependency)
120
+ selected_case = node.cases.find { |entry| entry[:match] == selector_value }
121
+ selected_contract = selected_case ? selected_case[:contract] : node.default_contract
122
+ matched_case = selected_case ? selected_case[:match] : :default
123
+
124
+ raise BranchSelectionError, "Branch '#{node.name}' has no matching case and no default" unless selected_contract
125
+
126
+ context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
127
+ memo[dependency_name] = resolve_dependency_value(dependency_name)
128
+ end
129
+
130
+ child_inputs = if node.input_mapper?
131
+ map_branch_inputs(node, selector_value, context_values)
132
+ else
133
+ node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
134
+ memo[child_input_name] = resolve_dependency_value(dependency_name)
135
+ end
136
+ end
137
+
138
+ @execution.events.emit(
139
+ :branch_selected,
140
+ node: node,
141
+ status: :succeeded,
142
+ payload: {
143
+ selector: node.selector_dependency,
144
+ selector_value: selector_value,
145
+ matched_case: matched_case,
146
+ selected_contract: selected_contract.name || "AnonymousContract"
147
+ }
148
+ )
149
+
150
+ child_contract = selected_contract.new(child_inputs)
151
+ child_contract.resolve_all
152
+ child_error = child_contract.result.errors.values.first
153
+ raise child_error if child_error
154
+
155
+ NodeState.new(node: node, status: :succeeded, value: child_contract.result)
156
+ end
157
+
158
+ def map_branch_inputs(node, selector_value, context_values)
159
+ mapper = node.input_mapper
160
+
161
+ if mapper.is_a?(Symbol) || mapper.is_a?(String)
162
+ return @execution.contract_instance.public_send(mapper, selector: selector_value, **context_values)
163
+ end
164
+
165
+ mapper.call(selector: selector_value, **context_values)
166
+ end
167
+
168
+ def resolve_collection(node)
169
+ items = resolve_dependency_value(node.source_dependency)
170
+ context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
171
+ memo[dependency_name] = resolve_dependency_value(dependency_name)
172
+ end
173
+ normalized_items = normalize_collection_items(node, items, context_values)
174
+ collection_items = {}
175
+
176
+ normalized_items.each do |item_inputs|
177
+ item_key = extract_collection_key(node, item_inputs)
178
+ emit_collection_item_event(:collection_item_started, node, item_key, item_inputs: item_inputs)
179
+ child_contract = node.contract_class.new(item_inputs)
180
+ begin
181
+ child_contract.resolve_all
182
+ rescue Igniter::Error
183
+ nil
184
+ end
185
+ child_error = child_contract.execution.cache.values.find(&:failed?)&.error
186
+
187
+ if child_error
188
+ collection_items[item_key] = Runtime::CollectionResult::Item.new(
189
+ key: item_key,
190
+ status: :failed,
191
+ error: child_error
192
+ )
193
+ emit_collection_item_event(
194
+ :collection_item_failed,
195
+ node,
196
+ item_key,
197
+ error: child_error.message,
198
+ error_type: child_error.class.name,
199
+ child_execution_id: child_contract.execution.events.execution_id
200
+ )
201
+ raise child_error if node.mode == :fail_fast
202
+ else
203
+ collection_items[item_key] = Runtime::CollectionResult::Item.new(
204
+ key: item_key,
205
+ status: :succeeded,
206
+ result: child_contract.result
207
+ )
208
+ emit_collection_item_event(
209
+ :collection_item_succeeded,
210
+ node,
211
+ item_key,
212
+ child_execution_id: child_contract.execution.events.execution_id
213
+ )
214
+ end
215
+ end
216
+
217
+ NodeState.new(
218
+ node: node,
219
+ status: :succeeded,
220
+ value: Runtime::CollectionResult.new(items: collection_items, mode: node.mode)
221
+ )
222
+ end
223
+
224
+ def resolve_dependency_value(dependency_name)
225
+ if @execution.compiled_graph.node?(dependency_name)
226
+ dependency_state = resolve(dependency_name)
227
+ raise dependency_state.error if dependency_state.failed?
228
+ raise PendingDependencyError.new(dependency_state.value, context: pending_context(dependency_state.node)) if dependency_state.pending?
229
+
230
+ dependency_state.value
231
+ elsif @execution.compiled_graph.outputs_by_name.key?(dependency_name.to_sym)
232
+ output = @execution.compiled_graph.fetch_output(dependency_name)
233
+ value = @execution.send(:resolve_exported_output, output)
234
+ raise PendingDependencyError.new(value) if deferred_result?(value)
235
+
236
+ value
237
+ else
238
+ raise ResolutionError, "Unknown dependency: #{dependency_name}"
239
+ end
240
+ end
241
+
242
+ def deferred_result?(value)
243
+ value.is_a?(Runtime::DeferredResult)
244
+ end
245
+
246
+ def normalize_deferred_result(value, node)
247
+ Runtime::DeferredResult.build(
248
+ token: value.token,
249
+ payload: value.payload,
250
+ source_node: value.source_node || node.name,
251
+ waiting_on: value.waiting_on
252
+ )
253
+ end
254
+
255
+ def emit_resolution_event(node, state)
256
+ event_type =
257
+ if state.failed?
258
+ :node_failed
259
+ elsif state.pending?
260
+ :node_pending
261
+ else
262
+ :node_succeeded
263
+ end
264
+
265
+ payload = state.pending? ? pending_payload(state) : success_payload(node, state)
266
+ @execution.events.emit(event_type, node: node, status: state.status, payload: payload)
267
+ end
268
+
269
+ def emit_collection_item_event(type, node, item_key, payload = {})
270
+ @execution.events.emit(
271
+ type,
272
+ node: node,
273
+ payload: payload.merge(item_key: item_key)
274
+ )
275
+ end
276
+
277
+ def pending_payload(state)
278
+ return {} unless state.value.is_a?(Runtime::DeferredResult)
279
+
280
+ state.value.to_h
281
+ end
282
+
283
+ def pending_context(node)
284
+ {
285
+ graph: @execution.compiled_graph.name,
286
+ node_id: node.id,
287
+ node_name: node.name,
288
+ node_path: node.path,
289
+ source_location: node.source_location
290
+ }
291
+ end
292
+
88
293
  def success_payload(node, state)
89
- return {} unless node.kind == :composition
294
+ return {} unless %i[composition branch].include?(node.kind)
90
295
  return {} unless state.value.is_a?(Igniter::Runtime::Result)
91
296
 
92
297
  {
@@ -109,6 +314,96 @@ module Igniter
109
314
  }
110
315
  )
111
316
  end
317
+
318
+ def normalize_guard_value(node, value)
319
+ return value unless node.respond_to?(:guard?) && node.guard?
320
+ return true if value
321
+
322
+ raise ResolutionError.new(
323
+ node.metadata[:guard_message] || "Guard '#{node.name}' failed",
324
+ context: {
325
+ graph: @execution.compiled_graph.name,
326
+ node_id: node.id,
327
+ node_name: node.name,
328
+ node_path: node.path,
329
+ source_location: node.source_location
330
+ }
331
+ )
332
+ end
333
+
334
+ def normalize_collection_items(node, items, context_values = {})
335
+ if node.input_mapper? && items.is_a?(Hash)
336
+ items = items.to_a
337
+ end
338
+
339
+ unless items.is_a?(Array)
340
+ raise CollectionInputError.new(
341
+ "Collection '#{node.name}' expects an array, got #{items.class}",
342
+ context: collection_context(node)
343
+ )
344
+ end
345
+
346
+ mapped_items = if node.input_mapper?
347
+ items.map { |item| map_collection_item_inputs(node, item, context_values) }
348
+ else
349
+ items
350
+ end
351
+
352
+ mapped_items.each do |item|
353
+ next if item.is_a?(Hash)
354
+
355
+ raise CollectionInputError.new(
356
+ "Collection '#{node.name}' expects item hashes, got #{item.class}",
357
+ context: collection_context(node)
358
+ )
359
+ end
360
+
361
+ ensure_unique_collection_keys!(node, mapped_items)
362
+ mapped_items.map { |item| item.transform_keys(&:to_sym) }
363
+ end
364
+
365
+ def map_collection_item_inputs(node, item, context_values)
366
+ mapper = node.input_mapper
367
+
368
+ if mapper.is_a?(Symbol) || mapper.is_a?(String)
369
+ return @execution.contract_instance.public_send(mapper, item: item, **context_values)
370
+ end
371
+
372
+ mapper.call(item: item, **context_values)
373
+ end
374
+
375
+ def extract_collection_key(node, item_inputs)
376
+ item_inputs.fetch(node.key_name)
377
+ rescue KeyError
378
+ raise CollectionKeyError.new(
379
+ "Collection '#{node.name}' item is missing key '#{node.key_name}'",
380
+ context: collection_context(node)
381
+ )
382
+ end
383
+
384
+ def ensure_unique_collection_keys!(node, items)
385
+ keys = items.map do |item|
386
+ item.fetch(node.key_name) { raise CollectionKeyError.new("Collection '#{node.name}' item is missing key '#{node.key_name}'", context: collection_context(node)) }
387
+ end
388
+
389
+ duplicates = keys.group_by(&:itself).select { |_key, entries| entries.size > 1 }.keys
390
+ return if duplicates.empty?
391
+
392
+ raise CollectionKeyError.new(
393
+ "Collection '#{node.name}' has duplicate keys: #{duplicates.join(', ')}",
394
+ context: collection_context(node)
395
+ )
396
+ end
397
+
398
+ def collection_context(node)
399
+ {
400
+ graph: @execution.compiled_graph.name,
401
+ node_id: node.id,
402
+ node_name: node.name,
403
+ node_path: node.path,
404
+ source_location: node.source_location
405
+ }
406
+ end
112
407
  end
113
408
  end
114
409
  end
@@ -18,7 +18,7 @@ module Igniter
18
18
 
19
19
  def success?
20
20
  @execution.resolve_all
21
- !failed?
21
+ !failed? && !pending?
22
22
  end
23
23
 
24
24
  def failed?
@@ -26,6 +26,11 @@ module Igniter
26
26
  @execution.cache.values.any?(&:failed?)
27
27
  end
28
28
 
29
+ def pending?
30
+ @execution.resolve_all
31
+ @execution.cache.values.any?(&:pending?)
32
+ end
33
+
29
34
  def errors
30
35
  @execution.resolve_all
31
36
  @execution.cache.values.each_with_object({}) do |state, memo|
@@ -52,8 +57,9 @@ module Igniter
52
57
  graph: @execution.compiled_graph.name,
53
58
  execution_id: @execution.events.execution_id,
54
59
  outputs: to_h,
55
- success: !failed?,
60
+ success: !failed? && !pending?,
56
61
  failed: failed?,
62
+ pending: pending?,
57
63
  errors: serialize_errors(errors),
58
64
  states: states
59
65
  }
@@ -73,6 +79,8 @@ module Igniter
73
79
  case value
74
80
  when Result
75
81
  value.as_json
82
+ when CollectionResult
83
+ value.as_json
76
84
  when Array
77
85
  value.map { |item| serialize_value(item) }
78
86
  else
@@ -82,8 +90,12 @@ module Igniter
82
90
 
83
91
  def serialize_output_value(value)
84
92
  case value
93
+ when DeferredResult
94
+ value.as_json
85
95
  when Result
86
96
  value.to_h
97
+ when CollectionResult
98
+ value.to_h
87
99
  when Array
88
100
  value.map { |item| serialize_output_value(item) }
89
101
  else
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class RunnerFactory
6
+ def self.build(strategy, execution, resolver:, max_workers: nil, store: nil)
7
+ case strategy.to_sym
8
+ when :inline
9
+ Runners::InlineRunner.new(execution, resolver: resolver, max_workers: max_workers)
10
+ when :store
11
+ Runners::StoreRunner.new(execution, resolver: resolver, store: store, max_workers: max_workers)
12
+ when :thread_pool
13
+ Runners::ThreadPoolRunner.new(execution, resolver: resolver, max_workers: max_workers)
14
+ else
15
+ raise CompileError, "Unknown execution runner strategy: #{strategy}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end