simple_flow 0.1.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.rubocop.yml +57 -0
  5. data/CHANGELOG.md +4 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE +21 -0
  8. data/README.md +481 -0
  9. data/Rakefile +15 -0
  10. data/benchmarks/parallel_vs_sequential.rb +98 -0
  11. data/benchmarks/pipeline_overhead.rb +130 -0
  12. data/docs/api/middleware.md +468 -0
  13. data/docs/api/parallel-step.md +363 -0
  14. data/docs/api/pipeline.md +382 -0
  15. data/docs/api/result.md +375 -0
  16. data/docs/concurrent/best-practices.md +687 -0
  17. data/docs/concurrent/introduction.md +246 -0
  18. data/docs/concurrent/parallel-steps.md +418 -0
  19. data/docs/concurrent/performance.md +481 -0
  20. data/docs/core-concepts/flow-control.md +452 -0
  21. data/docs/core-concepts/middleware.md +389 -0
  22. data/docs/core-concepts/overview.md +219 -0
  23. data/docs/core-concepts/pipeline.md +315 -0
  24. data/docs/core-concepts/result.md +168 -0
  25. data/docs/core-concepts/steps.md +391 -0
  26. data/docs/development/benchmarking.md +443 -0
  27. data/docs/development/contributing.md +380 -0
  28. data/docs/development/dagwood-concepts.md +435 -0
  29. data/docs/development/testing.md +514 -0
  30. data/docs/getting-started/examples.md +197 -0
  31. data/docs/getting-started/installation.md +62 -0
  32. data/docs/getting-started/quick-start.md +218 -0
  33. data/docs/guides/choosing-concurrency-model.md +441 -0
  34. data/docs/guides/complex-workflows.md +440 -0
  35. data/docs/guides/data-fetching.md +478 -0
  36. data/docs/guides/error-handling.md +635 -0
  37. data/docs/guides/file-processing.md +505 -0
  38. data/docs/guides/validation-patterns.md +496 -0
  39. data/docs/index.md +169 -0
  40. data/examples/.gitignore +3 -0
  41. data/examples/01_basic_pipeline.rb +112 -0
  42. data/examples/02_error_handling.rb +178 -0
  43. data/examples/03_middleware.rb +186 -0
  44. data/examples/04_parallel_automatic.rb +221 -0
  45. data/examples/05_parallel_explicit.rb +279 -0
  46. data/examples/06_real_world_ecommerce.rb +288 -0
  47. data/examples/07_real_world_etl.rb +277 -0
  48. data/examples/08_graph_visualization.rb +246 -0
  49. data/examples/09_pipeline_visualization.rb +266 -0
  50. data/examples/10_concurrency_control.rb +235 -0
  51. data/examples/11_sequential_dependencies.rb +243 -0
  52. data/examples/12_none_constant.rb +161 -0
  53. data/examples/README.md +374 -0
  54. data/examples/regression_test/01_basic_pipeline.txt +38 -0
  55. data/examples/regression_test/02_error_handling.txt +92 -0
  56. data/examples/regression_test/03_middleware.txt +61 -0
  57. data/examples/regression_test/04_parallel_automatic.txt +86 -0
  58. data/examples/regression_test/05_parallel_explicit.txt +80 -0
  59. data/examples/regression_test/06_real_world_ecommerce.txt +53 -0
  60. data/examples/regression_test/07_real_world_etl.txt +58 -0
  61. data/examples/regression_test/08_graph_visualization.txt +429 -0
  62. data/examples/regression_test/09_pipeline_visualization.txt +305 -0
  63. data/examples/regression_test/10_concurrency_control.txt +96 -0
  64. data/examples/regression_test/11_sequential_dependencies.txt +86 -0
  65. data/examples/regression_test/12_none_constant.txt +64 -0
  66. data/examples/regression_test.rb +105 -0
  67. data/lib/simple_flow/dependency_graph.rb +120 -0
  68. data/lib/simple_flow/dependency_graph_visualizer.rb +326 -0
  69. data/lib/simple_flow/middleware.rb +36 -0
  70. data/lib/simple_flow/parallel_executor.rb +80 -0
  71. data/lib/simple_flow/pipeline.rb +405 -0
  72. data/lib/simple_flow/result.rb +88 -0
  73. data/lib/simple_flow/step_tracker.rb +58 -0
  74. data/lib/simple_flow/version.rb +5 -0
  75. data/lib/simple_flow.rb +41 -0
  76. data/mkdocs.yml +146 -0
  77. data/pipeline_graph.dot +51 -0
  78. data/pipeline_graph.html +60 -0
  79. data/pipeline_graph.mmd +19 -0
  80. metadata +127 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tsort'
