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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +4 -0
- data/COMMITS.md +196 -0
- data/LICENSE +21 -0
- data/README.md +481 -0
- data/Rakefile +15 -0
- data/benchmarks/parallel_vs_sequential.rb +98 -0
- data/benchmarks/pipeline_overhead.rb +130 -0
- data/docs/api/middleware.md +468 -0
- data/docs/api/parallel-step.md +363 -0
- data/docs/api/pipeline.md +382 -0
- data/docs/api/result.md +375 -0
- data/docs/concurrent/best-practices.md +687 -0
- data/docs/concurrent/introduction.md +246 -0
- data/docs/concurrent/parallel-steps.md +418 -0
- data/docs/concurrent/performance.md +481 -0
- data/docs/core-concepts/flow-control.md +452 -0
- data/docs/core-concepts/middleware.md +389 -0
- data/docs/core-concepts/overview.md +219 -0
- data/docs/core-concepts/pipeline.md +315 -0
- data/docs/core-concepts/result.md +168 -0
- data/docs/core-concepts/steps.md +391 -0
- data/docs/development/benchmarking.md +443 -0
- data/docs/development/contributing.md +380 -0
- data/docs/development/dagwood-concepts.md +435 -0
- data/docs/development/testing.md +514 -0
- data/docs/getting-started/examples.md +197 -0
- data/docs/getting-started/installation.md +62 -0
- data/docs/getting-started/quick-start.md +218 -0
- data/docs/guides/choosing-concurrency-model.md +441 -0
- data/docs/guides/complex-workflows.md +440 -0
- data/docs/guides/data-fetching.md +478 -0
- data/docs/guides/error-handling.md +635 -0
- data/docs/guides/file-processing.md +505 -0
- data/docs/guides/validation-patterns.md +496 -0
- data/docs/index.md +169 -0
- data/examples/.gitignore +3 -0
- data/examples/01_basic_pipeline.rb +112 -0
- data/examples/02_error_handling.rb +178 -0
- data/examples/03_middleware.rb +186 -0
- data/examples/04_parallel_automatic.rb +221 -0
- data/examples/05_parallel_explicit.rb +279 -0
- data/examples/06_real_world_ecommerce.rb +288 -0
- data/examples/07_real_world_etl.rb +277 -0
- data/examples/08_graph_visualization.rb +246 -0
- data/examples/09_pipeline_visualization.rb +266 -0
- data/examples/10_concurrency_control.rb +235 -0
- data/examples/11_sequential_dependencies.rb +243 -0
- data/examples/12_none_constant.rb +161 -0
- data/examples/README.md +374 -0
- data/examples/regression_test/01_basic_pipeline.txt +38 -0
- data/examples/regression_test/02_error_handling.txt +92 -0
- data/examples/regression_test/03_middleware.txt +61 -0
- data/examples/regression_test/04_parallel_automatic.txt +86 -0
- data/examples/regression_test/05_parallel_explicit.txt +80 -0
- data/examples/regression_test/06_real_world_ecommerce.txt +53 -0
- data/examples/regression_test/07_real_world_etl.txt +58 -0
- data/examples/regression_test/08_graph_visualization.txt +429 -0
- data/examples/regression_test/09_pipeline_visualization.txt +305 -0
- data/examples/regression_test/10_concurrency_control.txt +96 -0
- data/examples/regression_test/11_sequential_dependencies.txt +86 -0
- data/examples/regression_test/12_none_constant.txt +64 -0
- data/examples/regression_test.rb +105 -0
- data/lib/simple_flow/dependency_graph.rb +120 -0
- data/lib/simple_flow/dependency_graph_visualizer.rb +326 -0
- data/lib/simple_flow/middleware.rb +36 -0
- data/lib/simple_flow/parallel_executor.rb +80 -0
- data/lib/simple_flow/pipeline.rb +405 -0
- data/lib/simple_flow/result.rb +88 -0
- data/lib/simple_flow/step_tracker.rb +58 -0
- data/lib/simple_flow/version.rb +5 -0
- data/lib/simple_flow.rb +41 -0
- data/mkdocs.yml +146 -0
- data/pipeline_graph.dot +51 -0
- data/pipeline_graph.html +60 -0
- data/pipeline_graph.mmd +19 -0
- 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
|