4
+
5
+ module SimpleFlow
6
+ ##
7
+ # DependencyGraph manages dependencies between pipeline steps and determines
8
+ # which steps can be executed in parallel. This is adapted from the dagwood gem
9
+ # (https://github.com/rewindio/dagwood) to work with SimpleFlow pipelines.
10
+ #
11
+ # Example:
12
+ # graph = SimpleFlow::DependencyGraph.new(
13
+ # fetch_user: [],
14
+ # fetch_orders: [:fetch_user],
15
+ # fetch_products: [:fetch_user],
16
+ # calculate_total: [:fetch_orders, :fetch_products]
17
+ # )
18
+ #
19
+ # graph.parallel_order
20
+ # # => [[:fetch_user], [:fetch_orders, :fetch_products], [:calculate_total]]
21
+ #
22
+ class DependencyGraph
23
+ include TSort
24
+
25
+ attr_reader :dependencies
26
+
27
+ # @param dependencies [Hash]
28
+ # A hash of the form { step1: [:step2, :step3], step2: [:step3], step3: []}
29
+ # would mean that "step1" depends on step2 and step3, step2 depends on step3
30
+ # and step3 has no dependencies. Nil and missing values will be converted to [].
31
+ def initialize(dependencies)
32
+ @dependencies = Hash.new([]).merge(dependencies.transform_values { |v| v.nil? ? [] : Array(v).sort })
33
+ end
34
+
35
+ # Returns steps in topological order (dependencies first)
36
+ # @return [Array] ordered list of step names
37
+ def order
38
+ @order ||= tsort
39
+ end
40
+
41
+ # Returns steps in reverse topological order
42
+ # @return [Array] reverse ordered list of step names
43
+ def reverse_order
44
+ @reverse_order ||= order.reverse
45
+ end
46
+
47
+ # Groups steps that can be executed in parallel.
48
+ # Steps can run in parallel if:
49
+ # 1) They have the exact same dependencies OR
50
+ # 2) All of a step's dependencies have been resolved in previous groups
51
+ #
52
+ # @return [Array<Array>] array of groups, where each group can run in parallel
53
+ def parallel_order
54
+ groups = []
55
+ ungrouped_dependencies = order.dup
56
+
57
+ until ungrouped_dependencies.empty?
58
+ # Start this group with the first dependency we haven't grouped yet
59
+ group_starter = ungrouped_dependencies.delete_at(0)
60
+ group = [group_starter]
61
+
62
+ ungrouped_dependencies.each do |ungrouped_dependency|
63
+ same_priority = @dependencies[ungrouped_dependency].all? do |sub_dependency|
64
+ groups.reduce(false) { |found, g| found || g.include?(sub_dependency) }
65
+ end
66
+
67
+ group << ungrouped_dependency if same_priority
68
+ end
69
+
70
+ # Remove dependencies we managed to group
71
+ ungrouped_dependencies -= group
72
+
73
+ groups << group.sort
74
+ end
75
+
76
+ groups
77
+ end
78
+
79
+ # Generate a subgraph starting at the given node
80
+ # @param node [Symbol] the starting node
81
+ # @return [DependencyGraph] a new graph containing only the node and its dependencies
82
+ def subgraph(node)
83
+ return self.class.new({}) unless @dependencies.key? node
84
+
85
+ # Add the given node and its dependencies to our hash
86
+ hash = {}
87
+ hash[node] = @dependencies[node]
88
+
89
+ # For every dependency of the given node, recursively create a subgraph and merge it into our result
90
+ @dependencies[node].each { |dep| hash.merge! subgraph(dep).dependencies }
91
+
92
+ self.class.new hash
93
+ end
94
+
95
+ # Returns a new graph containing all dependencies from this graph and the given graph.
96
+ # If both graphs depend on the same item, but that item's sub-dependencies differ, the
97
+ # resulting graph will depend on the union of both.
98
+ # @param other [DependencyGraph] another dependency graph
99
+ # @return [DependencyGraph] merged graph
100
+ def merge(other)
101
+ all_dependencies = {}
102
+
103
+ (dependencies.keys | other.dependencies.keys).each do |key|
104
+ all_dependencies[key] = dependencies[key] | other.dependencies[key]
105
+ end
106
+
107
+ self.class.new all_dependencies
108
+ end
109
+
110
+ private
111
+
112
+ def tsort_each_child(node, &block)
113
+ @dependencies.fetch(node, []).each(&block)
114
+ end
115
+
116
+ def tsort_each_node(&block)
117
+ @dependencies.each_key(&block)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleFlow
4
+ ##
5
+ # DependencyGraphVisualizer generates visual representations of dependency graphs.
6
+ # Supports ASCII art for terminal display and Graphviz DOT format for graph images.
7
+ #
8
+ # Example:
9
+ # graph = SimpleFlow::DependencyGraph.new(
10
+ # fetch_user: [],
11
+ # fetch_orders: [:fetch_user],
12
+ # fetch_products: [:fetch_user],
13
+ # calculate: [:fetch_orders, :fetch_products]
14
+ # )
15
+ #
16
+ # visualizer = SimpleFlow::DependencyGraphVisualizer.new(graph)
17
+ # puts visualizer.to_ascii
18
+ # File.write('graph.dot', visualizer.to_dot)
19
+ #
20
+ class DependencyGraphVisualizer
21
+ attr_reader :graph
22
+
23
+ # @param graph [DependencyGraph] the dependency graph to visualize
24
+ def initialize(graph)
25
+ @graph = graph
26
+ end
27
+
28
+ # Generate ASCII art representation of the dependency graph
29
+ # @param show_groups [Boolean] whether to show parallel execution groups
30
+ # @return [String] ASCII art representation
31
+ def to_ascii(show_groups: true)
32
+ output = []
33
+ output << "Dependency Graph"
34
+ output << "=" * 60
35
+ output << ""
36
+
37
+ # Show dependencies
38
+ output << "Dependencies:"
39
+ @graph.dependencies.each do |step, deps|
40
+ deps_str = deps.empty? ? "(none)" : deps.map { |d| ":#{d}" }.join(", ")
41
+ output << " :#{step}"
42
+ output << " └─ depends on: #{deps_str}"
43
+ end
44
+ output << ""
45
+
46
+ # Show execution order
47
+ output << "Execution Order (sequential):"
48
+ order = @graph.order
49
+ order.each_with_index do |step, idx|
50
+ prefix = idx == 0 ? " " : " ↓ "
51
+ output << "#{prefix}:#{step}"
52
+ end
53
+ output << ""
54
+
55
+ if show_groups
56
+ # Show parallel groups
57
+ output << "Parallel Execution Groups:"
58
+ parallel_groups = @graph.parallel_order
59
+ parallel_groups.each_with_index do |group, idx|
60
+ output << " Group #{idx + 1}:"
61
+ if group.size == 1
62
+ output << " └─ :#{group.first} (sequential)"
63
+ else
64
+ output << " ├─ Parallel execution of #{group.size} steps:"
65
+ group.each_with_index do |step, step_idx|
66
+ prefix = step_idx == group.size - 1 ? " └─" : " ├─"
67
+ output << "#{prefix} :#{step}"
68
+ end
69
+ end
70
+ end
71
+ output << ""
72
+
73
+ # Show execution tree
74
+ output << "Execution Tree:"
75
+ output.concat(build_tree(parallel_groups))
76
+ end
77
+
78
+ output.join("\n")
79
+ end
80
+
81
+ # Generate Graphviz DOT format for the dependency graph
82
+ # @param include_groups [Boolean] whether to color-code parallel groups
83
+ # @param orientation [String] graph orientation: 'TB' (top-to-bottom), 'LR' (left-to-right)
84
+ # @return [String] DOT format representation
85
+ def to_dot(include_groups: true, orientation: 'TB')
86
+ lines = []
87
+ lines << "digraph DependencyGraph {"
88
+ lines << " rankdir=#{orientation};"
89
+ lines << " node [shape=box, style=rounded];"
90
+ lines << ""
91
+
92
+ if include_groups
93
+ # Color-code parallel groups
94
+ parallel_groups = @graph.parallel_order
95
+ colors = ['lightblue', 'lightgreen', 'lightyellow', 'lightpink', 'lightgray']
96
+
97
+ parallel_groups.each_with_index do |group, idx|
98
+ color = colors[idx % colors.size]
99
+ lines << " // Group #{idx + 1}"
100
+ group.each do |step|
101
+ lines << " #{step} [fillcolor=#{color}, style=\"rounded,filled\"];"
102
+ end
103
+ end
104
+ lines << ""
105
+ end
106
+
107
+ # Add nodes and edges
108
+ lines << " // Dependencies"
109
+ @graph.dependencies.each do |step, deps|
110
+ if deps.empty?
111
+ lines << " #{step};"
112
+ else
113
+ deps.each do |dep|
114
+ lines << " #{dep} -> #{step};"
115
+ end
116
+ end
117
+ end
118
+
119
+ lines << ""
120
+ lines << " // Legend"
121
+ if include_groups
122
+ lines << " subgraph cluster_legend {"
123
+ lines << " label=\"Parallel Groups\";"
124
+ lines << " style=dashed;"
125
+ parallel_groups.each_with_index do |group, idx|
126
+ next if group.empty?
127
+ color = colors[idx % colors.size]
128
+ lines << " legend_#{idx} [label=\"Group #{idx + 1} (#{group.size} step#{'s' if group.size > 1})\", fillcolor=#{color}, style=\"rounded,filled\"];"
129
+ end
130
+ lines << " }"
131
+ end
132
+
133
+ lines << "}"
134
+ lines.join("\n")
135
+ end
136
+
137
+ # Generate Mermaid diagram format for the dependency graph
138
+ # @return [String] Mermaid format representation
139
+ def to_mermaid
140
+ lines = []
141
+ lines << "graph TD"
142
+
143
+ @graph.dependencies.each do |step, deps|
144
+ if deps.empty?
145
+ lines << " #{step}[#{step}]"
146
+ else
147
+ deps.each do |dep|
148
+ lines << " #{dep}[#{dep}] --> #{step}[#{step}]"
149
+ end
150
+ end
151
+ end
152
+
153
+ # Add styling for parallel groups
154
+ parallel_groups = @graph.parallel_order
155
+ parallel_groups.each_with_index do |group, idx|
156
+ next if group.size <= 1
157
+ lines << " classDef group#{idx} fill:##{['9cf', '9f9', 'ff9', 'f9f', 'ccc'][idx % 5]}"
158
+ group.each do |step|
159
+ lines << " class #{step} group#{idx}"
160
+ end
161
+ end
162
+
163
+ lines.join("\n")
164
+ end
165
+
166
+ # Generate a simple text-based execution plan
167
+ # @return [String] text execution plan
168
+ def to_execution_plan
169
+ output = []
170
+ output << "Execution Plan"
171
+ output << "=" * 60
172
+ output << ""
173
+
174
+ parallel_groups = @graph.parallel_order
175
+ total_steps = @graph.dependencies.size
176
+
177
+ output << "Total Steps: #{total_steps}"
178
+ output << "Execution Phases: #{parallel_groups.size}"
179
+ output << ""
180
+
181
+ parallel_groups.each_with_index do |group, idx|
182
+ output << "Phase #{idx + 1}:"
183
+ if group.size == 1
184
+ output << " → Execute :#{group.first}"
185
+ else
186
+ output << " ⚡ Execute in parallel:"
187
+ group.each do |step|
188
+ deps = @graph.dependencies[step]
189
+ deps_str = deps.empty? ? "no dependencies" : "after #{deps.map { |d| ":#{d}" }.join(', ')}"
190
+ output << " • :#{step} (#{deps_str})"
191
+ end
192
+ end
193
+ output << ""
194
+ end
195
+
196
+ # Calculate potential speedup
197
+ sequential_cost = total_steps
198
+ parallel_cost = parallel_groups.size
199
+ speedup = (sequential_cost.to_f / parallel_cost).round(2)
200
+
201
+ output << "Performance Estimate:"
202
+ output << " Sequential execution: #{sequential_cost} time units"
203
+ output << " Parallel execution: #{parallel_cost} time units"
204
+ output << " Potential speedup: #{speedup}x"
205
+
206
+ output.join("\n")
207
+ end
208
+
209
+ # Generate HTML page with interactive visualization using vis.js
210
+ # @param title [String] page title
211
+ # @return [String] HTML page content
212
+ def to_html(title: "Dependency Graph")
213
+ parallel_groups = @graph.parallel_order
214
+ nodes = []
215
+ edges = []
216
+
217
+ # Build nodes with group coloring
218
+ group_colors = ['#A8D5FF', '#A8FFA8', '#FFFFA8', '#FFA8FF', '#D3D3D3']
219
+ step_to_group = {}
220
+ parallel_groups.each_with_index do |group, idx|
221
+ group.each { |step| step_to_group[step] = idx }
222
+ end
223
+
224
+ @graph.dependencies.keys.each do |step|
225
+ group_idx = step_to_group[step] || 0
226
+ nodes << {
227
+ id: step.to_s,
228
+ label: step.to_s,
229
+ color: group_colors[group_idx % group_colors.size],
230
+ level: parallel_groups.index { |g| g.include?(step) }
231
+ }
232
+ end
233
+
234
+ # Build edges
235
+ @graph.dependencies.each do |step, deps|
236
+ deps.each do |dep|
237
+ edges << { from: dep.to_s, to: step.to_s }
238
+ end
239
+ end
240
+
241
+ html = <<~HTML
242
+ <!DOCTYPE html>
243
+ <html>
244
+ <head>
245
+ <title>#{title}</title>
246
+ <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
247
+ <style>
248
+ body { font-family: Arial, sans-serif; margin: 20px; }
249
+ #graph { width: 100%; height: 600px; border: 1px solid #ddd; }
250
+ .info { margin-top: 20px; padding: 15px; background: #f5f5f5; border-radius: 5px; }
251
+ .legend { display: flex; gap: 20px; margin-top: 10px; }
252
+ .legend-item { display: flex; align-items: center; gap: 5px; }
253
+ .legend-color { width: 20px; height: 20px; border-radius: 3px; }
254
+ </style>
255
+ </head>
256
+ <body>
257
+ <h1>#{title}</h1>
258
+ <div id="graph"></div>
259
+ <div class="info">
260
+ <h3>Execution Groups (Parallel)</h3>
261
+ <div class="legend">
262
+ #{parallel_groups.map.with_index { |group, idx|
263
+ "<div class='legend-item'><div class='legend-color' style='background: #{group_colors[idx % group_colors.size]}'></div><span>Group #{idx + 1}: #{group.join(', ')}</span></div>"
264
+ }.join("\n ")}
265
+ </div>
266
+ </div>
267
+ <script>
268
+ var nodes = new vis.DataSet(#{nodes.to_json});
269
+ var edges = new vis.DataSet(#{edges.to_json});
270
+ var container = document.getElementById('graph');
271
+ var data = { nodes: nodes, edges: edges };
272
+ var options = {
273
+ layout: {
274
+ hierarchical: {
275
+ direction: 'UD',
276
+ sortMethod: 'directed',
277
+ levelSeparation: 150
278
+ }
279
+ },
280
+ edges: {
281
+ arrows: 'to',
282
+ smooth: { type: 'cubicBezier' }
283
+ },
284
+ nodes: {
285
+ shape: 'box',
286
+ margin: 10,
287
+ widthConstraint: { minimum: 100, maximum: 200 }
288
+ },
289
+ physics: {
290
+ enabled: false
291
+ }
292
+ };
293
+ var network = new vis.Network(container, data, options);
294
+ </script>
295
+ </body>
296
+ </html>
297
+ HTML
298
+
299
+ html
300
+ end
301
+
302
+ private
303
+
304
+ def build_tree(parallel_groups)
305
+ output = []
306
+ parallel_groups.each_with_index do |group, idx|
307
+ is_last = idx == parallel_groups.size - 1
308
+
309
+ if group.size == 1
310
+ prefix = is_last ? " └─" : " ├─"
311
+ output << "#{prefix} :#{group.first}"
312
+ else
313
+ prefix = is_last ? " └─" : " ├─"
314
+ output << "#{prefix} [Parallel]"
315
+ group.each_with_index do |step, step_idx|
316
+ is_last_step = step_idx == group.size - 1
317
+ connector = is_last ? " " : " │ "
318
+ step_prefix = is_last_step ? "└─" : "├─"
319
+ output << "#{connector}#{step_prefix} :#{step}"
320
+ end
321
+ end
322
+ end
323
+ output
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,36 @@
1
+ module SimpleFlow
2
+ module MiddleWare
3
+ class Logging
4
+ def initialize(callable, logger = nil)
5
+ @callable, @logger = callable, logger
6
+ end
7
+
8
+ def call(result)
9
+ logger.info("Before call")
10
+ result = @callable.call(result)
11
+ logger.info("After call")
12
+ result
13
+ end
14
+
15
+ private
16
+
17
+ def logger
18
+ @logger ||= Logger.new($stdout)
19
+ end
20
+ end
21
+
22
+ class Instrumentation
23
+ def initialize(callable, api_key: nil)
24
+ @callable, @api_key = callable, api_key
25
+ end
26
+
27
+ def call(result)
28
+ start_time = Time.now
29
+ result = @callable.call(result)
30
+ duration = Time.now - start_time
31
+ puts "Instrumentation: #{@api_key} took #{duration}s"
32
+ result
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'async'
5
+ require 'async/barrier'
6
+ ASYNC_AVAILABLE = true
7
+ rescue LoadError
8
+ ASYNC_AVAILABLE = false
9
+ end
10
+
11
+ module SimpleFlow
12
+ ##
13
+ # ParallelExecutor handles parallel execution of steps.
14
+ # Uses the async gem for fiber-based concurrency if available,
15
+ # falls back to Ruby threads otherwise.
16
+ #
17
+ class ParallelExecutor
18
+ # Execute a group of steps in parallel
19
+ # @param steps [Array<Proc>] array of callable steps
20
+ # @param result [Result] the input result
21
+ # @param concurrency [Symbol] concurrency model (:auto, :threads, :async)
22
+ # @return [Array<Result>] array of results from each step
23
+ def self.execute_parallel(steps, result, concurrency: :auto)
24
+ case concurrency
25
+ when :auto
26
+ # Auto-detect: use async if available, otherwise threads
27
+ ASYNC_AVAILABLE ? execute_with_async(steps, result) : execute_with_threads(steps, result)
28
+ when :threads
29
+ execute_with_threads(steps, result)
30
+ when :async
31
+ raise ArgumentError, "Async gem not available" unless ASYNC_AVAILABLE
32
+ execute_with_async(steps, result)
33
+ else
34
+ raise ArgumentError, "Invalid concurrency option: #{concurrency.inspect}"
35
+ end
36
+ end
37
+
38
+ # Execute steps with async gem (fiber-based concurrency)
39
+ # @param steps [Array<Proc>] array of callable steps
40
+ # @param result [Result] the input result
41
+ # @return [Array<Result>] array of results from each step
42
+ def self.execute_with_async(steps, result)
43
+ results = []
44
+
45
+ Async do
46
+ barrier = Async::Barrier.new
47
+ tasks = []
48
+
49
+ steps.each do |step|
50
+ tasks << barrier.async do
51
+ step.call(result)
52
+ end
53
+ end
54
+
55
+ barrier.wait
56
+ results = tasks.map(&:result)
57
+ end
58
+
59
+ results
60
+ end
61
+
62
+ # Execute steps with Ruby threads (fallback for true parallelism)
63
+ # @param steps [Array<Proc>] array of callable steps
64
+ # @param result [Result] the input result
65
+ # @return [Array<Result>] array of results from each step
66
+ def self.execute_with_threads(steps, result)
67
+ threads = steps.map do |step|
68
+ Thread.new { step.call(result) }
69
+ end
70
+
71
+ threads.map(&:value)
72
+ end
73
+
74
+ # Check if async is available
75
+ # @return [Boolean]
76
+ def self.async_available?
77
+ ASYNC_AVAILABLE
78
+ end
79
+ end
80
+ end